diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 9fcc8aaf2..af46f896e 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -1094,29 +1094,6 @@ func (lc *LocalClient) NetworkLockDisable(ctx context.Context, secret []byte) er return nil } -// StreamServe returns an io.ReadCloser that streams serve/Funnel -// connections made to the provided HostPort. -// -// If Serve and Funnel were not already enabled for the HostPort in the ServeConfig, -// the backend enables it for the duration of the context's lifespan and -// then turns it back off once the context is closed. If either are already enabled, -// then they remain that way but logs are still streamed -func (lc *LocalClient) StreamServe(ctx context.Context, hp ipn.ServeStreamRequest) (io.ReadCloser, error) { - req, err := http.NewRequestWithContext(ctx, "POST", "http://"+apitype.LocalAPIHost+"/localapi/v0/stream-serve", jsonBody(hp)) - if err != nil { - return nil, err - } - res, err := lc.doLocalRequestNiceError(req) - if err != nil { - return nil, err - } - if res.StatusCode != 200 { - res.Body.Close() - return nil, errors.New(res.Status) - } - return res.Body, nil -} - // GetServeConfig return the current serve config. // // If the serve config is empty, it returns (nil, nil). diff --git a/cmd/tailscale/cli/serve.go b/cmd/tailscale/cli/serve.go index 39c0e106e..ab1a16913 100644 --- a/cmd/tailscale/cli/serve.go +++ b/cmd/tailscale/cli/serve.go @@ -149,7 +149,6 @@ type localServeClient interface { QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*tailscale.IPNBusWatcher, error) IncrementCounter(ctx context.Context, name string, delta int) error - StreamServe(ctx context.Context, req ipn.ServeStreamRequest) (io.ReadCloser, error) // TODO: testing :) } // serveEnv is the environment the serve command runs within. All I/O should be diff --git a/cmd/tailscale/cli/serve_dev.go b/cmd/tailscale/cli/serve_dev.go index c2c94cc4a..06ef559b9 100644 --- a/cmd/tailscale/cli/serve_dev.go +++ b/cmd/tailscale/cli/serve_dev.go @@ -5,9 +5,9 @@ package cli import ( "context" + "errors" "flag" "fmt" - "io" "log" "os" "os/signal" @@ -16,6 +16,7 @@ import ( "github.com/peterbourgon/ff/v3/ffcli" "tailscale.com/ipn" + "tailscale.com/util/mak" ) type execFunc func(ctx context.Context, args []string) error @@ -30,14 +31,14 @@ var infoMap = map[string]commandInfo{ ShortHelp: "Serve content and local servers on your tailnet", LongHelp: strings.Join([]string{ "Serve lets you share a local server securely within your tailnet.", - "To share a local server on the internet, use \"tailscale funnel\"", + `To share a local server on the internet, use "tailscale funnel"`, }, "\n"), }, "funnel": { ShortHelp: "Serve content and local servers on the internet", LongHelp: strings.Join([]string{ "Funnel lets you share a local server on the internet using Tailscale.", - "To share only within your tailnet, use \"tailscale serve\"", + `To share only within your tailnet, use "tailscale serve"`, }, "\n"), }, } @@ -134,14 +135,56 @@ func (e *serveEnv) runServeDev(funnel bool) execFunc { } func (e *serveEnv) streamServe(ctx context.Context, req ipn.ServeStreamRequest) error { - stream, err := e.lc.StreamServe(ctx, req) + watcher, err := e.lc.WatchIPNBus(ctx, ipn.NotifyInitialState) if err != nil { return err } - defer stream.Close() + defer watcher.Close() + n, err := watcher.Next() + if err != nil { + return err + } + sessionID := n.SessionID + if sessionID == "" { + return errors.New("missing SessionID") + } + sc, err := e.lc.GetServeConfig(ctx) + if err != nil { + return fmt.Errorf("error getting serve config: %w", err) + } + if sc == nil { + sc = &ipn.ServeConfig{} + } + setHandler(sc, req, sessionID) + err = e.lc.SetServeConfig(ctx, sc) + if err != nil { + return fmt.Errorf("error setting serve config: %w", err) + } + + fmt.Fprintf(os.Stderr, "Funnel started on \"https://%s\".\n", strings.TrimSuffix(string(req.HostPort), ":443")) + fmt.Fprintf(os.Stderr, "Press Ctrl-C to stop Funnel.\n\n") + + for { + _, err = watcher.Next() + if err != nil { + if errors.Is(err, context.Canceled) { + return nil + } + return err + } + } +} - fmt.Fprintf(os.Stderr, "Serve started on \"https://%s\".\n", strings.TrimSuffix(string(req.HostPort), ":443")) - fmt.Fprintf(os.Stderr, "Press Ctrl-C to stop.\n\n") - _, err = io.Copy(os.Stdout, stream) - return err +// setHandler modifies sc to add a Foreground config (described by req) with the given sessionID. +func setHandler(sc *ipn.ServeConfig, req ipn.ServeStreamRequest, sessionID string) { + fconf := &ipn.ServeConfig{} + mak.Set(&sc.Foreground, sessionID, fconf) + mak.Set(&fconf.TCP, 443, &ipn.TCPPortHandler{HTTPS: true}) + + wsc := &ipn.WebServerConfig{} + mak.Set(&fconf.Web, req.HostPort, wsc) + mak.Set(&wsc.Handlers, req.MountPoint, &ipn.HTTPHandler{ + Proxy: req.Source, + }) + mak.Set(&fconf.AllowFunnel, req.HostPort, true) } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 85eb7bf56..65a167f2c 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "log" + "maps" "net" "net/http" "net/http/httputil" @@ -241,8 +242,9 @@ type LocalBackend struct { componentLogUntil map[string]componentLogState // ServeConfig fields. (also guarded by mu) - lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig - serveConfig ipn.ServeConfigView // or !Valid if none + lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig + serveConfig ipn.ServeConfigView // or !Valid if none + activeWatchSessions set.Set[string] // of WatchIPN SessionID serveListeners map[netip.AddrPort]*serveListener // addrPort => serveListener serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *httputil.ReverseProxy @@ -301,23 +303,24 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo clock := tstime.StdClock{} b := &LocalBackend{ - ctx: ctx, - ctxCancel: cancel, - logf: logf, - keyLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now), - statsLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now), - sys: sys, - e: e, - dialer: dialer, - store: store, - pm: pm, - backendLogID: logID, - state: ipn.NoState, - portpoll: portpoll, - em: newExpiryManager(logf), - gotPortPollRes: make(chan struct{}), - loginFlags: loginFlags, - clock: clock, + ctx: ctx, + ctxCancel: cancel, + logf: logf, + keyLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now), + statsLogf: logger.LogOnChange(logf, 5*time.Minute, clock.Now), + sys: sys, + e: e, + dialer: dialer, + store: store, + pm: pm, + backendLogID: logID, + state: ipn.NoState, + portpoll: portpoll, + em: newExpiryManager(logf), + gotPortPollRes: make(chan struct{}), + loginFlags: loginFlags, + clock: clock, + activeWatchSessions: make(set.Set[string]), } netMon := sys.NetMon.Get() @@ -1956,6 +1959,7 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa var ini *ipn.Notify b.mu.Lock() + b.activeWatchSessions.Add(sessionID) const initialBits = ipn.NotifyInitialState | ipn.NotifyInitialPrefs | ipn.NotifyInitialNetMap if mask&initialBits != 0 { @@ -1981,6 +1985,7 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa defer func() { b.mu.Lock() delete(b.notifyWatchers, handle) + delete(b.activeWatchSessions, sessionID) b.mu.Unlock() }() @@ -2011,7 +2016,9 @@ func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWa go b.pollRequestEngineStatus(ctx) } - defer b.DeleteForegroundSession(sessionID) // TODO(marwan-at-work): check err + // TODO(marwan-at-work): check err + // TODO(marwan-at-work): streaming background logs? + defer b.DeleteForegroundSession(sessionID) for { select { @@ -2776,7 +2783,7 @@ func (b *LocalBackend) SetPrefs(newp *ipn.Prefs) { // 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.AllowFunnel().Len() > 0 + return b.serveConfig.Valid() && b.serveConfig.HasAllowFunnel() } // setPrefsLockedOnEntry requires b.mu be held to call it, but it @@ -4092,6 +4099,10 @@ 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.netMap == nil || !b.netMap.SelfNode.Valid() || !prefs.Valid() || b.pm.CurrentProfile().ID == "" { // We're not logged in, so we don't have a profile. @@ -4100,6 +4111,7 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) { 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. @@ -4119,6 +4131,12 @@ func (b *LocalBackend) reloadServeConfigLocked(prefs ipn.PrefsView) { b.serveConfig = ipn.ServeConfigView{} return } + + // remove inactive sessions + maps.DeleteFunc(conf.Foreground, func(s string, sc *ipn.ServeConfig) bool { + return !b.activeWatchSessions.Contains(s) + }) + b.serveConfig = conf.View() } @@ -4136,7 +4154,7 @@ func (b *LocalBackend) setTCPPortsInterceptedFromNetmapAndPrefsLocked(prefs ipn. b.reloadServeConfigLocked(prefs) if b.serveConfig.Valid() { servePorts := make([]uint16, 0, 3) - b.serveConfig.TCP().Range(func(port uint16, _ ipn.TCPPortHandlerView) bool { + b.serveConfig.RangeOverTCPs(func(port uint16, _ ipn.TCPPortHandlerView) bool { if port > 0 { servePorts = append(servePorts, uint16(port)) } @@ -4169,7 +4187,7 @@ func (b *LocalBackend) setServeProxyHandlersLocked() { return } var backends map[string]bool - b.serveConfig.Web().Range(func(_ ipn.HostPort, conf ipn.WebServerConfigView) (cont bool) { + b.serveConfig.RangeOverWebs(func(_ ipn.HostPort, conf ipn.WebServerConfigView) (cont bool) { conf.Handlers().Range(func(_ string, h ipn.HTTPHandlerView) (cont bool) { backend := h.Proxy() if backend == "" { diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index a9422433e..7c6b2a3fd 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -26,6 +26,7 @@ import ( "tailscale.com/types/logger" "tailscale.com/types/logid" "tailscale.com/types/netmap" + "tailscale.com/util/set" "tailscale.com/wgengine" "tailscale.com/wgengine/filter" "tailscale.com/wgengine/wgcfg" @@ -843,6 +844,9 @@ var _ legacyBackend = (*LocalBackend)(nil) func TestWatchNotificationsCallbacks(t *testing.T) { b := new(LocalBackend) + // activeWatchSessions is typically set in NewLocalBackend + // so WatchNotifications expects it to be non-empty. + b.activeWatchSessions = make(set.Set[string]) n := new(ipn.Notify) b.WatchNotifications(context.Background(), 0, func() { b.mu.Lock() diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 13af214d0..38c47867a 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -274,111 +274,6 @@ func (b *LocalBackend) DeleteForegroundSession(sessionID string) error { return b.setServeConfigLocked(sc) } -// StreamServe opens a stream to write any incoming connections made -// to the given HostPort out to the listening io.Writer. -// -// If Serve and Funnel were not already enabled for the HostPort in the ServeConfig, -// the backend enables it for the duration of the context's lifespan and -// then turns it back off once the context is closed. If either are already enabled, -// then they remain that way but logs are still streamed -// -// TODO(marwan-at-work): this whole endpoint will be -// deleted in a follow up PR in favor of WatchIPNBus -func (b *LocalBackend) StreamServe(ctx context.Context, w io.Writer, req ipn.ServeStreamRequest) (err error) { - f, ok := w.(http.Flusher) - if !ok { - return errors.New("writer not a flusher") - } - f.Flush() - - port, err := req.HostPort.Port() - if err != nil { - return err - } - - // Turn on Funnel for the given HostPort. - sc := b.ServeConfig().AsStruct() - if sc == nil { - sc = &ipn.ServeConfig{} - } - setHandler(sc, req) - if err := b.SetServeConfig(sc); err != nil { - return fmt.Errorf("errro setting serve config: %w", err) - } - // Defer turning off Funnel once stream ends. - defer func() { - sc := b.ServeConfig().AsStruct() - deleteHandler(sc, req, port) - err = errors.Join(err, b.SetServeConfig(sc)) - }() - - select { - case <-ctx.Done(): - // Triggered by foreground `tailscale funnel` process - // (the streamer) getting closed, or by turning off Tailscale. - } - - return nil -} - -func setHandler(sc *ipn.ServeConfig, req ipn.ServeStreamRequest) { - if sc.TCP == nil { - sc.TCP = make(map[uint16]*ipn.TCPPortHandler) - } - if _, ok := sc.TCP[443]; !ok { - sc.TCP[443] = &ipn.TCPPortHandler{ - HTTPS: true, - } - } - if sc.Web == nil { - sc.Web = make(map[ipn.HostPort]*ipn.WebServerConfig) - } - wsc, ok := sc.Web[req.HostPort] - if !ok { - wsc = &ipn.WebServerConfig{} - sc.Web[req.HostPort] = wsc - } - if wsc.Handlers == nil { - wsc.Handlers = make(map[string]*ipn.HTTPHandler) - } - wsc.Handlers[req.MountPoint] = &ipn.HTTPHandler{ - Proxy: req.Source, - } - if req.Funnel { - if sc.AllowFunnel == nil { - sc.AllowFunnel = make(map[ipn.HostPort]bool) - } - sc.AllowFunnel[req.HostPort] = true - } -} - -func deleteHandler(sc *ipn.ServeConfig, req ipn.ServeStreamRequest, port uint16) { - delete(sc.AllowFunnel, req.HostPort) - if sc.TCP != nil { - delete(sc.TCP, port) - } - if sc.Web == nil { - return - } - if sc.Web[req.HostPort] == nil { - return - } - wsc, ok := sc.Web[req.HostPort] - if !ok { - return - } - if wsc.Handlers == nil { - return - } - if _, ok := wsc.Handlers[req.MountPoint]; !ok { - return - } - delete(wsc.Handlers, req.MountPoint) - if len(wsc.Handlers) == 0 { - delete(sc.Web, req.HostPort) - } -} - func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target ipn.HostPort, srcAddr netip.AddrPort, getConnOrReset func() (net.Conn, bool), sendRST func()) { b.mu.Lock() sc := b.serveConfig @@ -390,7 +285,7 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target return } - if !sc.AllowFunnel().Get(target) { + if !sc.HasFunnelForTarget(target) { b.logf("localbackend: got ingress conn for unconfigured %q; rejecting", target) sendRST() return @@ -448,7 +343,7 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort) return nil } - tcph, ok := sc.TCP().GetOk(dport) + tcph, ok := sc.FindTCP(dport) if !ok { b.logf("[unexpected] localbackend: got TCP conn without TCP config for port %v; from %v", dport, srcAddr) return nil @@ -643,6 +538,8 @@ func (b *LocalBackend) addTailscaleIdentityHeaders(r *httputil.ProxyRequest) { r.Out.Header.Set("Tailscale-Headers-Info", "https://tailscale.com/s/serve-headers") } +// serveWebHandler is an http.HandlerFunc that maps incoming requests to the +// correct *http. func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) { h, mountPoint, ok := b.getServeHandler(r) if !ok { @@ -784,7 +681,7 @@ func (b *LocalBackend) webServerConfig(hostname string, port uint16) (c ipn.WebS if !b.serveConfig.Valid() { return c, false } - return b.serveConfig.Web().GetOk(key) + return b.serveConfig.FindWeb(key) } func (b *LocalBackend) getTLSServeCertForPort(port uint16) func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 8b0cd8f54..678e91458 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -97,7 +97,6 @@ var handler = map[string]localAPIHandler{ "set-expiry-sooner": (*Handler).serveSetExpirySooner, "start": (*Handler).serveStart, "status": (*Handler).serveStatus, - "stream-serve": (*Handler).serveStreamServe, "tka/init": (*Handler).serveTKAInit, "tka/log": (*Handler).serveTKALog, "tka/modify": (*Handler).serveTKAModify, @@ -854,35 +853,6 @@ func (h *Handler) serveServeConfig(w http.ResponseWriter, r *http.Request) { } } -// serveStreamServe handles foreground serve and funnel streams. This is -// currently in development per https://github.com/tailscale/tailscale/issues/8489 -func (h *Handler) serveStreamServe(w http.ResponseWriter, r *http.Request) { - if !envknob.UseWIPCode() { - http.Error(w, "stream serve not yet available", http.StatusNotImplemented) - return - } - if !h.PermitWrite { - // Write permission required because we modify the ServeConfig. - http.Error(w, "serve stream denied", http.StatusForbidden) - return - } - if r.Method != "POST" { - http.Error(w, "POST required", http.StatusMethodNotAllowed) - return - } - var req ipn.ServeStreamRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeErrorJSON(w, fmt.Errorf("decoding HostPort: %w", err)) - return - } - w.Header().Set("Content-Type", "application/json") - if err := h.b.StreamServe(r.Context(), w, req); err != nil { - writeErrorJSON(w, fmt.Errorf("streaming serve: %w", err)) - return - } - w.WriteHeader(http.StatusOK) -} - func (h *Handler) serveCheckIPForwarding(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "IP forwarding check access denied", http.StatusForbidden) diff --git a/ipn/serve.go b/ipn/serve.go index ad77dedf4..754cd09a4 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -37,12 +37,12 @@ type ServeConfig struct { // traffic is allowed, from trusted ingress peers. AllowFunnel map[HostPort]bool `json:",omitempty"` - // Foreground is a map of an IPN Bus session id to a - // foreground serve config. Note that only TCP and Web - // are used inside the Foreground map. - // - // TODO(marwan-at-work): this is not currently - // used. Remove the TODO in the follow up PR. + // Foreground is a map of an IPN Bus session ID to an alternate foreground + // serve config that's valid for the life of that WatchIPNBus session ID. + // This. This allows the config to specify ephemeral configs that are + // used in the CLI's foreground mode to ensure ungraceful shutdowns + // of either the client or the LocalBackend does not expose ports + // that users are not aware of. Foreground map[string]*ServeConfig `json:",omitempty"` } @@ -320,3 +320,102 @@ func CheckFunnelPort(wantedPort uint16, nodeAttrs []string) error { } return deny(portsStr) } + +// RangeOverTCPs ranges over both background and foreground TCPs. +// If the returned bool from the given f is false, then this function stops +// iterating immediately and does not check other foreground configs. +func (v ServeConfigView) RangeOverTCPs(f func(port uint16, _ TCPPortHandlerView) bool) { + parentCont := true + v.TCP().Range(func(k uint16, v TCPPortHandlerView) (cont bool) { + parentCont = f(k, v) + return parentCont + }) + v.Foreground().Range(func(k string, v ServeConfigView) (cont bool) { + if !parentCont { + return false + } + v.TCP().Range(func(k uint16, v TCPPortHandlerView) (cont bool) { + parentCont = f(k, v) + return parentCont + }) + return parentCont + }) +} + +// RangeOverWebs ranges over both background and foreground Webs. +// If the returned bool from the given f is false, then this function stops +// iterating immediately and does not check other foreground configs. +func (v ServeConfigView) RangeOverWebs(f func(_ HostPort, conf WebServerConfigView) bool) { + parentCont := true + v.Web().Range(func(k HostPort, v WebServerConfigView) (cont bool) { + parentCont = f(k, v) + return parentCont + }) + v.Foreground().Range(func(k string, v ServeConfigView) (cont bool) { + if !parentCont { + return false + } + v.Web().Range(func(k HostPort, v WebServerConfigView) (cont bool) { + parentCont = f(k, v) + return parentCont + }) + return parentCont + }) +} + +// FindTCP returns the first TCP that matches with the given port. It +// prefers a foreground match first followed by a background search if none +// existed. +func (v ServeConfigView) FindTCP(port uint16) (res TCPPortHandlerView, ok bool) { + v.Foreground().Range(func(_ string, v ServeConfigView) (cont bool) { + res, ok = v.TCP().GetOk(port) + return !ok + }) + if ok { + return res, ok + } + return v.TCP().GetOk(port) +} + +// FindWeb returns the first Web that matches with the given HostPort. It +// prefers a foreground match first followed by a background search if none +// existed. +func (v ServeConfigView) FindWeb(hp HostPort) (res WebServerConfigView, ok bool) { + v.Foreground().Range(func(_ string, v ServeConfigView) (cont bool) { + res, ok = v.Web().GetOk(hp) + return !ok + }) + if ok { + return res, ok + } + return v.Web().GetOk(hp) +} + +// HasAllowFunnel returns whether this config has at least one AllowFunnel +// set in the background or foreground configs. +func (v ServeConfigView) HasAllowFunnel() bool { + return v.AllowFunnel().Len() > 0 || func() bool { + var exists bool + v.Foreground().Range(func(k string, v ServeConfigView) (cont bool) { + exists = v.AllowFunnel().Len() > 0 + return !exists + }) + return exists + }() +} + +// FindFunnel reports whether target exists in in either the background AllowFunnel +// or any of the foreground configs. +func (v ServeConfigView) HasFunnelForTarget(target HostPort) bool { + if v.AllowFunnel().Get(target) { + return true + } + var exists bool + v.Foreground().Range(func(_ string, v ServeConfigView) (cont bool) { + if exists = v.AllowFunnel().Get(target); exists { + return false + } + return true + }) + return exists +}