mirror of https://github.com/tailscale/tailscale/
all: add ts_omit_serve, start making tailscale serve/funnel be modular
tailscaled tailscale combined (linux/amd64)
29853147 17384418 31412596 omitting everything
+ 621570 + 219277 + 554256 .. add serve
Updates #17128
Change-Id: I87c2c6c3d3fc2dc026c3de8ef7000a813b41d31c
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
pull/17156/head
parent
5b5ae2b2ee
commit
4cca9f7c67
@ -0,0 +1,55 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !ts_omit_serve
|
||||||
|
|
||||||
|
package local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetServeConfig return the current serve config.
|
||||||
|
//
|
||||||
|
// If the serve config is empty, it returns (nil, nil).
|
||||||
|
func (lc *Client) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) {
|
||||||
|
body, h, err := lc.sendWithHeaders(ctx, "GET", "/localapi/v0/serve-config", 200, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting serve config: %w", err)
|
||||||
|
}
|
||||||
|
sc, err := getServeConfigFromJSON(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sc == nil {
|
||||||
|
sc = new(ipn.ServeConfig)
|
||||||
|
}
|
||||||
|
sc.ETag = h.Get("Etag")
|
||||||
|
return sc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getServeConfigFromJSON(body []byte) (sc *ipn.ServeConfig, err error) {
|
||||||
|
if err := json.Unmarshal(body, &sc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetServeConfig sets or replaces the serving settings.
|
||||||
|
// If config is nil, settings are cleared and serving is disabled.
|
||||||
|
func (lc *Client) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error {
|
||||||
|
h := make(http.Header)
|
||||||
|
if config != nil {
|
||||||
|
h.Set("If-Match", config.ETag)
|
||||||
|
}
|
||||||
|
_, _, err := lc.sendWithHeaders(ctx, "POST", "/localapi/v0/serve-config", 200, jsonBody(config), h)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sending serve config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build ts_omit_serve
|
||||||
|
|
||||||
|
// These are temporary (2025-09-13) stubs for when tailscaled is built with the
|
||||||
|
// ts_omit_serve build tag, disabling serve.
|
||||||
|
//
|
||||||
|
// TODO: move serve to a separate package, out of ipnlocal, and delete this
|
||||||
|
// file. One step at a time.
|
||||||
|
|
||||||
|
package ipnlocal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
const serveEnabled = false
|
||||||
|
|
||||||
|
type localListener = struct{}
|
||||||
|
|
||||||
|
func (b *LocalBackend) DeleteForegroundSession(sessionID string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type funnelFlow = struct{}
|
||||||
|
|
||||||
|
func (*LocalBackend) hasIngressEnabledLocked() bool { return false }
|
||||||
|
func (*LocalBackend) shouldWireInactiveIngressLocked() bool { return false }
|
||||||
|
|
||||||
|
func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcfg.VIPService {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
//go:build !ts_omit_serve
|
||||||
|
|
||||||
|
package localapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/ipn/ipnlocal"
|
||||||
|
"tailscale.com/util/httpm"
|
||||||
|
"tailscale.com/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register("serve-config", (*Handler).serveServeConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case httpm.GET:
|
||||||
|
if !h.PermitRead {
|
||||||
|
http.Error(w, "serve config denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config := h.b.ServeConfig()
|
||||||
|
bts, err := json.Marshal(config)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "error encoding config: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(bts)
|
||||||
|
etag := hex.EncodeToString(sum[:])
|
||||||
|
w.Header().Set("Etag", etag)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(bts)
|
||||||
|
case httpm.POST:
|
||||||
|
if !h.PermitWrite {
|
||||||
|
http.Error(w, "serve config denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configIn := new(ipn.ServeConfig)
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(configIn); err != nil {
|
||||||
|
WriteErrorJSON(w, fmt.Errorf("decoding config: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// require a local admin when setting a path handler
|
||||||
|
// TODO: roll-up this Windows-specific check into either PermitWrite
|
||||||
|
// or a global admin escalation check.
|
||||||
|
if err := authorizeServeConfigForGOOSAndUserContext(runtime.GOOS, configIn, h); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
etag := r.Header.Get("If-Match")
|
||||||
|
if err := h.b.SetServeConfig(configIn, etag); err != nil {
|
||||||
|
if errors.Is(err, ipnlocal.ErrETagMismatch) {
|
||||||
|
http.Error(w, err.Error(), http.StatusPreconditionFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteErrorJSON(w, fmt.Errorf("updating config: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizeServeConfigForGOOSAndUserContext(goos string, configIn *ipn.ServeConfig, h *Handler) error {
|
||||||
|
switch goos {
|
||||||
|
case "windows", "linux", "darwin", "illumos", "solaris":
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Only check for local admin on tailscaled-on-mac (based on "sudo"
|
||||||
|
// permissions). On sandboxed variants (MacSys and AppStore), tailscaled
|
||||||
|
// cannot serve files outside of the sandbox and this check is not
|
||||||
|
// relevant.
|
||||||
|
if goos == "darwin" && version.IsSandboxedMacOS() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !configIn.HasPathHandler() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if h.Actor.IsLocalAdmin(h.b.OperatorUserID()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch goos {
|
||||||
|
case "windows":
|
||||||
|
return errors.New("must be a Windows local admin to serve a path")
|
||||||
|
case "linux", "darwin", "illumos", "solaris":
|
||||||
|
return errors.New("must be root, or be an operator and able to run 'sudo tailscale' to serve a path")
|
||||||
|
default:
|
||||||
|
// We filter goos at the start of the func, this default case
|
||||||
|
// should never happen.
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue