diff --git a/cmd/tsidp/tsidp.go b/cmd/tsidp/tsidp.go index 43a3ae94b..3e077f9fd 100644 --- a/cmd/tsidp/tsidp.go +++ b/cmd/tsidp/tsidp.go @@ -7,6 +7,7 @@ package main import ( + "bytes" "context" crand "crypto/rand" "crypto/rsa" @@ -16,6 +17,7 @@ import ( "encoding/binary" "encoding/json" "encoding/pem" + "errors" "flag" "fmt" "io" @@ -25,6 +27,7 @@ import ( "net/netip" "net/url" "os" + "os/signal" "strconv" "strings" "sync" @@ -35,6 +38,7 @@ import ( "tailscale.com/client/tailscale" "tailscale.com/client/tailscale/apitype" "tailscale.com/envknob" + "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" "tailscale.com/tsnet" @@ -44,13 +48,22 @@ import ( "tailscale.com/util/mak" "tailscale.com/util/must" "tailscale.com/util/rands" + "tailscale.com/version" ) +// ctxConn is a key to look up a net.Conn stored in an HTTP request's context. +type ctxConn struct{} + +// funnelClientsFile is the file where client IDs and secrets for OIDC clients +// accessing the IDP over Funnel are persisted. +const funnelClientsFile = "oidc-funnel-clients.json" + var ( flagVerbose = flag.Bool("verbose", false, "be verbose") flagPort = flag.Int("port", 443, "port to listen on") flagLocalPort = flag.Int("local-port", -1, "allow requests from localhost") flagUseLocalTailscaled = flag.Bool("use-local-tailscaled", false, "use local tailscaled instead of tsnet") + flagFunnel = flag.Bool("funnel", false, "use Tailscale Funnel to make tsidp available on the public internet") ) func main() { @@ -61,9 +74,11 @@ func main() { } var ( - lc *tailscale.LocalClient - st *ipnstate.Status - err error + lc *tailscale.LocalClient + st *ipnstate.Status + err error + watcherChan chan error + cleanup func() lns []net.Listener ) @@ -90,6 +105,18 @@ func main() { if !anySuccess { log.Fatalf("failed to listen on any of %v", st.TailscaleIPs) } + + // tailscaled needs to be setting an HTTP header for funneled requests + // that older versions don't provide. + // TODO(naman): is this the correct check? + if *flagFunnel && !version.AtLeast(st.Version, "1.71.0") { + log.Fatalf("Local tailscaled not new enough to support -funnel. Update Tailscale or use tsnet mode.") + } + cleanup, watcherChan, err = serveOnLocalTailscaled(ctx, lc, st, uint16(*flagPort), *flagFunnel) + if err != nil { + log.Fatalf("could not serve on local tailscaled: %v", err) + } + defer cleanup() } else { ts := &tsnet.Server{ Hostname: "idp", @@ -105,7 +132,15 @@ func main() { if err != nil { log.Fatalf("getting local client: %v", err) } - ln, err := ts.ListenTLS("tcp", fmt.Sprintf(":%d", *flagPort)) + var ln net.Listener + if *flagFunnel { + if err := ipn.CheckFunnelAccess(uint16(*flagPort), st.Self); err != nil { + log.Fatalf("%v", err) + } + ln, err = ts.ListenFunnel("tcp", fmt.Sprintf(":%d", *flagPort)) + } else { + ln, err = ts.ListenTLS("tcp", fmt.Sprintf(":%d", *flagPort)) + } if err != nil { log.Fatal(err) } @@ -113,13 +148,26 @@ func main() { } srv := &idpServer{ - lc: lc, + lc: lc, + funnel: *flagFunnel, + localTSMode: *flagUseLocalTailscaled, } if *flagPort != 443 { srv.serverURL = fmt.Sprintf("https://%s:%d", strings.TrimSuffix(st.Self.DNSName, "."), *flagPort) } else { srv.serverURL = fmt.Sprintf("https://%s", strings.TrimSuffix(st.Self.DNSName, ".")) } + if *flagFunnel { + f, err := os.Open(funnelClientsFile) + if err == nil { + srv.funnelClients = make(map[string]*funnelClient) + if err := json.NewDecoder(f).Decode(&srv.funnelClients); err != nil { + log.Fatalf("could not parse %s: %v", funnelClientsFile, err) + } + } else if !errors.Is(err, os.ErrNotExist) { + log.Fatalf("could not open %s: %v", funnelClientsFile, err) + } + } log.Printf("Running tsidp at %s ...", srv.serverURL) @@ -134,35 +182,129 @@ func main() { } for _, ln := range lns { - go http.Serve(ln, srv) + server := http.Server{ + Handler: srv, + ConnContext: func(ctx context.Context, c net.Conn) context.Context { + return context.WithValue(ctx, ctxConn{}, c) + }, + } + go server.Serve(ln) + } + // need to catch os.Interrupt, otherwise deferred cleanup code doesn't run + exitChan := make(chan os.Signal, 1) + signal.Notify(exitChan, os.Interrupt) + select { + case <-exitChan: + log.Printf("interrupt, exiting") + return + case <-watcherChan: + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { + log.Printf("watcher closed, exiting") + return + } + log.Fatalf("watcher error: %v", err) + return + } +} + +// serveOnLocalTailscaled starts a serve session using an already-running +// tailscaled instead of starting a fresh tsnet server, making something +// listening on clientDNSName:dstPort accessible over serve/funnel. +func serveOnLocalTailscaled(ctx context.Context, lc *tailscale.LocalClient, st *ipnstate.Status, dstPort uint16, shouldFunnel bool) (cleanup func(), watcherChan chan error, err error) { + // In order to support funneling out in local tailscaled mode, we need + // to add a serve config to forward the listeners we bound above and + // allow those forwarders to be funneled out. + sc, err := lc.GetServeConfig(ctx) + if err != nil { + return nil, nil, fmt.Errorf("could not get serve config: %v", err) + } + if sc == nil { + sc = new(ipn.ServeConfig) + } + + // We watch the IPN bus just to get a session ID. The session expires + // when we stop watching the bus, and that auto-deletes the foreground + // serve/funnel configs we are creating below. + watcher, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyNoPrivateKeys) + if err != nil { + return nil, nil, fmt.Errorf("could not set up ipn bus watcher: %v", err) + } + defer func() { + if err != nil { + watcher.Close() + } + }() + n, err := watcher.Next() + if err != nil { + return nil, nil, fmt.Errorf("could not get initial state from ipn bus watcher: %v", err) + } + if n.SessionID == "" { + err = fmt.Errorf("missing sessionID in ipn.Notify") + return nil, nil, err + } + watcherChan = make(chan error) + go func() { + for { + _, err = watcher.Next() + if err != nil { + watcherChan <- err + return + } + } + }() + + // Create a foreground serve config that gets cleaned up when tsidp + // exits and the session ID associated with this config is invalidated. + foregroundSc := new(ipn.ServeConfig) + mak.Set(&sc.Foreground, n.SessionID, foregroundSc) + serverURL := strings.TrimSuffix(st.Self.DNSName, ".") + fmt.Printf("setting funnel for %s:%v\n", serverURL, dstPort) + + foregroundSc.SetFunnel(serverURL, dstPort, shouldFunnel) + foregroundSc.SetWebHandler(&ipn.HTTPHandler{ + Proxy: fmt.Sprintf("https://%s", net.JoinHostPort(serverURL, strconv.Itoa(int(dstPort)))), + }, serverURL, uint16(*flagPort), "/", true) + err = lc.SetServeConfig(ctx, sc) + if err != nil { + return nil, watcherChan, fmt.Errorf("could not set serve config: %v", err) } - select {} + + return func() { watcher.Close() }, watcherChan, nil } type idpServer struct { lc *tailscale.LocalClient loopbackURL string serverURL string // "https://foo.bar.ts.net" + funnel bool + localTSMode bool lazyMux lazy.SyncValue[*http.ServeMux] lazySigningKey lazy.SyncValue[*signingKey] lazySigner lazy.SyncValue[jose.Signer] - mu sync.Mutex // guards the fields below - code map[string]*authRequest // keyed by random hex - accessToken map[string]*authRequest // keyed by random hex + mu sync.Mutex // guards the fields below + code map[string]*authRequest // keyed by random hex + accessToken map[string]*authRequest // keyed by random hex + funnelClients map[string]*funnelClient // keyed by client ID } type authRequest struct { // localRP is true if the request is from a relying party running on the - // same machine as the idp server. It is mutually exclusive with rpNodeID. + // same machine as the idp server. It is mutually exclusive with rpNodeID + // and funnelRP. localRP bool // rpNodeID is the NodeID of the relying party (who requested the auth, such // as Proxmox or Synology), not the user node who is being authenticated. It - // is mutually exclusive with localRP. + // is mutually exclusive with localRP and funnelRP. rpNodeID tailcfg.NodeID + // funnelRP is non-nil if the request is from a relying party outside the + // tailnet, via Tailscale Funnel. It is mutually exclusive with rpNodeID + // and localRP. + funnelRP *funnelClient + // clientID is the "client_id" sent in the authorized request. clientID string @@ -181,9 +323,12 @@ type authRequest struct { validTill time.Time } -func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string, lc *tailscale.LocalClient) error { +// allowRelyingParty validates that a relying party identified either by a +// known remoteAddr or a valid client ID/secret pair is allowed to proceed +// with the authorization flow associated with this authRequest. +func (ar *authRequest) allowRelyingParty(r *http.Request, lc *tailscale.LocalClient) error { if ar.localRP { - ra, err := netip.ParseAddrPort(remoteAddr) + ra, err := netip.ParseAddrPort(r.RemoteAddr) if err != nil { return err } @@ -192,7 +337,18 @@ func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string, } return nil } - who, err := lc.WhoIs(ctx, remoteAddr) + if ar.funnelRP != nil { + clientID, clientSecret, ok := r.BasicAuth() + if !ok { + clientID = r.FormValue("client_id") + clientSecret = r.FormValue("client_secret") + } + if ar.funnelRP.ID != clientID || ar.funnelRP.Secret != clientSecret { + return fmt.Errorf("tsidp: invalid client credentials") + } + return nil + } + who, err := lc.WhoIs(r.Context(), r.RemoteAddr) if err != nil { return fmt.Errorf("tsidp: error getting WhoIs: %w", err) } @@ -203,24 +359,60 @@ func (ar *authRequest) allowRelyingParty(ctx context.Context, remoteAddr string, } func (s *idpServer) authorize(w http.ResponseWriter, r *http.Request) { - who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr) + // This URL is visited by the user who is being authenticated. If they are + // visiting the URL over Funnel, that means they are not part of the + // tailnet that they are trying to be authenticated for. + if isFunnelRequest(r) { + http.Error(w, "tsidp: unauthorized", http.StatusUnauthorized) + return + } + + uq := r.URL.Query() + + redirectURI := uq.Get("redirect_uri") + if redirectURI == "" { + http.Error(w, "tsidp: must specify redirect_uri", http.StatusBadRequest) + return + } + + var remoteAddr string + if s.localTSMode { + // in local tailscaled mode, the local tailscaled is forwarding us + // HTTP requests, so reading r.RemoteAddr will just get us our own + // address. + remoteAddr = r.Header.Get("X-Forwarded-For") + } else { + remoteAddr = r.RemoteAddr + } + who, err := s.lc.WhoIs(r.Context(), remoteAddr) if err != nil { log.Printf("Error getting WhoIs: %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } - uq := r.URL.Query() - code := rands.HexString(32) ar := &authRequest{ nonce: uq.Get("nonce"), remoteUser: who, - redirectURI: uq.Get("redirect_uri"), + redirectURI: redirectURI, clientID: uq.Get("client_id"), } - if r.URL.Path == "/authorize/localhost" { + if r.URL.Path == "/authorize/funnel" { + s.mu.Lock() + c, ok := s.funnelClients[ar.clientID] + s.mu.Unlock() + if !ok { + http.Error(w, "tsidp: invalid client ID", http.StatusBadRequest) + return + } + if ar.redirectURI != c.RedirectURI { + http.Error(w, "tsidp: redirect_uri mismatch", http.StatusBadRequest) + return + } + ar.funnelRP = c + } else if r.URL.Path == "/authorize/localhost" { ar.localRP = true } else { var ok bool @@ -237,8 +429,10 @@ func (s *idpServer) authorize(w http.ResponseWriter, r *http.Request) { q := make(url.Values) q.Set("code", code) - q.Set("state", uq.Get("state")) - u := uq.Get("redirect_uri") + "?" + q.Encode() + if state := uq.Get("state"); state != "" { + q.Set("state", state) + } + u := redirectURI + "?" + q.Encode() log.Printf("Redirecting to %q", u) http.Redirect(w, r, u, http.StatusFound) @@ -251,6 +445,7 @@ func (s *idpServer) newMux() *http.ServeMux { mux.HandleFunc("/authorize/", s.authorize) mux.HandleFunc("/userinfo", s.serveUserInfo) mux.HandleFunc("/token", s.serveToken) + mux.HandleFunc("/clients/", s.serveClients) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { io.WriteString(w, "

Tailscale OIDC IdP

") @@ -284,11 +479,6 @@ func (s *idpServer) serveUserInfo(w http.ResponseWriter, r *http.Request) { http.Error(w, "tsidp: invalid token", http.StatusBadRequest) return } - if err := ar.allowRelyingParty(r.Context(), r.RemoteAddr, s.lc); err != nil { - log.Printf("Error allowing relying party: %v", err) - http.Error(w, err.Error(), http.StatusForbidden) - return - } if ar.validTill.Before(time.Now()) { http.Error(w, "tsidp: token expired", http.StatusBadRequest) @@ -348,7 +538,7 @@ func (s *idpServer) serveToken(w http.ResponseWriter, r *http.Request) { http.Error(w, "tsidp: code not found", http.StatusBadRequest) return } - if err := ar.allowRelyingParty(r.Context(), r.RemoteAddr, s.lc); err != nil { + if err := ar.allowRelyingParty(r, s.lc); err != nil { log.Printf("Error allowing relying party: %v", err) http.Error(w, err.Error(), http.StatusForbidden) return @@ -581,7 +771,9 @@ func (s *idpServer) serveOpenIDConfig(w http.ResponseWriter, r *http.Request) { } var authorizeEndpoint string rpEndpoint := s.serverURL - if who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil { + if isFunnelRequest(r) { + authorizeEndpoint = fmt.Sprintf("%s/authorize/funnel", s.serverURL) + } else if who, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil { authorizeEndpoint = fmt.Sprintf("%s/authorize/%d", s.serverURL, who.Node.ID) } else if ap.Addr().IsLoopback() { rpEndpoint = s.loopbackURL @@ -611,6 +803,148 @@ func (s *idpServer) serveOpenIDConfig(w http.ResponseWriter, r *http.Request) { } } +// funnelClient represents an OIDC client/relying party that is accessing the +// IDP over Funnel. +type funnelClient struct { + ID string `json:"client_id"` + Secret string `json:"client_secret,omitempty"` + Name string `json:"name,omitempty"` + RedirectURI string `json:"redirect_uri"` +} + +// /clients is a privileged endpoint that allows the visitor to create new +// Funnel-capable OIDC clients, so it is only accessible over the tailnet. +func (s *idpServer) serveClients(w http.ResponseWriter, r *http.Request) { + if isFunnelRequest(r) { + http.Error(w, "tsidp: not found", http.StatusNotFound) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/clients/") + + if path == "new" { + s.serveNewClient(w, r) + return + } + + if path == "" { + s.serveGetClientsList(w, r) + return + } + + s.mu.Lock() + c, ok := s.funnelClients[path] + s.mu.Unlock() + if !ok { + http.Error(w, "tsidp: not found", http.StatusNotFound) + return + } + + switch r.Method { + case "DELETE": + s.serveDeleteClient(w, r, path) + case "GET": + json.NewEncoder(w).Encode(&funnelClient{ + ID: c.ID, + Name: c.Name, + Secret: "", + RedirectURI: c.RedirectURI, + }) + default: + http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *idpServer) serveNewClient(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed) + return + } + redirectURI := r.FormValue("redirect_uri") + if redirectURI == "" { + http.Error(w, "tsidp: must provide redirect_uri", http.StatusBadRequest) + return + } + clientID := rands.HexString(32) + clientSecret := rands.HexString(64) + newClient := funnelClient{ + ID: clientID, + Secret: clientSecret, + Name: r.FormValue("name"), + RedirectURI: redirectURI, + } + s.mu.Lock() + defer s.mu.Unlock() + mak.Set(&s.funnelClients, clientID, &newClient) + if err := s.storeFunnelClientsLocked(); err != nil { + log.Printf("could not write funnel clients db: %v", err) + http.Error(w, "tsidp: could not write funnel clients to db", http.StatusInternalServerError) + // delete the new client to avoid inconsistent state between memory + // and disk + delete(s.funnelClients, clientID) + return + } + json.NewEncoder(w).Encode(newClient) +} + +func (s *idpServer) serveGetClientsList(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed) + return + } + s.mu.Lock() + redactedClients := make([]funnelClient, 0, len(s.funnelClients)) + for _, c := range s.funnelClients { + redactedClients = append(redactedClients, funnelClient{ + ID: c.ID, + Name: c.Name, + Secret: "", + RedirectURI: c.RedirectURI, + }) + } + s.mu.Unlock() + json.NewEncoder(w).Encode(redactedClients) +} + +func (s *idpServer) serveDeleteClient(w http.ResponseWriter, r *http.Request, clientID string) { + if r.Method != "DELETE" { + http.Error(w, "tsidp: method not allowed", http.StatusMethodNotAllowed) + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.funnelClients == nil { + http.Error(w, "tsidp: client not found", http.StatusNotFound) + return + } + if _, ok := s.funnelClients[clientID]; !ok { + http.Error(w, "tsidp: client not found", http.StatusNotFound) + return + } + deleted := s.funnelClients[clientID] + delete(s.funnelClients, clientID) + if err := s.storeFunnelClientsLocked(); err != nil { + log.Printf("could not write funnel clients db: %v", err) + http.Error(w, "tsidp: could not write funnel clients to db", http.StatusInternalServerError) + // restore the deleted value to avoid inconsistent state between memory + // and disk + s.funnelClients[clientID] = deleted + return + } + w.WriteHeader(http.StatusNoContent) +} + +// storeFunnelClientsLocked writes the current mapping of OIDC client ID/secret +// pairs for RPs that access the IDP over funnel. s.mu must be held while +// calling this. +func (s *idpServer) storeFunnelClientsLocked() error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(s.funnelClients); err != nil { + return err + } + return os.WriteFile(funnelClientsFile, buf.Bytes(), 0600) +} + const ( minimumRSAKeySize = 2048 ) @@ -700,3 +1034,24 @@ func parseID[T ~int64](input string) (_ T, ok bool) { } return T(i), true } + +// isFunnelRequest checks if an HTTP request is coming over Tailscale Funnel. +func isFunnelRequest(r *http.Request) bool { + // If we're funneling through the local tailscaled, it will set this HTTP + // header. + if r.Header.Get("Tailscale-Funnel-Request") != "" { + return true + } + + // If the funneled connection is from tsnet, then the net.Conn will be of + // type ipn.FunnelConn. + netConn := r.Context().Value(ctxConn{}) + // if the conn is wrapped inside TLS, unwrap it + if tlsConn, ok := netConn.(*tls.Conn); ok { + netConn = tlsConn.NetConn() + } + if _, ok := netConn.(*ipn.FunnelConn); ok { + return true + } + return false +} diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 39af4f86e..3da12d7cc 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -3781,7 +3781,7 @@ func (b *LocalBackend) TCPHandlerForDst(src, dst netip.AddrPort) (handler func(c return nil }, opts } - if handler := b.tcpHandlerForServe(dst.Port(), src); handler != nil { + if handler := b.tcpHandlerForServe(dst.Port(), src, nil); handler != nil { return handler, opts } return nil, nil diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 0b5d5e89a..9ad05a196 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -56,6 +56,16 @@ var serveHTTPContextKey ctxkey.Key[*serveHTTPContext] type serveHTTPContext struct { SrcAddr netip.AddrPort DestPort uint16 + + // provides funnel-specific context, nil if not funneled + Funnel *funnelFlow +} + +// funnelFlow represents a funneled connection initiated via IngressPeer +// to Host. +type funnelFlow struct { + Host string + IngressPeer tailcfg.NodeView } // localListener is the state of host-level net.Listen for a specific (Tailscale IP, port) @@ -91,7 +101,7 @@ func (b *LocalBackend) newServeListener(ctx context.Context, ap netip.AddrPort, handler: func(conn net.Conn) error { srcAddr := conn.RemoteAddr().(*net.TCPAddr).AddrPort() - handler := b.tcpHandlerForServe(ap.Port(), srcAddr) + handler := b.tcpHandlerForServe(ap.Port(), srcAddr, nil) if handler == nil { b.logf("[unexpected] local-serve: no handler for %v to port %v", srcAddr, ap.Port()) conn.Close() @@ -382,7 +392,7 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target return } - _, port, err := net.SplitHostPort(string(target)) + host, port, err := net.SplitHostPort(string(target)) if err != nil { logf("got ingress conn for bad target %q; rejecting", target) sendRST() @@ -407,9 +417,10 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target return } } - // TODO(bradfitz): pass ingressPeer etc in context to tcpHandlerForServe, - // extend serveHTTPContext or similar. - handler := b.tcpHandlerForServe(dport, srcAddr) + handler := b.tcpHandlerForServe(dport, srcAddr, &funnelFlow{ + Host: host, + IngressPeer: ingressPeer, + }) if handler == nil { logf("[unexpected] no matching ingress serve handler for %v to port %v", srcAddr, dport) sendRST() @@ -424,8 +435,9 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer tailcfg.NodeView, target } // tcpHandlerForServe returns a handler for a TCP connection to be served via -// the ipn.ServeConfig. -func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort) (handler func(net.Conn) error) { +// the ipn.ServeConfig. The funnelFlow can be nil if this is not a funneled +// connection. +func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort, f *funnelFlow) (handler func(net.Conn) error) { b.mu.Lock() sc := b.serveConfig b.mu.Unlock() @@ -444,6 +456,7 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort) Handler: http.HandlerFunc(b.serveWebHandler), BaseContext: func(_ net.Listener) context.Context { return serveHTTPContextKey.WithValue(context.Background(), &serveHTTPContext{ + Funnel: f, SrcAddr: srcAddr, DestPort: dport, }) @@ -712,15 +725,20 @@ func (b *LocalBackend) addTailscaleIdentityHeaders(r *httputil.ProxyRequest) { r.Out.Header.Del("Tailscale-User-Login") r.Out.Header.Del("Tailscale-User-Name") r.Out.Header.Del("Tailscale-User-Profile-Pic") + r.Out.Header.Del("Tailscale-Funnel-Request") r.Out.Header.Del("Tailscale-Headers-Info") c, ok := serveHTTPContextKey.ValueOk(r.Out.Context()) if !ok { return } + if c.Funnel != nil { + r.Out.Header.Set("Tailscale-Funnel-Request", "?1") + return + } node, user, ok := b.WhoIs("tcp", c.SrcAddr) if !ok { - return // traffic from outside of Tailnet (funneled) + return // traffic from outside of Tailnet (funneled or local machine) } if node.IsTagged() { // 2023-06-14: Not setting identity headers for tagged nodes.