From 6697690b55037a92308c1c33e5a5563865b505ec Mon Sep 17 00:00:00 2001 From: shayne Date: Wed, 21 Jun 2023 12:32:20 -0400 Subject: [PATCH] {cmd/tailscale/cli,ipn}: add http support to tailscale serve (#8358) Updates #8357 Signed-off-by: Shayne Sweeney --- cmd/tailscale/cli/funnel.go | 8 +-- cmd/tailscale/cli/serve.go | 109 ++++++++++++++++++++++---------- cmd/tailscale/cli/serve_test.go | 53 ++++++++++++++++ ipn/ipn_clone.go | 1 + ipn/ipn_view.go | 2 + ipn/ipnlocal/local.go | 4 ++ ipn/ipnlocal/serve.go | 34 +++++++--- ipn/serve.go | 48 +++++++++----- 8 files changed, 195 insertions(+), 64 deletions(-) diff --git a/cmd/tailscale/cli/funnel.go b/cmd/tailscale/cli/funnel.go index 8e3e80004..f8c15e19d 100644 --- a/cmd/tailscale/cli/funnel.go +++ b/cmd/tailscale/cli/funnel.go @@ -30,10 +30,10 @@ func newFunnelCommand(e *serveEnv) *ffcli.Command { return &ffcli.Command{ Name: "funnel", ShortHelp: "Turn on/off Funnel service", - ShortUsage: strings.TrimSpace(` -funnel {on|off} - funnel status [--json] -`), + ShortUsage: strings.Join([]string{ + "funnel {on|off}", + "funnel status [--json]", + }, "\n "), LongHelp: strings.Join([]string{ "Funnel allows you to publish a 'tailscale serve'", "server publicly, open to the entire internet.", diff --git a/cmd/tailscale/cli/serve.go b/cmd/tailscale/cli/serve.go index 3921a57a0..572e2148b 100644 --- a/cmd/tailscale/cli/serve.go +++ b/cmd/tailscale/cli/serve.go @@ -35,13 +35,14 @@ func newServeCommand(e *serveEnv) *ffcli.Command { return &ffcli.Command{ Name: "serve", ShortHelp: "Serve content and local servers", - ShortUsage: strings.TrimSpace(` -serve https: [off] - serve tcp: tcp://localhost: [off] - serve tls-terminated-tcp: tcp://localhost: [off] - serve status [--json] - serve reset -`), + ShortUsage: strings.Join([]string{ + "serve http: [off]", + "serve https: [off]", + "serve tcp: tcp://localhost: [off]", + "serve tls-terminated-tcp: tcp://localhost: [off]", + "serve status [--json]", + "serve reset", + }, "\n "), LongHelp: strings.TrimSpace(` *** BETA; all of this is subject to change *** @@ -58,8 +59,8 @@ EXAMPLES - To proxy requests to a web server at 127.0.0.1:3000: $ tailscale serve https:443 / http://127.0.0.1:3000 - Or, using the default port: - $ tailscale serve https / http://127.0.0.1:3000 + Or, using the default port (443): + $ tailscale serve https / http://127.0.0.1:3000 - To serve a single file or a directory of files: $ tailscale serve https / /home/alice/blog/index.html @@ -68,6 +69,12 @@ EXAMPLES - To serve simple static text: $ tailscale serve https:8080 / text:"Hello, world!" + - To serve over HTTP (tailnet only): + $ tailscale serve http:80 / http://127.0.0.1:3000 + + Or, using the default port (80): + $ tailscale serve http / http://127.0.0.1:3000 + - To forward incoming TCP connections on port 2222 to a local TCP server on port 22 (e.g. to run OpenSSH in parallel with Tailscale SSH): $ tailscale serve tcp:2222 tcp://localhost:22 @@ -175,6 +182,7 @@ func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status, // serve config types like proxy, path, and text. // // Examples: +// - tailscale serve http / http://localhost:3000 // - tailscale serve https / http://localhost:3000 // - tailscale serve https /images/ /var/www/images/ // - tailscale serve https:10000 /motd.txt text:"Hello, world!" @@ -199,19 +207,14 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error { return e.lc.SetServeConfig(ctx, sc) } - parsePort := func(portStr string) (uint16, error) { - port64, err := strconv.ParseUint(portStr, 10, 16) - if err != nil { - return 0, err - } - return uint16(port64), nil - } - srcType, srcPortStr, found := strings.Cut(args[0], ":") if !found { if srcType == "https" && srcPortStr == "" { // Default https port to 443. srcPortStr = "443" + } else if srcType == "http" && srcPortStr == "" { + // Default http port to 80. + srcPortStr = "80" } else { return flag.ErrHelp } @@ -219,18 +222,18 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error { turnOff := "off" == args[len(args)-1] - if len(args) < 2 || (srcType == "https" && !turnOff && len(args) < 3) { + if len(args) < 2 || ((srcType == "https" || srcType == "http") && !turnOff && len(args) < 3) { fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n") return flag.ErrHelp } - srcPort, err := parsePort(srcPortStr) + srcPort, err := parseServePort(srcPortStr) if err != nil { - return err + return fmt.Errorf("invalid port %q: %w", srcPortStr, err) } switch srcType { - case "https": + case "https", "http": mount, err := cleanMountPoint(args[1]) if err != nil { return err @@ -238,7 +241,8 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error { if turnOff { return e.handleWebServeRemove(ctx, srcPort, mount) } - return e.handleWebServe(ctx, srcPort, mount, args[2]) + useTLS := srcType == "https" + return e.handleWebServe(ctx, srcPort, useTLS, mount, args[2]) case "tcp", "tls-terminated-tcp": if turnOff { return e.handleTCPServeRemove(ctx, srcPort) @@ -246,20 +250,20 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error { return e.handleTCPServe(ctx, srcType, srcPort, args[1]) default: fmt.Fprintf(os.Stderr, "error: invalid serve type %q\n", srcType) - fmt.Fprint(os.Stderr, "must be one of: https:, tcp: or tls-terminated-tcp:\n\n", srcType) + fmt.Fprint(os.Stderr, "must be one of: http:, https:, tcp: or tls-terminated-tcp:\n\n", srcType) return flag.ErrHelp } } -// handleWebServe handles the "tailscale serve https:..." subcommand. -// It configures the serve config to forward HTTPS connections to the -// given source. +// handleWebServe handles the "tailscale serve (http/https):..." subcommand. It +// configures the serve config to forward HTTPS connections to the given source. // // Examples: +// - tailscale serve http / http://localhost:3000 // - tailscale serve https / http://localhost:3000 // - tailscale serve https:8443 /files/ /home/alice/shared-files/ // - tailscale serve https:10000 /motd.txt text:"Hello, world!" -func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, mount, source string) error { +func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, useTLS bool, mount, source string) error { h := new(ipn.HTTPHandler) ts, _, _ := strings.Cut(source, ":") @@ -318,7 +322,7 @@ func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, mount, so return flag.ErrHelp } - mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: true}) + mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS}) if _, ok := sc.Web[hp]; !ok { mak.Set(&sc.Web, hp, new(ipn.WebServerConfig)) @@ -626,7 +630,10 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error { printf("\n") } for hp := range sc.Web { - printWebStatusTree(sc, hp) + err := e.printWebStatusTree(sc, hp) + if err != nil { + return err + } printf("\n") } printFunnelWarning(sc) @@ -665,20 +672,37 @@ func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.S return nil } -func printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) { +func (e *serveEnv) printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) error { + // No-op if no serve config if sc == nil { - return + return nil } fStatus := "tailnet only" if sc.AllowFunnel[hp] { fStatus = "Funnel on" } host, portStr, _ := net.SplitHostPort(string(hp)) - if portStr == "443" { - printf("https://%s (%s)\n", host, fStatus) - } else { - printf("https://%s:%s (%s)\n", host, portStr, fStatus) + + port, err := parseServePort(portStr) + if err != nil { + return fmt.Errorf("invalid port %q: %w", portStr, err) + } + + scheme := "https" + if sc.IsServingHTTP(port) { + scheme = "http" } + + portPart := ":" + portStr + if scheme == "http" && portStr == "80" || + scheme == "https" && portStr == "443" { + portPart = "" + } + if scheme == "http" { + hostname, _, _ := strings.Cut("host", ".") + printf("%s://%s%s (%s)\n", scheme, hostname, portPart, fStatus) + } + printf("%s://%s%s (%s)\n", scheme, host, portPart, fStatus) srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) { switch { case h.Path != "": @@ -705,6 +729,8 @@ func printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) { t, d := srvTypeAndDesc(h) printf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d) } + + return nil } func elipticallyTruncate(s string, max int) string { @@ -725,3 +751,16 @@ func (e *serveEnv) runServeReset(ctx context.Context, args []string) error { sc := new(ipn.ServeConfig) return e.lc.SetServeConfig(ctx, sc) } + +// parseServePort parses a port number from a string and returns it as a +// uint16. It returns an error if the port number is invalid or zero. +func parseServePort(s string) (uint16, error) { + p, err := strconv.ParseUint(s, 10, 16) + if err != nil { + return 0, err + } + if p == 0 { + return 0, errors.New("port number must be non-zero") + } + return uint16(p), nil +} diff --git a/cmd/tailscale/cli/serve_test.go b/cmd/tailscale/cli/serve_test.go index 2dec06dd7..3d5b8f1f6 100644 --- a/cmd/tailscale/cli/serve_test.go +++ b/cmd/tailscale/cli/serve_test.go @@ -89,6 +89,59 @@ func TestServeConfigMutations(t *testing.T) { wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), }) + // https + add(step{reset: true}) + add(step{ // allow omitting port (default to 80) + command: cmd("http / http://localhost:3000"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + }, + }, + }) + add(step{ // support non Funnel port + command: cmd("http:9999 /abc http://localhost:3001"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 9999: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + "foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{ + "/abc": {Proxy: "http://127.0.0.1:3001"}, + }}, + }, + }, + }) + add(step{ + command: cmd("http:9999 /abc off"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + }, + }, + }) + add(step{ + command: cmd("http:8080 /abc http://127.0.0.1:3001"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 8080: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + "foo.test.ts.net:8080": {Handlers: map[string]*ipn.HTTPHandler{ + "/abc": {Proxy: "http://127.0.0.1:3001"}, + }}, + }, + }, + }) + // https add(step{reset: true}) add(step{ diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index 9dfd9f0c1..97207d039 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -103,6 +103,7 @@ func (src *TCPPortHandler) Clone() *TCPPortHandler { // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _TCPPortHandlerCloneNeedsRegeneration = TCPPortHandler(struct { HTTPS bool + HTTP bool TCPForward string TerminateTLS string }{}) diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 94da4eb3e..1abeb6709 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -228,12 +228,14 @@ func (v *TCPPortHandlerView) UnmarshalJSON(b []byte) error { } func (v TCPPortHandlerView) HTTPS() bool { return v.ж.HTTPS } +func (v TCPPortHandlerView) HTTP() bool { return v.ж.HTTP } func (v TCPPortHandlerView) TCPForward() string { return v.ж.TCPForward } func (v TCPPortHandlerView) TerminateTLS() string { return v.ж.TerminateTLS } // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _TCPPortHandlerViewNeedsRegeneration = TCPPortHandler(struct { HTTPS bool + HTTP bool TCPForward string TerminateTLS string }{}) diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 330f2a8c2..762dfba9b 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -4129,6 +4129,10 @@ func (b *LocalBackend) setServeProxyHandlersLocked() { b.serveConfig.Web().Range(func(_ ipn.HostPort, conf ipn.WebServerConfigView) (cont bool) { conf.Handlers().Range(func(_ string, h ipn.HTTPHandlerView) (cont bool) { backend := h.Proxy() + if backend == "" { + // Only create proxy handlers for servers with a proxy backend. + return true + } mak.Set(&backends, backend, true) if _, ok := b.serveProxyHandlers.Load(backend); ok { return true diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index 6266e4697..a6a7d1421 100644 --- a/ipn/ipnlocal/serve.go +++ b/ipn/ipnlocal/serve.go @@ -332,11 +332,8 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort) return nil } - if tcph.HTTPS() { + if tcph.HTTPS() || tcph.HTTP() { hs := &http.Server{ - TLSConfig: &tls.Config{ - GetCertificate: b.getTLSServeCertForPort(dport), - }, Handler: http.HandlerFunc(b.serveWebHandler), BaseContext: func(_ net.Listener) context.Context { return context.WithValue(context.Background(), serveHTTPContextKey{}, &serveHTTPContext{ @@ -345,8 +342,17 @@ func (b *LocalBackend) tcpHandlerForServe(dport uint16, srcAddr netip.AddrPort) }) }, } + if tcph.HTTPS() { + hs.TLSConfig = &tls.Config{ + GetCertificate: b.getTLSServeCertForPort(dport), + } + return func(c net.Conn) error { + return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "") + } + } + return func(c net.Conn) error { - return hs.ServeTLS(netutil.NewOneConnListener(c, nil), "", "") + return hs.Serve(netutil.NewOneConnListener(c, nil)) } } @@ -406,8 +412,14 @@ func getServeHTTPContext(r *http.Request) (c *serveHTTPContext, ok bool) { func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView, at string, ok bool) { var z ipn.HTTPHandlerView // zero value + hostname := r.Host if r.TLS == nil { - return z, "", false + tcd := "." + b.Status().CurrentTailnet.MagicDNSSuffix + if !strings.HasSuffix(hostname, tcd) { + hostname += tcd + } + } else { + hostname = r.TLS.ServerName } sctx, ok := getServeHTTPContext(r) @@ -415,7 +427,7 @@ func (b *LocalBackend) getServeHandler(r *http.Request) (_ ipn.HTTPHandlerView, b.logf("[unexpected] localbackend: no serveHTTPContext in request") return z, "", false } - wsc, ok := b.webServerConfig(r.TLS.ServerName, sctx.DestPort) + wsc, ok := b.webServerConfig(hostname, sctx.DestPort) if !ok { return z, "", false } @@ -472,7 +484,9 @@ func (b *LocalBackend) proxyHandlerForBackend(backend string) (*httputil.Reverse func addProxyForwardedHeaders(r *httputil.ProxyRequest) { r.Out.Header.Set("X-Forwarded-Host", r.In.Host) - r.Out.Header.Set("X-Forwarded-Proto", "https") + if r.In.TLS != nil { + r.Out.Header.Set("X-Forwarded-Proto", "https") + } if c, ok := getServeHTTPContext(r.Out); ok { r.Out.Header.Set("X-Forwarded-For", c.SrcAddr.Addr().String()) } @@ -634,8 +648,8 @@ func allNumeric(s string) bool { return s != "" } -func (b *LocalBackend) webServerConfig(sniName string, port uint16) (c ipn.WebServerConfigView, ok bool) { - key := ipn.HostPort(fmt.Sprintf("%s:%v", sniName, port)) +func (b *LocalBackend) webServerConfig(hostname string, port uint16) (c ipn.WebServerConfigView, ok bool) { + key := ipn.HostPort(fmt.Sprintf("%s:%v", hostname, port)) b.mu.Lock() defer b.mu.Unlock() diff --git a/ipn/serve.go b/ipn/serve.go index 0dda5e251..48e3343a1 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -76,6 +76,12 @@ type TCPPortHandler struct { // It is mutually exclusive with TCPForward. HTTPS bool `json:",omitempty"` + // HTTP, if true, means that tailscaled should handle this connection as an + // HTTP request as configured by ServeConfig.Web. + // + // It is mutually exclusive with TCPForward. + HTTP bool `json:",omitempty"` + // TCPForward is the IP:port to forward TCP connections to. // Whether or not TLS is terminated by tailscaled depends on // TerminateTLS. @@ -103,7 +109,7 @@ type HTTPHandler struct { // temporary ones? Error codes? Redirects? } -// WebHandlerExists checks if the ServeConfig Web handler exists for +// WebHandlerExists reports whether if the ServeConfig Web handler exists for // the given host:port and mount point. func (sc *ServeConfig) WebHandlerExists(hp HostPort, mount string) bool { h := sc.GetWebHandler(hp, mount) @@ -128,9 +134,8 @@ func (sc *ServeConfig) GetTCPPortHandler(port uint16) *TCPPortHandler { return sc.TCP[port] } -// IsTCPForwardingAny checks if ServeConfig is currently forwarding -// in TCPForward mode on any port. -// This is exclusive of Web/HTTPS serving. +// IsTCPForwardingAny reports whether ServeConfig is currently forwarding in +// TCPForward mode on any port. This is exclusive of Web/HTTPS serving. func (sc *ServeConfig) IsTCPForwardingAny() bool { if sc == nil || len(sc.TCP) == 0 { return false @@ -143,34 +148,47 @@ func (sc *ServeConfig) IsTCPForwardingAny() bool { return false } -// IsTCPForwardingOnPort checks if ServeConfig is currently forwarding -// in TCPForward mode on the given port. -// This is exclusive of Web/HTTPS serving. +// IsTCPForwardingOnPort reports whether if ServeConfig is currently forwarding +// in TCPForward mode on the given port. This is exclusive of Web/HTTPS serving. func (sc *ServeConfig) IsTCPForwardingOnPort(port uint16) bool { if sc == nil || sc.TCP[port] == nil { return false } - return !sc.TCP[port].HTTPS + return !sc.IsServingWeb(port) } -// IsServingWeb checks if ServeConfig is currently serving -// Web/HTTPS on the given port. -// This is exclusive of TCPForwarding. +// IsServingWeb reports whether if ServeConfig is currently serving Web +// (HTTP/HTTPS) on the given port. This is exclusive of TCPForwarding. func (sc *ServeConfig) IsServingWeb(port uint16) bool { + return sc.IsServingHTTP(port) || sc.IsServingHTTPS(port) +} + +// IsServingHTTPS reports whether if ServeConfig is currently serving HTTPS on +// the given port. This is exclusive of HTTP and TCPForwarding. +func (sc *ServeConfig) IsServingHTTPS(port uint16) bool { if sc == nil || sc.TCP[port] == nil { return false } return sc.TCP[port].HTTPS } -// IsFunnelOn checks if ServeConfig is currently allowing -// funnel traffic for any host:port. +// IsServingHTTP reports whether if ServeConfig is currently serving HTTP on the +// given port. This is exclusive of HTTPS and TCPForwarding. +func (sc *ServeConfig) IsServingHTTP(port uint16) bool { + if sc == nil || sc.TCP[port] == nil { + return false + } + return sc.TCP[port].HTTP +} + +// IsFunnelOn reports whether if ServeConfig is currently allowing funnel +// traffic for any host:port. // // View version of ServeConfig.IsFunnelOn. func (v ServeConfigView) IsFunnelOn() bool { return v.ж.IsFunnelOn() } -// IsFunnelOn checks if ServeConfig is currently allowing -// funnel traffic for any host:port. +// IsFunnelOn reports whether if ServeConfig is currently allowing funnel +// traffic for any host:port. func (sc *ServeConfig) IsFunnelOn() bool { if sc == nil { return false