From 50990f8931a5b9a0c893a8943bd7a2e60b87a923 Mon Sep 17 00:00:00 2001 From: Marwan Sulaiman Date: Tue, 5 Sep 2023 13:51:52 -0400 Subject: [PATCH] ipn, ipn/ipnlocal: add Foreground field for ServeConfig This PR adds a new field to the serve config that can be used to identify which serves are in "foreground mode" and then can also be used to ensure they do not get persisted to disk so that if Tailscaled gets ungracefully shutdown, the reloaded ServeConfig will not have those ports opened. Updates #8489 Signed-off-by: Marwan Sulaiman --- client/tailscale/localclient.go | 23 ------- cmd/tailscale/cli/serve.go | 1 - cmd/tailscale/cli/serve_dev.go | 61 ++++++++++++++--- ipn/ipnlocal/local.go | 64 +++++++++++------- ipn/ipnlocal/local_test.go | 4 ++ ipn/ipnlocal/serve.go | 113 ++------------------------------ ipn/localapi/localapi.go | 30 --------- ipn/serve.go | 111 +++++++++++++++++++++++++++++-- 8 files changed, 207 insertions(+), 200 deletions(-) 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 +}