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>
This commit is contained in:
Brad Fitzpatrick
2025-09-13 20:20:08 -07:00
committed by Brad Fitzpatrick
parent 5b5ae2b2ee
commit 4cca9f7c67
21 changed files with 651 additions and 491 deletions

View File

@@ -1217,20 +1217,6 @@ func (lc *Client) Ping(ctx context.Context, ip netip.Addr, pingtype tailcfg.Ping
return lc.PingWithOpts(ctx, ip, pingtype, PingOpts{})
}
// 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
}
// DisconnectControl shuts down all connections to control, thus making control consider this node inactive. This can be
// run on HA subnet router or app connector replicas before shutting them down to ensure peers get told to switch over
// to another replica whilst there is still some grace period for the existing connections to terminate.
@@ -1242,32 +1228,6 @@ func (lc *Client) DisconnectControl(ctx context.Context) error {
return nil
}
// 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
}
// tailscaledConnectHint gives a little thing about why tailscaled (or
// platform equivalent) is not answering localapi connections.
//

55
client/local/serve.go Normal file
View File

@@ -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
}

View File

@@ -798,6 +798,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/envknob/featureknob from tailscale.com/client/web+
tailscale.com/feature from tailscale.com/ipn/ipnext+
tailscale.com/feature/buildfeatures from tailscale.com/ipn/ipnlocal+
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal+

View File

@@ -213,6 +213,8 @@ var (
maybeWebCmd,
maybeDriveCmd,
maybeNetlockCmd,
maybeFunnelCmd,
maybeServeCmd,
_ func() *ffcli.Command
)
@@ -254,8 +256,8 @@ change in the future.
pingCmd,
ncCmd,
sshCmd,
funnelCmd(),
serveCmd(),
nilOrCall(maybeFunnelCmd),
nilOrCall(maybeServeCmd),
versionCmd,
nilOrCall(maybeWebCmd),
nilOrCall(fileCmd),

View File

@@ -1,6 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serve
package cli
import (
@@ -16,6 +18,10 @@ import (
"tailscale.com/tailcfg"
)
func init() {
maybeFunnelCmd = funnelCmd
}
var funnelCmd = func() *ffcli.Command {
se := &serveEnv{lc: &localClient}
// previously used to serve legacy newFunnelCommand unless useWIPCode is true
@@ -174,3 +180,42 @@ func printFunnelWarning(sc *ipn.ServeConfig) {
fmt.Fprintf(Stderr, " run: `tailscale serve --help` to see how to configure handlers\n")
}
}
func init() {
hookPrintFunnelStatus.Set(printFunnelStatus)
}
// printFunnelStatus prints the status of the funnel, if it's running.
// It prints nothing if the funnel is not running.
func printFunnelStatus(ctx context.Context) {
sc, err := localClient.GetServeConfig(ctx)
if err != nil {
outln()
printf("# Funnel:\n")
printf("# - Unable to get Funnel status: %v\n", err)
return
}
if !sc.IsFunnelOn() {
return
}
outln()
printf("# Funnel on:\n")
for hp, on := range sc.AllowFunnel {
if !on { // if present, should be on
continue
}
sni, portStr, _ := net.SplitHostPort(string(hp))
p, _ := strconv.ParseUint(portStr, 10, 16)
isTCP := sc.IsTCPForwardingOnPort(uint16(p), noService)
url := "https://"
if isTCP {
url = "tcp://"
}
url += sni
if isTCP || p != 443 {
url += ":" + portStr
}
printf("# - %s\n", url)
}
outln()
}

View File

@@ -1,6 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serve
package cli
import (
@@ -31,6 +33,10 @@ import (
"tailscale.com/version"
)
func init() {
maybeServeCmd = serveCmd
}
var serveCmd = func() *ffcli.Command {
se := &serveEnv{lc: &localClient}
// previously used to serve legacy newFunnelCommand unless useWIPCode is true

View File

@@ -1,6 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serve
package cli
import (

View File

@@ -15,12 +15,12 @@ import (
"net/http"
"net/netip"
"os"
"strconv"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"github.com/toqueteos/webbrowser"
"golang.org/x/net/idna"
"tailscale.com/feature"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/netmon"
@@ -238,44 +238,13 @@ func runStatus(ctx context.Context, args []string) error {
outln()
printHealth()
}
printFunnelStatus(ctx)
if f, ok := hookPrintFunnelStatus.GetOk(); ok {
f(ctx)
}
return nil
}
// printFunnelStatus prints the status of the funnel, if it's running.
// It prints nothing if the funnel is not running.
func printFunnelStatus(ctx context.Context) {
sc, err := localClient.GetServeConfig(ctx)
if err != nil {
outln()
printf("# Funnel:\n")
printf("# - Unable to get Funnel status: %v\n", err)
return
}
if !sc.IsFunnelOn() {
return
}
outln()
printf("# Funnel on:\n")
for hp, on := range sc.AllowFunnel {
if !on { // if present, should be on
continue
}
sni, portStr, _ := net.SplitHostPort(string(hp))
p, _ := strconv.ParseUint(portStr, 10, 16)
isTCP := sc.IsTCPForwardingOnPort(uint16(p), noService)
url := "https://"
if isTCP {
url = "tcp://"
}
url += sni
if isTCP || p != 443 {
url += ":" + portStr
}
printf("# - %s\n", url)
}
outln()
}
var hookPrintFunnelStatus feature.Hook[func(context.Context)]
// isRunningOrStarting reports whether st is in state Running or Starting.
// It also returns a description of the status suitable to display to a user.

View File

@@ -104,7 +104,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/drive from tailscale.com/client/local+
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/envknob/featureknob from tailscale.com/client/web
tailscale.com/feature from tailscale.com/tsweb
tailscale.com/feature from tailscale.com/tsweb+
tailscale.com/feature/capture/dissector from tailscale.com/cmd/tailscale/cli
tailscale.com/feature/syspolicy from tailscale.com/cmd/tailscale/cli
tailscale.com/health from tailscale.com/net/tlsdial+

View File

@@ -272,6 +272,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/envknob/featureknob from tailscale.com/client/web+
tailscale.com/feature from tailscale.com/feature/wakeonlan+
tailscale.com/feature/buildfeatures from tailscale.com/ipn/ipnlocal+
tailscale.com/feature/capture from tailscale.com/feature/condregister
tailscale.com/feature/condregister from tailscale.com/cmd/tailscaled
tailscale.com/feature/drive from tailscale.com/feature/condregister

View File

@@ -239,6 +239,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/envknob/featureknob from tailscale.com/client/web+
tailscale.com/feature from tailscale.com/ipn/ipnext+
tailscale.com/feature/buildfeatures from tailscale.com/ipn/ipnlocal+
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal+

View File

@@ -72,9 +72,6 @@ var c2nHandlers = map[methodAndPath]c2nHandler{
// Linux netfilter.
req("POST /netfilter-kind"): handleC2NSetNetfilterKind,
// VIP services.
req("GET /vip-services"): handleC2NVIPServicesGet,
}
// RegisterC2N registers a new c2n handler for the given pattern.
@@ -280,16 +277,6 @@ func handleC2NSetNetfilterKind(b *LocalBackend, w http.ResponseWriter, r *http.R
w.WriteHeader(http.StatusNoContent)
}
func handleC2NVIPServicesGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /vip-services received")
var res tailcfg.C2NVIPServicesResponse
res.VIPServices = b.VIPServices()
res.ServicesHash = b.vipServiceHash(res.VIPServices)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}
func handleC2NUpdateGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /update received")

View File

@@ -18,7 +18,6 @@ import (
"fmt"
"io"
"log"
"maps"
"math"
"math/rand/v2"
"net"
@@ -53,6 +52,7 @@ import (
"tailscale.com/envknob"
"tailscale.com/envknob/featureknob"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/health/healthmsg"
"tailscale.com/hostinfo"
@@ -585,7 +585,6 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
b.e.SetJailedFilter(noneFilter)
b.setTCPPortsIntercepted(nil)
b.setVIPServicesTCPPortsIntercepted(nil)
b.statusChanged = sync.NewCond(&b.statusLock)
b.e.SetStatusCallback(b.setWgengineStatus)
@@ -3759,46 +3758,6 @@ func generateInterceptVIPServicesTCPPortFunc(svcAddrPorts map[netip.Addr]func(ui
}
}
// setVIPServicesTCPPortsIntercepted populates b.shouldInterceptVIPServicesTCPPortAtomic with an
// efficient func for ShouldInterceptTCPPort to use, which is called on every incoming packet.
func (b *LocalBackend) setVIPServicesTCPPortsIntercepted(svcPorts map[tailcfg.ServiceName][]uint16) {
b.mu.Lock()
defer b.mu.Unlock()
b.setVIPServicesTCPPortsInterceptedLocked(svcPorts)
}
func (b *LocalBackend) setVIPServicesTCPPortsInterceptedLocked(svcPorts map[tailcfg.ServiceName][]uint16) {
if len(svcPorts) == 0 {
b.shouldInterceptVIPServicesTCPPortAtomic.Store(func(netip.AddrPort) bool { return false })
return
}
nm := b.currentNode().NetMap()
if nm == nil {
b.logf("can't set intercept function for Service TCP Ports, netMap is nil")
return
}
vipServiceIPMap := nm.GetVIPServiceIPMap()
if len(vipServiceIPMap) == 0 {
// No approved VIP Services
return
}
svcAddrPorts := make(map[netip.Addr]func(uint16) bool)
// Only set the intercept function if the service has been assigned a VIP.
for svcName, ports := range svcPorts {
addrs, ok := vipServiceIPMap[svcName]
if !ok {
continue
}
interceptFn := generateInterceptTCPPortFunc(ports)
for _, addr := range addrs {
svcAddrPorts[addr] = interceptFn
}
}
b.shouldInterceptVIPServicesTCPPortAtomic.Store(generateInterceptVIPServicesTCPPortFunc(svcAddrPorts))
}
// setAtomicValuesFromPrefsLocked populates sshAtomicBool, containsViaIPFuncAtomic,
// shouldInterceptTCPPortAtomic, and exposeRemoteWebClientAtomicBool from the prefs p,
// which may be !Valid().
@@ -3809,7 +3768,9 @@ func (b *LocalBackend) setAtomicValuesFromPrefsLocked(p ipn.PrefsView) {
if !p.Valid() {
b.containsViaIPFuncAtomic.Store(ipset.FalseContainsIPFunc())
b.setTCPPortsIntercepted(nil)
b.setVIPServicesTCPPortsInterceptedLocked(nil)
if f, ok := hookServeClearVIPServicesTCPPortsInterceptedLocked.GetOk(); ok {
f(b)
}
b.lastServeConfJSON = mem.B(nil)
b.serveConfig = ipn.ServeConfigView{}
} else {
@@ -4738,32 +4699,6 @@ func (b *LocalBackend) checkProfileNameLocked(p *ipn.Prefs) error {
return nil
}
// wantIngressLocked reports whether this node has ingress configured. This bool
// is sent to the coordination server (in Hostinfo.WireIngress) as an
// optimization hint to know primarily which nodes are NOT using ingress, to
// avoid doing work for regular nodes.
//
// Even if the user's ServeConfig.AllowFunnel map was manually edited in raw
// mode and contains map entries with false values, sending true (from Len > 0)
// is still fine. This is only an optimization hint for the control plane and
// doesn't affect security or correctness. And we also don't expect people to
// modify their ServeConfig in raw mode.
func (b *LocalBackend) wantIngressLocked() bool {
return b.serveConfig.Valid() && b.serveConfig.HasAllowFunnel()
}
// hasIngressEnabledLocked reports whether the node has any funnel endpoint enabled. This bool is sent to control (in
// Hostinfo.IngressEnabled) to determine whether 'Funnel' badge should be displayed on this node in the admin panel.
func (b *LocalBackend) hasIngressEnabledLocked() bool {
return b.serveConfig.Valid() && b.serveConfig.IsFunnelOn()
}
// shouldWireInactiveIngressLocked reports whether the node is in a state where funnel is not actively enabled, but it
// seems that it is intended to be used with funnel.
func (b *LocalBackend) shouldWireInactiveIngressLocked() bool {
return b.serveConfig.Valid() && !b.hasIngressEnabledLocked() && b.wantIngressLocked()
}
// setPrefsLockedOnEntry requires b.mu be held to call it, but it
// unlocks b.mu when done. newp ownership passes to this function.
// It returns a read-only copy of the new prefs.
@@ -4907,6 +4842,16 @@ var (
magicDNSIPv6 = tsaddr.TailscaleServiceIPv6()
)
// Hook exclusively for serve.
var (
hookServeTCPHandlerForVIPService feature.Hook[func(b *LocalBackend, dst netip.AddrPort, src netip.AddrPort) (handler func(c net.Conn) error)]
hookTCPHandlerForServe feature.Hook[func(b *LocalBackend, dport uint16, srcAddr netip.AddrPort, f *funnelFlow) (handler func(net.Conn) error)]
hookServeUpdateServeTCPPortNetMapAddrListenersLocked feature.Hook[func(b *LocalBackend, ports []uint16)]
hookServeSetTCPPortsInterceptedFromNetmapAndPrefsLocked feature.Hook[func(b *LocalBackend, prefs ipn.PrefsView) (handlePorts []uint16)]
hookServeClearVIPServicesTCPPortsInterceptedLocked feature.Hook[func(*LocalBackend)]
)
// TCPHandlerForDst returns a TCP handler for connections to dst, or nil if
// no handler is needed. It also returns a list of TCP socket options to
// apply to the socket before calling the handler.
@@ -4929,10 +4874,10 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
}
}
// TODO(tailscale/corp#26001): Get handler for VIP services and Local IPs using
// the same function.
if handler := b.tcpHandlerForVIPService(dst, src); handler != nil {
return handler, opts
if f, ok := hookServeTCPHandlerForVIPService.GetOk(); ok {
if handler := f(b, dst, src); handler != nil {
return handler, opts
}
}
// Then handle external connections to the local IP.
if !b.isLocalIP(dst.Addr()) {
@@ -4958,8 +4903,10 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c
return nil
}, opts
}
if handler := b.tcpHandlerForServe(dst.Port(), src, nil); handler != nil {
return handler, opts
if f, ok := hookTCPHandlerForServe.GetOk(); ok {
if handler := f(b, dst.Port(), src, nil); handler != nil {
return handler, opts
}
}
return nil, nil
}
@@ -6341,7 +6288,9 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
netns.SetDisableBindConnToInterface(nm.HasCap(tailcfg.CapabilityDebugDisableBindConnToInterface))
b.setTCPPortsInterceptedFromNetmapAndPrefsLocked(b.pm.CurrentPrefs())
b.ipVIPServiceMap = nm.GetIPVIPServiceMap()
if buildfeatures.HasServe {
b.ipVIPServiceMap = nm.GetIPVIPServiceMap()
}
if !oldSelf.Equal(nm.SelfNodeOrZero()) {
for _, f := range b.extHost.Hooks().OnSelfChange {
@@ -6411,55 +6360,12 @@ func (b *LocalBackend) setDebugLogsByCapabilityLocked(nm *netmap.NetworkMap) {
}
}
// reloadServeConfigLocked reloads the serve config from the store or resets the
// serve config to nil if not logged in. The "changed" parameter, when false, instructs
// the method to only run the reset-logic and not reload the store from memory to ensure
// foreground sessions are not removed if they are not saved on disk.
func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
if !b.currentNode().Self().Valid() || !prefs.Valid() || b.pm.CurrentProfile().ID() == "" {
// We're not logged in, so we don't have a profile.
// Don't try to load the serve config.
b.lastServeConfJSON = mem.B(nil)
b.serveConfig = ipn.ServeConfigView{}
return
}
confKey := ipn.ServeConfigKey(b.pm.CurrentProfile().ID())
// TODO(maisem,bradfitz): prevent reading the config from disk
// if the profile has not changed.
confj, err := b.store.ReadState(confKey)
if err != nil {
b.lastServeConfJSON = mem.B(nil)
b.serveConfig = ipn.ServeConfigView{}
return
}
if b.lastServeConfJSON.Equal(mem.B(confj)) {
return
}
b.lastServeConfJSON = mem.B(confj)
var conf ipn.ServeConfig
if err := json.Unmarshal(confj, &conf); err != nil {
b.logf("invalid ServeConfig %q in StateStore: %v", confKey, err)
b.serveConfig = ipn.ServeConfigView{}
return
}
// remove inactive sessions
maps.DeleteFunc(conf.Foreground, func(sessionID string, sc *ipn.ServeConfig) bool {
_, ok := b.notifyWatchers[sessionID]
return !ok
})
b.serveConfig = conf.View()
}
// setTCPPortsInterceptedFromNetmapAndPrefsLocked calls setTCPPortsIntercepted with
// the ports that tailscaled should handle as a function of b.netMap and b.prefs.
//
// b.mu must be held.
func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.PrefsView) {
handlePorts := make([]uint16, 0, 4)
var vipServicesPorts map[tailcfg.ServiceName][]uint16
if prefs.Valid() && prefs.RunSSH() && envknob.CanSSHD() {
handlePorts = append(handlePorts, 22)
@@ -6473,42 +6379,14 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn.
}
}
b.reloadServeConfigLocked(prefs)
if b.serveConfig.Valid() {
servePorts := make([]uint16, 0, 3)
for port := range b.serveConfig.TCPs() {
if port > 0 {
servePorts = append(servePorts, uint16(port))
}
}
handlePorts = append(handlePorts, servePorts...)
for svc, cfg := range b.serveConfig.Services().All() {
servicePorts := make([]uint16, 0, 3)
for port := range cfg.TCP().All() {
if port > 0 {
servicePorts = append(servicePorts, uint16(port))
}
}
if _, ok := vipServicesPorts[svc]; !ok {
mak.Set(&vipServicesPorts, svc, servicePorts)
} else {
mak.Set(&vipServicesPorts, svc, append(vipServicesPorts[svc], servicePorts...))
}
}
b.setServeProxyHandlersLocked()
// don't listen on netmap addresses if we're in userspace mode
if !b.sys.IsNetstack() {
b.updateServeTCPPortNetMapAddrListenersLocked(servePorts)
}
if f, ok := hookServeSetTCPPortsInterceptedFromNetmapAndPrefsLocked.GetOk(); ok {
v := f(b, prefs)
handlePorts = append(handlePorts, v...)
}
// Update funnel and service hash info in hostinfo and kick off control update if needed.
b.updateIngressAndServiceHashLocked(prefs)
b.setTCPPortsIntercepted(handlePorts)
b.setVIPServicesTCPPortsInterceptedLocked(vipServicesPorts)
}
// updateIngressAndServiceHashLocked updates the hostinfo.ServicesHash, hostinfo.WireIngress and
@@ -6541,51 +6419,6 @@ func (b *LocalBackend) updateIngressAndServiceHashLocked(prefs ipn.PrefsView) {
}
}
// setServeProxyHandlersLocked ensures there is an http proxy handler for each
// backend specified in serveConfig. It expects serveConfig to be valid and
// up-to-date, so should be called after reloadServeConfigLocked.
func (b *LocalBackend) setServeProxyHandlersLocked() {
if !b.serveConfig.Valid() {
return
}
var backends map[string]bool
for _, conf := range b.serveConfig.Webs() {
for _, h := range conf.Handlers().All() {
backend := h.Proxy()
if backend == "" {
// Only create proxy handlers for servers with a proxy backend.
continue
}
mak.Set(&backends, backend, true)
if _, ok := b.serveProxyHandlers.Load(backend); ok {
continue
}
b.logf("serve: creating a new proxy handler for %s", backend)
p, err := b.proxyHandlerForBackend(backend)
if err != nil {
// The backend endpoint (h.Proxy) should have been validated by expandProxyTarget
// in the CLI, so just log the error here.
b.logf("[unexpected] could not create proxy for %v: %s", backend, err)
continue
}
b.serveProxyHandlers.Store(backend, p)
}
}
// Clean up handlers for proxy backends that are no longer present
// in configuration.
b.serveProxyHandlers.Range(func(key, value any) bool {
backend := key.(string)
if !backends[backend] {
b.logf("serve: closing idle connections to %s", backend)
b.serveProxyHandlers.Delete(backend)
value.(*reverseProxy).close()
}
return true
})
}
// operatorUserName returns the current pref's OperatorUser's name, or the
// empty string if none.
func (b *LocalBackend) operatorUserName() string {
@@ -7196,7 +7029,14 @@ func (b *LocalBackend) ShouldInterceptTCPPort(port uint16) bool {
// ShouldInterceptVIPServiceTCPPort reports whether the given TCP port number
// to a VIP service should be intercepted by Tailscaled and handled in-process.
func (b *LocalBackend) ShouldInterceptVIPServiceTCPPort(ap netip.AddrPort) bool {
return b.shouldInterceptVIPServicesTCPPortAtomic.Load()(ap)
if !buildfeatures.HasServe {
return false
}
f := b.shouldInterceptVIPServicesTCPPortAtomic.Load()
if f == nil {
return false
}
return f(ap)
}
// SwitchProfile switches to the profile with the given id.
@@ -8131,15 +7971,6 @@ func maybeUsernameOf(actor ipnauth.Actor) string {
return username
}
// VIPServices returns the list of tailnet services that this node
// is serving as a destination for.
// The returned memory is owned by the caller.
func (b *LocalBackend) VIPServices() []*tailcfg.VIPService {
b.mu.Lock()
defer b.mu.Unlock()
return b.vipServicesFromPrefsLocked(b.pm.CurrentPrefs())
}
func (b *LocalBackend) vipServiceHash(services []*tailcfg.VIPService) string {
if len(services) == 0 {
return ""
@@ -8153,39 +7984,9 @@ func (b *LocalBackend) vipServiceHash(services []*tailcfg.VIPService) string {
return hex.EncodeToString(hash[:])
}
func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcfg.VIPService {
// keyed by service name
var services map[tailcfg.ServiceName]*tailcfg.VIPService
if b.serveConfig.Valid() {
for svc, config := range b.serveConfig.Services().All() {
mak.Set(&services, svc, &tailcfg.VIPService{
Name: svc,
Ports: config.ServicePortRange(),
})
}
}
for _, s := range prefs.AdvertiseServices().All() {
sn := tailcfg.ServiceName(s)
if services == nil || services[sn] == nil {
mak.Set(&services, sn, &tailcfg.VIPService{
Name: sn,
})
}
services[sn].Active = true
}
servicesList := slicesx.MapValues(services)
// [slicesx.MapValues] provides the values in an indeterminate order, but since we'll
// be hashing a representation of this list later we want it to be in a consistent
// order.
slices.SortFunc(servicesList, func(a, b *tailcfg.VIPService) int {
return strings.Compare(a.Name.String(), b.Name.String())
})
return servicesList
}
var metricCurrentWatchIPNBus = clientmetric.NewGauge("localbackend_current_watch_ipn_bus")
var (
metricCurrentWatchIPNBus = clientmetric.NewGauge("localbackend_current_watch_ipn_bus")
)
func (b *LocalBackend) stateEncrypted() opt.Bool {
switch runtime.GOOS {

View File

@@ -28,7 +28,6 @@ import (
"tailscale.com/envknob"
"tailscale.com/health"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/net/netaddr"
"tailscale.com/net/netmon"
"tailscale.com/net/netutil"
@@ -387,10 +386,6 @@ func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case "/v0/sockstats":
h.handleServeSockStats(w, r)
return
case "/v0/ingress":
metricIngressCalls.Add(1)
h.handleServeIngress(w, r)
return
}
if ph, ok := peerAPIHandlers[r.URL.Path]; ok {
ph(h, w, r)
@@ -413,67 +408,6 @@ This is my Tailscale device. Your device is %v.
}
}
func (h *peerAPIHandler) handleServeIngress(w http.ResponseWriter, r *http.Request) {
// http.Errors only useful if hitting endpoint manually
// otherwise rely on log lines when debugging ingress connections
// as connection is hijacked for bidi and is encrypted tls
if !h.canIngress() {
h.logf("ingress: denied; no ingress cap from %v", h.remoteAddr)
http.Error(w, "denied; no ingress cap", http.StatusForbidden)
return
}
logAndError := func(code int, publicMsg string) {
h.logf("ingress: bad request from %v: %s", h.remoteAddr, publicMsg)
http.Error(w, publicMsg, code)
}
bad := func(publicMsg string) {
logAndError(http.StatusBadRequest, publicMsg)
}
if r.Method != "POST" {
logAndError(http.StatusMethodNotAllowed, "only POST allowed")
return
}
srcAddrStr := r.Header.Get("Tailscale-Ingress-Src")
if srcAddrStr == "" {
bad("Tailscale-Ingress-Src header not set")
return
}
srcAddr, err := netip.ParseAddrPort(srcAddrStr)
if err != nil {
bad("Tailscale-Ingress-Src header invalid; want ip:port")
return
}
target := ipn.HostPort(r.Header.Get("Tailscale-Ingress-Target"))
if target == "" {
bad("Tailscale-Ingress-Target header not set")
return
}
if _, _, err := net.SplitHostPort(string(target)); err != nil {
bad("Tailscale-Ingress-Target header invalid; want host:port")
return
}
getConnOrReset := func() (net.Conn, bool) {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
h.logf("ingress: failed hijacking conn")
http.Error(w, "failed hijacking conn", http.StatusInternalServerError)
return nil, false
}
io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n\r\n")
return &ipn.FunnelConn{
Conn: conn,
Src: srcAddr,
Target: target,
}, true
}
sendRST := func() {
http.Error(w, "denied", http.StatusForbidden)
}
h.ps.b.HandleIngressTCPConn(h.peerNode, target, srcAddr, getConnOrReset, sendRST)
}
func (h *peerAPIHandler) handleServeInterfaces(w http.ResponseWriter, r *http.Request) {
if !h.canDebug() {
http.Error(w, "denied; no debug access", http.StatusForbidden)
@@ -1099,6 +1033,5 @@ var (
metricInvalidRequests = clientmetric.NewCounter("peerapi_invalid_requests")
// Non-debug PeerAPI endpoints.
metricDNSCalls = clientmetric.NewCounter("peerapi_dns")
metricIngressCalls = clientmetric.NewCounter("peerapi_ingress")
metricDNSCalls = clientmetric.NewCounter("peerapi_dns")
)

View File

@@ -1,6 +1,10 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serve
// TODO: move this whole file to its own package, out of ipnlocal.
package ipnlocal
import (
@@ -12,6 +16,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"mime"
"net"
"net/http"
@@ -28,6 +33,7 @@ import (
"time"
"unicode/utf8"
"go4.org/mem"
"golang.org/x/net/http2"
"tailscale.com/ipn"
"tailscale.com/logtail/backoff"
@@ -36,11 +42,26 @@ import (
"tailscale.com/tailcfg"
"tailscale.com/types/lazy"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
"tailscale.com/util/ctxkey"
"tailscale.com/util/mak"
"tailscale.com/util/slicesx"
"tailscale.com/version"
)
func init() {
hookServeTCPHandlerForVIPService.Set((*LocalBackend).tcpHandlerForVIPService)
hookTCPHandlerForServe.Set((*LocalBackend).tcpHandlerForServe)
hookServeUpdateServeTCPPortNetMapAddrListenersLocked.Set((*LocalBackend).updateServeTCPPortNetMapAddrListenersLocked)
hookServeSetTCPPortsInterceptedFromNetmapAndPrefsLocked.Set(serveSetTCPPortsInterceptedFromNetmapAndPrefsLocked)
hookServeClearVIPServicesTCPPortsInterceptedLocked.Set(func(b *LocalBackend) {
b.setVIPServicesTCPPortsInterceptedLocked(nil)
})
RegisterC2N("GET /vip-services", handleC2NVIPServicesGet)
}
const (
contentTypeHeader = "Content-Type"
grpcBaseContentType = "application/grpc"
@@ -222,6 +243,10 @@ func (s *localListener) handleListenersAccept(ln net.Listener) error {
//
// b.mu must be held.
func (b *LocalBackend) updateServeTCPPortNetMapAddrListenersLocked(ports []uint16) {
if b.sys.IsNetstack() {
// don't listen on netmap addresses if we're in userspace mode
return
}
// close existing listeners where port
// is no longer in incoming ports list
for ap, sl := range b.serveListeners {
@@ -439,6 +464,38 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target
handler(c)
}
func (b *LocalBackend) vipServicesFromPrefsLocked(prefs ipn.PrefsView) []*tailcfg.VIPService {
// keyed by service name
var services map[tailcfg.ServiceName]*tailcfg.VIPService
if b.serveConfig.Valid() {
for svc, config := range b.serveConfig.Services().All() {
mak.Set(&services, svc, &tailcfg.VIPService{
Name: svc,
Ports: config.ServicePortRange(),
})
}
}
for _, s := range prefs.AdvertiseServices().All() {
sn := tailcfg.ServiceName(s)
if services == nil || services[sn] == nil {
mak.Set(&services, sn, &tailcfg.VIPService{
Name: sn,
})
}
services[sn].Active = true
}
servicesList := slicesx.MapValues(services)
// [slicesx.MapValues] provides the values in an indeterminate order, but since we'll
// be hashing a representation of this list later we want it to be in a consistent
// order.
slices.SortFunc(servicesList, func(a, b *tailcfg.VIPService) int {
return strings.Compare(a.Name.String(), b.Name.String())
})
return servicesList
}
// tcpHandlerForVIPService returns a handler for a TCP connection to a VIP service
// that is being served via the ipn.ServeConfig. It returns nil if the destination
// address is not a VIP service or if the VIP service does not have a TCP handler set.
@@ -1046,3 +1103,278 @@ func (b *LocalBackend) getTLSServeCertForPort(port uint16, forVIPService tailcfg
return &cert, nil
}
}
// setServeProxyHandlersLocked ensures there is an http proxy handler for each
// backend specified in serveConfig. It expects serveConfig to be valid and
// up-to-date, so should be called after reloadServeConfigLocked.
func (b *LocalBackend) setServeProxyHandlersLocked() {
if !b.serveConfig.Valid() {
return
}
var backends map[string]bool
for _, conf := range b.serveConfig.Webs() {
for _, h := range conf.Handlers().All() {
backend := h.Proxy()
if backend == "" {
// Only create proxy handlers for servers with a proxy backend.
continue
}
mak.Set(&backends, backend, true)
if _, ok := b.serveProxyHandlers.Load(backend); ok {
continue
}
b.logf("serve: creating a new proxy handler for %s", backend)
p, err := b.proxyHandlerForBackend(backend)
if err != nil {
// The backend endpoint (h.Proxy) should have been validated by expandProxyTarget
// in the CLI, so just log the error here.
b.logf("[unexpected] could not create proxy for %v: %s", backend, err)
continue
}
b.serveProxyHandlers.Store(backend, p)
}
}
// Clean up handlers for proxy backends that are no longer present
// in configuration.
b.serveProxyHandlers.Range(func(key, value any) bool {
backend := key.(string)
if !backends[backend] {
b.logf("serve: closing idle connections to %s", backend)
b.serveProxyHandlers.Delete(backend)
value.(*reverseProxy).close()
}
return true
})
}
// VIPServices returns the list of tailnet services that this node
// is serving as a destination for.
// The returned memory is owned by the caller.
func (b *LocalBackend) VIPServices() []*tailcfg.VIPService {
b.mu.Lock()
defer b.mu.Unlock()
return b.vipServicesFromPrefsLocked(b.pm.CurrentPrefs())
}
func handleC2NVIPServicesGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) {
b.logf("c2n: GET /vip-services received")
var res tailcfg.C2NVIPServicesResponse
res.VIPServices = b.VIPServices()
res.ServicesHash = b.vipServiceHash(res.VIPServices)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}
var metricIngressCalls = clientmetric.NewCounter("peerapi_ingress")
func init() {
RegisterPeerAPIHandler("/v0/ingress", handleServeIngress)
}
func handleServeIngress(ph PeerAPIHandler, w http.ResponseWriter, r *http.Request) {
h := ph.(*peerAPIHandler)
metricIngressCalls.Add(1)
// http.Errors only useful if hitting endpoint manually
// otherwise rely on log lines when debugging ingress connections
// as connection is hijacked for bidi and is encrypted tls
if !h.canIngress() {
h.logf("ingress: denied; no ingress cap from %v", h.remoteAddr)
http.Error(w, "denied; no ingress cap", http.StatusForbidden)
return
}
logAndError := func(code int, publicMsg string) {
h.logf("ingress: bad request from %v: %s", h.remoteAddr, publicMsg)
http.Error(w, publicMsg, code)
}
bad := func(publicMsg string) {
logAndError(http.StatusBadRequest, publicMsg)
}
if r.Method != "POST" {
logAndError(http.StatusMethodNotAllowed, "only POST allowed")
return
}
srcAddrStr := r.Header.Get("Tailscale-Ingress-Src")
if srcAddrStr == "" {
bad("Tailscale-Ingress-Src header not set")
return
}
srcAddr, err := netip.ParseAddrPort(srcAddrStr)
if err != nil {
bad("Tailscale-Ingress-Src header invalid; want ip:port")
return
}
target := ipn.HostPort(r.Header.Get("Tailscale-Ingress-Target"))
if target == "" {
bad("Tailscale-Ingress-Target header not set")
return
}
if _, _, err := net.SplitHostPort(string(target)); err != nil {
bad("Tailscale-Ingress-Target header invalid; want host:port")
return
}
getConnOrReset := func() (net.Conn, bool) {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
h.logf("ingress: failed hijacking conn")
http.Error(w, "failed hijacking conn", http.StatusInternalServerError)
return nil, false
}
io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n\r\n")
return &ipn.FunnelConn{
Conn: conn,
Src: srcAddr,
Target: target,
}, true
}
sendRST := func() {
http.Error(w, "denied", http.StatusForbidden)
}
h.ps.b.HandleIngressTCPConn(h.peerNode, target, srcAddr, getConnOrReset, sendRST)
}
// wantIngressLocked reports whether this node has ingress configured. This bool
// is sent to the coordination server (in Hostinfo.WireIngress) as an
// optimization hint to know primarily which nodes are NOT using ingress, to
// avoid doing work for regular nodes.
//
// Even if the user's ServeConfig.AllowFunnel map was manually edited in raw
// mode and contains map entries with false values, sending true (from Len > 0)
// is still fine. This is only an optimization hint for the control plane and
// doesn't affect security or correctness. And we also don't expect people to
// modify their ServeConfig in raw mode.
func (b *LocalBackend) wantIngressLocked() bool {
return b.serveConfig.Valid() && b.serveConfig.HasAllowFunnel()
}
// hasIngressEnabledLocked reports whether the node has any funnel endpoint enabled. This bool is sent to control (in
// Hostinfo.IngressEnabled) to determine whether 'Funnel' badge should be displayed on this node in the admin panel.
func (b *LocalBackend) hasIngressEnabledLocked() bool {
return b.serveConfig.Valid() && b.serveConfig.IsFunnelOn()
}
// shouldWireInactiveIngressLocked reports whether the node is in a state where funnel is not actively enabled, but it
// seems that it is intended to be used with funnel.
func (b *LocalBackend) shouldWireInactiveIngressLocked() bool {
return b.serveConfig.Valid() && !b.hasIngressEnabledLocked() && b.wantIngressLocked()
}
func serveSetTCPPortsInterceptedFromNetmapAndPrefsLocked(b *LocalBackend, prefs ipn.PrefsView) (handlePorts []uint16) {
var vipServicesPorts map[tailcfg.ServiceName][]uint16
b.reloadServeConfigLocked(prefs)
if b.serveConfig.Valid() {
servePorts := make([]uint16, 0, 3)
for port := range b.serveConfig.TCPs() {
if port > 0 {
servePorts = append(servePorts, uint16(port))
}
}
handlePorts = append(handlePorts, servePorts...)
for svc, cfg := range b.serveConfig.Services().All() {
servicePorts := make([]uint16, 0, 3)
for port := range cfg.TCP().All() {
if port > 0 {
servicePorts = append(servicePorts, uint16(port))
}
}
if _, ok := vipServicesPorts[svc]; !ok {
mak.Set(&vipServicesPorts, svc, servicePorts)
} else {
mak.Set(&vipServicesPorts, svc, append(vipServicesPorts[svc], servicePorts...))
}
}
b.setServeProxyHandlersLocked()
// don't listen on netmap addresses if we're in userspace mode
if !b.sys.IsNetstack() {
b.updateServeTCPPortNetMapAddrListenersLocked(servePorts)
}
}
b.setVIPServicesTCPPortsInterceptedLocked(vipServicesPorts)
return handlePorts
}
// reloadServeConfigLocked reloads the serve config from the store or resets the
// serve config to nil if not logged in. The "changed" parameter, when false, instructs
// the method to only run the reset-logic and not reload the store from memory to ensure
// foreground sessions are not removed if they are not saved on disk.
func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) {
if !b.currentNode().Self().Valid() || !prefs.Valid() || b.pm.CurrentProfile().ID() == "" {
// We're not logged in, so we don't have a profile.
// Don't try to load the serve config.
b.lastServeConfJSON = mem.B(nil)
b.serveConfig = ipn.ServeConfigView{}
return
}
confKey := ipn.ServeConfigKey(b.pm.CurrentProfile().ID())
// TODO(maisem,bradfitz): prevent reading the config from disk
// if the profile has not changed.
confj, err := b.store.ReadState(confKey)
if err != nil {
b.lastServeConfJSON = mem.B(nil)
b.serveConfig = ipn.ServeConfigView{}
return
}
if b.lastServeConfJSON.Equal(mem.B(confj)) {
return
}
b.lastServeConfJSON = mem.B(confj)
var conf ipn.ServeConfig
if err := json.Unmarshal(confj, &conf); err != nil {
b.logf("invalid ServeConfig %q in StateStore: %v", confKey, err)
b.serveConfig = ipn.ServeConfigView{}
return
}
// remove inactive sessions
maps.DeleteFunc(conf.Foreground, func(sessionID string, sc *ipn.ServeConfig) bool {
_, ok := b.notifyWatchers[sessionID]
return !ok
})
b.serveConfig = conf.View()
}
func (b *LocalBackend) setVIPServicesTCPPortsInterceptedLocked(svcPorts map[tailcfg.ServiceName][]uint16) {
if len(svcPorts) == 0 {
b.shouldInterceptVIPServicesTCPPortAtomic.Store(func(netip.AddrPort) bool { return false })
return
}
nm := b.currentNode().NetMap()
if nm == nil {
b.logf("can't set intercept function for Service TCP Ports, netMap is nil")
return
}
vipServiceIPMap := nm.GetVIPServiceIPMap()
if len(vipServiceIPMap) == 0 {
// No approved VIP Services
return
}
svcAddrPorts := make(map[netip.Addr]func(uint16) bool)
// Only set the intercept function if the service has been assigned a VIP.
for svcName, ports := range svcPorts {
addrs, ok := vipServiceIPMap[svcName]
if !ok {
continue
}
interceptFn := generateInterceptTCPPortFunc(ports)
for _, addr := range addrs {
svcAddrPorts[addr] = interceptFn
}
}
b.shouldInterceptVIPServicesTCPPortAtomic.Store(generateInterceptVIPServicesTCPPortFunc(svcAddrPorts))
}

View File

@@ -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
}

View File

@@ -1,6 +1,8 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serve
package ipnlocal
import (

View File

@@ -8,8 +8,6 @@ import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -112,7 +110,6 @@ var handler = map[string]LocalAPIHandler{
"query-feature": (*Handler).serveQueryFeature,
"reload-config": (*Handler).reloadConfig,
"reset-auth": (*Handler).serveResetAuth,
"serve-config": (*Handler).serveServeConfig,
"set-dns": (*Handler).serveSetDNS,
"set-expiry-sooner": (*Handler).serveSetExpirySooner,
"set-gui-visible": (*Handler).serveSetGUIVisible,
@@ -1209,89 +1206,6 @@ func (h *Handler) serveResetAuth(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
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")
}
}
func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) {
if !h.PermitRead {
http.Error(w, "IP forwarding check access denied", http.StatusForbidden)

108
ipn/localapi/serve.go Normal file
View File

@@ -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")
}
}

View File

@@ -235,6 +235,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/envknob from tailscale.com/client/local+
tailscale.com/envknob/featureknob from tailscale.com/client/web+
tailscale.com/feature from tailscale.com/ipn/ipnext+
tailscale.com/feature/buildfeatures from tailscale.com/ipn/ipnlocal+
tailscale.com/feature/syspolicy from tailscale.com/logpolicy
tailscale.com/health from tailscale.com/control/controlclient+
tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal+

View File

@@ -33,6 +33,7 @@ import (
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
"gvisor.dev/gvisor/pkg/waiter"
"tailscale.com/envknob"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/metrics"
"tailscale.com/net/dns"
@@ -643,13 +644,15 @@ func (ns *Impl) UpdateNetstackIPs(nm *netmap.NetworkMap) {
var selfNode tailcfg.NodeView
var serviceAddrSet set.Set[netip.Addr]
if nm != nil {
vipServiceIPMap := nm.GetVIPServiceIPMap()
serviceAddrSet = make(set.Set[netip.Addr], len(vipServiceIPMap)*2)
for _, addrs := range vipServiceIPMap {
serviceAddrSet.AddSlice(addrs)
}
ns.atomicIsLocalIPFunc.Store(ipset.NewContainsIPFunc(nm.GetAddresses()))
ns.atomicIsVIPServiceIPFunc.Store(serviceAddrSet.Contains)
if buildfeatures.HasServe {
vipServiceIPMap := nm.GetVIPServiceIPMap()
serviceAddrSet = make(set.Set[netip.Addr], len(vipServiceIPMap)*2)
for _, addrs := range vipServiceIPMap {
serviceAddrSet.AddSlice(addrs)
}
ns.atomicIsVIPServiceIPFunc.Store(serviceAddrSet.Contains)
}
selfNode = nm.SelfNode
} else {
ns.atomicIsLocalIPFunc.Store(ipset.FalseContainsIPFunc())
@@ -1032,6 +1035,9 @@ func (ns *Impl) isLocalIP(ip netip.Addr) bool {
// isVIPServiceIP reports whether ip is an IP address that's
// assigned to a VIP service.
func (ns *Impl) isVIPServiceIP(ip netip.Addr) bool {
if !buildfeatures.HasServe {
return false
}
return ns.atomicIsVIPServiceIPFunc.Load()(ip)
}
@@ -1074,7 +1080,7 @@ func (ns *Impl) shouldProcessInbound(p *packet.Parsed, t *tstun.Wrapper) bool {
return true
}
}
if isService {
if buildfeatures.HasServe && isService {
if p.IsEchoRequest() {
return true
}