From 35ff5bf5a63935d2e61fa51034dbc97e49dfd0b8 Mon Sep 17 00:00:00 2001 From: Marwan Sulaiman Date: Thu, 17 Aug 2023 11:47:35 -0400 Subject: [PATCH] cmd/tailscale/cli, ipn/ipnlocal: [funnel] add stream mode Adds ability to start Funnel in the foreground and stream incoming connections. When foreground process is stopped, Funnel is turned back off for the port. Exampe usage: ``` TAILSCALE_FUNNEL_V2=on tailscale funnel 8080 ``` Updates #8489 Signed-off-by: Marwan Sulaiman --- client/tailscale/localclient.go | 23 +++++ cmd/tailscale/cli/cli.go | 2 +- cmd/tailscale/cli/funnel.go | 11 ++- cmd/tailscale/cli/funnel_dev.go | 112 ++++++++++++++++++++++ cmd/tailscale/cli/serve.go | 1 + cmd/tailscale/cli/serve_test.go | 6 ++ cmd/tailscaled/depaware.txt | 2 + ipn/ipnlocal/local.go | 3 + ipn/ipnlocal/serve.go | 164 ++++++++++++++++++++++++++++++++ ipn/localapi/localapi.go | 26 +++++ ipn/serve.go | 51 ++++++++++ 11 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 cmd/tailscale/cli/funnel_dev.go diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index fde465bac..c57b58895 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -1057,6 +1057,29 @@ 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/cli.go b/cmd/tailscale/cli/cli.go index f17a8aeb1..7c6ab488b 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -120,7 +120,7 @@ change in the future. pingCmd, ncCmd, sshCmd, - funnelCmd, + funnelCmd(), serveCmd, versionCmd, webCmd, diff --git a/cmd/tailscale/cli/funnel.go b/cmd/tailscale/cli/funnel.go index 63583d834..ff66adc4d 100644 --- a/cmd/tailscale/cli/funnel.go +++ b/cmd/tailscale/cli/funnel.go @@ -20,7 +20,16 @@ import ( "tailscale.com/util/mak" ) -var funnelCmd = newFunnelCommand(&serveEnv{lc: &localClient}) +var funnelCmd = func() *ffcli.Command { + se := &serveEnv{lc: &localClient} + // This flag is used to switch to an in-development + // implementation of the tailscale funnel command. + // See https://github.com/tailscale/tailscale/issues/7844 + if os.Getenv("TAILSCALE_FUNNEL_DEV") == "on" { + return newFunnelDevCommand(se) + } + return newFunnelCommand(se) +} // newFunnelCommand returns a new "funnel" subcommand using e as its environment. // The funnel subcommand is used to turn on/off the Funnel service. diff --git a/cmd/tailscale/cli/funnel_dev.go b/cmd/tailscale/cli/funnel_dev.go new file mode 100644 index 000000000..3aecabb83 --- /dev/null +++ b/cmd/tailscale/cli/funnel_dev.go @@ -0,0 +1,112 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/ipn" +) + +// newFunnelDevCommand returns a new "funnel" subcommand using e as its environment. +// The funnel subcommand is used to turn on/off the Funnel service. +// Funnel is off by default. +// Funnel allows you to publish a 'tailscale serve' server publicly, +// open to the entire internet. +// newFunnelCommand shares the same serveEnv as the "serve" subcommand. +// See newServeCommand and serve.go for more details. +func newFunnelDevCommand(e *serveEnv) *ffcli.Command { + return &ffcli.Command{ + Name: "funnel", + ShortHelp: "Turn on/off Funnel service", + ShortUsage: strings.Join([]string{ + "funnel ", + "funnel status [--json]", + }, "\n "), + LongHelp: strings.Join([]string{ + "Funnel allows you to expose your local", + "server publicly to the entire internet.", + "Note that it only supports https servers at this point.", + "This command is in development and is unsupported", + }, "\n"), + Exec: e.runFunnelDev, + UsageFunc: usageFunc, + Subcommands: []*ffcli.Command{ + { + Name: "status", + Exec: e.runServeStatus, + ShortHelp: "show current serve/Funnel status", + FlagSet: e.newFlags("funnel-status", func(fs *flag.FlagSet) { + fs.BoolVar(&e.json, "json", false, "output JSON") + }), + UsageFunc: usageFunc, + }, + }, + } +} + +// runFunnelDev is the entry point for the "tailscale funnel" subcommand and +// manages turning on/off Funnel. Funnel is off by default. +// +// Note: funnel is only supported on single DNS name for now. (2023-08-18) +func (e *serveEnv) runFunnelDev(ctx context.Context, args []string) error { + if len(args) != 1 { + return flag.ErrHelp + } + var source string + port64, err := strconv.ParseUint(args[0], 10, 16) + if err == nil { + source = fmt.Sprintf("http://127.0.0.1:%d", port64) + } else { + source, err = expandProxyTarget(args[0]) + } + if err != nil { + return err + } + + st, err := e.getLocalClientStatusWithoutPeers(ctx) + if err != nil { + return fmt.Errorf("getting client status: %w", err) + } + + if err := e.verifyFunnelEnabled(ctx, st, 443); err != nil { + return err + } + + dnsName := strings.TrimSuffix(st.Self.DNSName, ".") + hp := ipn.HostPort(dnsName + ":443") // TODO(marwan-at-work): support the 2 other ports + + // In the streaming case, the process stays running in the + // foreground and prints out connections to the HostPort. + // + // The local backend handles updating the ServeConfig as + // necessary, then restores it to its original state once + // the process's context is closed or the client turns off + // Tailscale. + return e.streamServe(ctx, ipn.ServeStreamRequest{ + HostPort: hp, + Source: source, + MountPoint: "/", // TODO(marwan-at-work): support multiple mount points + }) +} + +func (e *serveEnv) streamServe(ctx context.Context, req ipn.ServeStreamRequest) error { + stream, err := e.lc.StreamServe(ctx, req) + if err != nil { + return err + } + defer stream.Close() + + 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") + _, err = io.Copy(os.Stdout, stream) + return err +} diff --git a/cmd/tailscale/cli/serve.go b/cmd/tailscale/cli/serve.go index ef05f92f7..7b4d38691 100644 --- a/cmd/tailscale/cli/serve.go +++ b/cmd/tailscale/cli/serve.go @@ -135,6 +135,7 @@ 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_test.go b/cmd/tailscale/cli/serve_test.go index 4b2c98fd8..642cce5ce 100644 --- a/cmd/tailscale/cli/serve_test.go +++ b/cmd/tailscale/cli/serve_test.go @@ -9,6 +9,7 @@ import ( "errors" "flag" "fmt" + "io" "os" "path/filepath" "reflect" @@ -900,6 +901,11 @@ func (lc *fakeLocalServeClient) IncrementCounter(ctx context.Context, name strin return nil // unused in tests } +func (lc *fakeLocalServeClient) StreamServe(ctx context.Context, req ipn.ServeStreamRequest) (io.ReadCloser, error) { + // TODO: testing :) + return nil, nil +} + // exactError returns an error checker that wants exactly the provided want error. // If optName is non-empty, it's used in the error message. func exactErr(want error, optName ...string) func(error) string { diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 2e4075e64..87e0d8baa 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -93,6 +93,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de L github.com/google/nftables/expr from github.com/google/nftables+ L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+ L github.com/google/nftables/xt from github.com/google/nftables/expr+ + github.com/google/uuid from tailscale.com/ipn/ipnlocal github.com/hdevalence/ed25519consensus from tailscale.com/tka L 💣 github.com/illarion/gonotify from tailscale.com/net/dns L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun @@ -438,6 +439,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de crypto/tls from github.com/tcnksm/go-httpstat+ crypto/x509 from crypto/tls+ crypto/x509/pkix from crypto/x509+ + database/sql/driver from github.com/google/uuid W debug/dwarf from debug/pe W debug/pe from github.com/dblohm7/wingoes/pe embed from tailscale.com+ diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 9e52b2942..73fdb46a6 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -244,6 +244,9 @@ type LocalBackend struct { serveListeners map[netip.AddrPort]*serveListener // addrPort => serveListener serveProxyHandlers sync.Map // string (HTTPHandler.Proxy) => *httputil.ReverseProxy + // serveStreamers is a map for those running Funnel in the foreground + // and streaming incoming requests. + serveStreamers map[uint16]map[uint32]func(ipn.FunnelRequestLog) // serve port => map of stream loggers (key is UUID) // statusLock must be held before calling statusChanged.Wait() or // statusChanged.Broadcast(). diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 0444aa53b..9d713388a 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -23,6 +23,7 @@ import ( "sync" "time" + "github.com/google/uuid" "tailscale.com/ipn" "tailscale.com/logtail/backoff" "tailscale.com/net/netutil" @@ -257,6 +258,165 @@ func (b *LocalBackend) ServeConfig() ipn.ServeConfigView { return b.serveConfig } +// 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 +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)) + }() + + var writeErrs []error + writeToStream := func(log ipn.FunnelRequestLog) { + jsonLog, err := json.Marshal(log) + if err != nil { + writeErrs = append(writeErrs, err) + return + } + if _, err := fmt.Fprintf(w, "%s\n", jsonLog); err != nil { + writeErrs = append(writeErrs, err) + return + } + f.Flush() + } + + // Hook up connections stream. + b.mu.Lock() + mak.NonNilMapForJSON(&b.serveStreamers) + if b.serveStreamers[port] == nil { + b.serveStreamers[port] = make(map[uint32]func(ipn.FunnelRequestLog)) + } + id := uuid.New().ID() + b.serveStreamers[port][id] = writeToStream + b.mu.Unlock() + + // Clean up streamer when done. + defer func() { + b.mu.Lock() + mak.NonNilMapForJSON(&b.serveStreamers) + delete(b.serveStreamers[port], id) + b.mu.Unlock() + }() + + select { + case <-ctx.Done(): + // Triggered by foreground `tailscale funnel` process + // (the streamer) getting closed, or by turning off Tailscale. + } + + return errors.Join(writeErrs...) +} + +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 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) maybeLogServeConnection(destPort uint16, srcAddr netip.AddrPort) { + b.mu.Lock() + streamers := b.serveStreamers[destPort] + b.mu.Unlock() + if len(streamers) == 0 { + return + } + + var log ipn.FunnelRequestLog + log.SrcAddr = srcAddr + log.Time = time.Now() // TODO: use a different clock somewhere? + + if node, user, ok := b.WhoIs(srcAddr); ok { + log.NodeName = node.ComputedName() + if node.IsTagged() { + log.NodeTags = node.Tags().AsSlice() + } else { + log.UserLoginName = user.LoginName + log.UserDisplayName = user.DisplayName + } + } + + for _, stream := range streamers { + stream(log) + } +} + 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 @@ -359,6 +519,7 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort) if backDst := tcph.TCPForward(); backDst != "" { return func(conn net.Conn) error { defer conn.Close() + b.maybeLogServeConnection(dport, srcAddr) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) backConn, err := b.dialer.SystemDial(ctx, "tcp", backDst) cancel() @@ -527,6 +688,9 @@ func (b *LocalBackend) serveWebHandler(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } + if c, ok := getServeHTTPContext(r); ok { + b.maybeLogServeConnection(c.DestPort, c.SrcAddr) + } if s := h.Text(); s != "" { w.Header().Set("Content-Type", "text/plain; charset=utf-8") io.WriteString(w, s) diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 278ced00d..d16649a06 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -99,6 +99,7 @@ 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, @@ -857,6 +858,31 @@ 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 !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 55bf39a93..5d6f7129e 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -12,6 +12,7 @@ import ( "slices" "strconv" "strings" + "time" "tailscale.com/tailcfg" ) @@ -42,6 +43,21 @@ type ServeConfig struct { // There is no implicit port 443. It must contain a colon. type HostPort string +// Port extracts just the port number from hp. +// An error is reported in the case that the hp does not +// have a valid numeric port ending. +func (hp HostPort) Port() (uint16, error) { + _, port, err := net.SplitHostPort(string(hp)) + if err != nil { + return 0, err + } + port16, err := strconv.ParseUint(port, 10, 16) + if err != nil { + return 0, err + } + return uint16(port16), nil +} + // A FunnelConn wraps a net.Conn that is coming over a // Funnel connection. It can be used to determine further // information about the connection, like the source address @@ -62,6 +78,41 @@ type FunnelConn struct { Src netip.AddrPort } +// ServeStreamRequest defines the json request body +// for the serve stream endpoint +type ServeStreamRequest struct { + // HostPort is the DNS and port of the tailscale + // URL. + HostPort HostPort `json:",omitempty"` + + // Source is the user's serve destination + // such as their localhost server. + Source string `json:",omitempty"` + + // MountPoint is the path prefix for + // the given HostPort. + MountPoint string `json:",omitempty"` +} + +// FunnelRequestLog is the JSON type written out to io.Writers +// watching funnel connections via ipnlocal.StreamServe. +// +// This structure is in development and subject to change. +type FunnelRequestLog struct { + Time time.Time `json:",omitempty"` // time of request forwarding + + // SrcAddr is the address that initiated the Funnel request. + SrcAddr netip.AddrPort `json:",omitempty"` + + // The following fields are only populated if the connection + // initiated from another node on the client's tailnet. + + NodeName string `json:",omitempty"` // src node MagicDNS name + NodeTags []string `json:",omitempty"` // src node tags + UserLoginName string `json:",omitempty"` // src node's owner login (if not tagged) + UserDisplayName string `json:",omitempty"` // src node's owner name (if not tagged) +} + // WebServerConfig describes a web server's configuration. type WebServerConfig struct { Handlers map[string]*HTTPHandler // mountPoint => handler