From 2b892ad6e741a9ab69408da61785eced53414da2 Mon Sep 17 00:00:00 2001 From: shayne Date: Mon, 13 Mar 2023 21:43:28 -0400 Subject: [PATCH] cmd/tailscale/cli: [serve] rework commands based on feedback (#6521) ``` $ tailscale serve https: [off] $ tailscale serve tcp: tcp://localhost: [off] $ tailscale serve tls-terminated-tcp: tcp://localhost: [off] $ tailscale serve status [--json] $ tailscale funnel {on|off} $ tailscale funnel status [--json] ``` Fixes: #6674 Signed-off-by: Shayne Sweeney --- cmd/tailscale/cli/cli.go | 2 + cmd/tailscale/cli/funnel.go | 138 +++++++++ cmd/tailscale/cli/serve.go | 500 ++++++++++++++++---------------- cmd/tailscale/cli/serve_test.go | 215 ++++++++------ cmd/tailscale/cli/status.go | 1 + 5 files changed, 513 insertions(+), 343 deletions(-) create mode 100644 cmd/tailscale/cli/funnel.go diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 714a1f995..d6ab7b710 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -147,6 +147,8 @@ change in the future. switch { case slices.Contains(args, "debug"): rootCmd.Subcommands = append(rootCmd.Subcommands, debugCmd) + case slices.Contains(args, "funnel"): + rootCmd.Subcommands = append(rootCmd.Subcommands, funnelCmd) case slices.Contains(args, "serve"): rootCmd.Subcommands = append(rootCmd.Subcommands, serveCmd) case slices.Contains(args, "update"): diff --git a/cmd/tailscale/cli/funnel.go b/cmd/tailscale/cli/funnel.go new file mode 100644 index 000000000..5532f05fa --- /dev/null +++ b/cmd/tailscale/cli/funnel.go @@ -0,0 +1,138 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "context" + "flag" + "fmt" + "net" + "os" + "strconv" + "strings" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/ipn" + "tailscale.com/util/mak" +) + +var funnelCmd = newFunnelCommand(&serveEnv{lc: &localClient}) + +// newFunnelCommand 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 newFunnelCommand(e *serveEnv) *ffcli.Command { + return &ffcli.Command{ + Name: "funnel", + ShortHelp: "[ALPHA] turn Tailscale Funnel on or off", + ShortUsage: strings.TrimSpace(` +funnel {on|off} + funnel status [--json] +`), + LongHelp: strings.Join([]string{ + "Funnel allows you to publish a 'tailscale serve'", + "server publicly, open to the entire internet.", + "", + "Turning off Funnel only turns off serving to the internet.", + "It does not affect serving to your tailnet.", + }, "\n"), + Exec: e.runFunnel, + 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, + }, + }, + } +} + +// runFunnel 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. (2022-11-15) +func (e *serveEnv) runFunnel(ctx context.Context, args []string) error { + if len(args) != 2 { + return flag.ErrHelp + } + + var on bool + switch args[1] { + case "on", "off": + on = args[1] == "on" + default: + return flag.ErrHelp + } + sc, err := e.lc.GetServeConfig(ctx) + if err != nil { + return err + } + if sc == nil { + sc = new(ipn.ServeConfig) + } + st, err := e.getLocalClientStatus(ctx) + if err != nil { + return fmt.Errorf("getting client status: %w", err) + } + + port64, err := strconv.ParseUint(args[0], 10, 16) + if err != nil { + return err + } + port := uint16(port64) + + if err := ipn.CheckFunnelAccess(port, st.Self.Capabilities); err != nil { + return err + } + dnsName := strings.TrimSuffix(st.Self.DNSName, ".") + hp := ipn.HostPort(dnsName + ":" + strconv.Itoa(int(port))) + if on == sc.AllowFunnel[hp] { + printFunnelWarning(sc) + // Nothing to do. + return nil + } + if on { + mak.Set(&sc.AllowFunnel, hp, true) + } else { + delete(sc.AllowFunnel, hp) + // clear map mostly for testing + if len(sc.AllowFunnel) == 0 { + sc.AllowFunnel = nil + } + } + if err := e.lc.SetServeConfig(ctx, sc); err != nil { + return err + } + printFunnelWarning(sc) + return nil +} + +// printFunnelWarning prints a warning if the Funnel is on but there is no serve +// config for its host:port. +func printFunnelWarning(sc *ipn.ServeConfig) { + var warn bool + for hp, a := range sc.AllowFunnel { + if !a { + continue + } + _, portStr, _ := net.SplitHostPort(string(hp)) + p, _ := strconv.ParseUint(portStr, 10, 16) + if _, ok := sc.TCP[uint16(p)]; !ok { + warn = true + fmt.Fprintf(os.Stderr, "Warning: funnel=on for %s, but no serve config\n", hp) + } + } + if warn { + fmt.Fprintf(os.Stderr, " run: `tailscale serve --help` to see how to configure handlers\n") + } +} diff --git a/cmd/tailscale/cli/serve.go b/cmd/tailscale/cli/serve.go index 996131720..509addee7 100644 --- a/cmd/tailscale/cli/serve.go +++ b/cmd/tailscale/cli/serve.go @@ -35,8 +35,11 @@ func newServeCommand(e *serveEnv) *ffcli.Command { Name: "serve", ShortHelp: "[ALPHA] Serve from your Tailscale node", ShortUsage: strings.TrimSpace(` - serve [flags] {proxy|path|text} - serve [flags] [sub-flags] `), +serve https: [off] + serve tcp: tcp://localhost: [off] + serve tls-terminated-tcp: tcp://localhost: [off] + serve status [--json] +`), LongHelp: strings.TrimSpace(` *** ALPHA; all of this is subject to change *** @@ -45,68 +48,42 @@ content and local servers from your Tailscale node to your tailnet. You can also choose to enable the Tailscale Funnel with: -'tailscale serve funnel on'. Funnel allows you to publish +'tailscale funnel on'. Funnel allows you to publish a 'tailscale serve' server publicly, open to the entire internet. See https://tailscale.com/funnel. EXAMPLES - To proxy requests to a web server at 127.0.0.1:3000: - $ tailscale serve / proxy 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 - To serve a single file or a directory of files: - $ tailscale serve / path /home/alice/blog/index.html - $ tailscale serve /images/ path /home/alice/blog/images + $ tailscale serve https / /home/alice/blog/index.html + $ tailscale serve https /images/ /home/alice/blog/images - To serve simple static text: - $ tailscale serve / text "Hello, world!" + $ tailscale serve https:8080 / text:"Hello, world!" + + - To forward raw TCP packets to a local TCP server on port 5432: + $ tailscale serve tcp:2222 tcp://localhost:22 + + - To forward raw, TLS-terminated TCP packets to a local TCP server on port 80: + $ tailscale serve tls-terminated-tcp:443 tcp://localhost:80 `), - Exec: e.runServe, - FlagSet: e.newFlags("serve", func(fs *flag.FlagSet) { - fs.BoolVar(&e.remove, "remove", false, "remove an existing serve config") - fs.UintVar(&e.servePort, "serve-port", 443, "port to serve on (443, 8443 or 10000)") - }), + Exec: e.runServe, UsageFunc: usageFunc, Subcommands: []*ffcli.Command{ { Name: "status", Exec: e.runServeStatus, - ShortHelp: "show current serve status", + ShortHelp: "show current serve/funnel status", FlagSet: e.newFlags("serve-status", func(fs *flag.FlagSet) { fs.BoolVar(&e.json, "json", false, "output JSON") }), UsageFunc: usageFunc, }, - { - Name: "tcp", - Exec: e.runServeTCP, - ShortHelp: "add or remove a TCP port forward", - LongHelp: strings.Join([]string{ - "EXAMPLES", - " - Forward TLS over TCP to a local TCP server on port 5432:", - " $ tailscale serve tcp 5432", - "", - " - Forward raw, TLS-terminated TCP packets to a local TCP server on port 5432:", - " $ tailscale serve tcp --terminate-tls 5432", - }, "\n"), - FlagSet: e.newFlags("serve-tcp", func(fs *flag.FlagSet) { - fs.BoolVar(&e.terminateTLS, "terminate-tls", false, "terminate TLS before forwarding TCP connection") - }), - UsageFunc: usageFunc, - }, - { - Name: "funnel", - Exec: e.runServeFunnel, - ShortUsage: "funnel [flags] {on|off}", - ShortHelp: "turn Tailscale Funnel on or off", - LongHelp: strings.Join([]string{ - "Funnel allows you to publish a 'tailscale serve'", - "server publicly, open to the entire internet.", - "", - "Turning off Funnel only turns off serving to the internet.", - "It does not affect serving to your tailnet.", - }, "\n"), - UsageFunc: usageFunc, - }, }, } } @@ -143,10 +120,7 @@ type localServeClient interface { // It also contains the flags, as registered with newServeCommand. type serveEnv struct { // flags - servePort uint // Port to serve on. Defaults to 443. - terminateTLS bool - remove bool // remove a serve config - json bool // output JSON (status only for now) + json bool // output JSON (status only for now) lc localServeClient // localClient interface, specific to serve @@ -186,24 +160,15 @@ func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status, return st, nil } -// validateServePort returns --serve-port flag value, -// or an error if the port is not a valid port to serve on. -func (e *serveEnv) validateServePort() (port uint16, err error) { - // Make sure e.servePort is uint16. - port = uint16(e.servePort) - if uint(port) != e.servePort { - return 0, fmt.Errorf("serve-port %d is out of range", e.servePort) - } - return port, nil -} - // runServe is the entry point for the "serve" subcommand, managing Web // serve config types like proxy, path, and text. // // Examples: -// - tailscale serve / proxy 3000 -// - tailscale serve /images/ path /var/www/images/ -// - tailscale --serve-port=10000 serve /motd.txt text "Hello, world!" +// - tailscale serve https / http://localhost:3000 +// - tailscale serve https /images/ /var/www/images/ +// - tailscale serve https:10000 /motd.txt text:"Hello, world!" +// - tailscale serve tcp:2222 tcp://localhost:22 +// - tailscale serve tls-terminated-tcp:443 tcp://localhost:80 func (e *serveEnv) runServe(ctx context.Context, args []string) error { if len(args) == 0 { return flag.ErrHelp @@ -223,39 +188,94 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error { return e.lc.SetServeConfig(ctx, sc) } - if !(len(args) == 3 || (e.remove && len(args) >= 1)) { - fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n") - return flag.ErrHelp + parsePort := func(portStr string) (uint16, error) { + port64, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return 0, err + } + return uint16(port64), nil } - srvPort, err := e.validateServePort() - if err != nil { - return err + srcType, srcPortStr, found := strings.Cut(args[0], ":") + if !found { + if srcType == "https" && srcPortStr == "" { + // Default https port to 443. + srcPortStr = "443" + } else { + return flag.ErrHelp + } } - srvPortStr := strconv.Itoa(int(srvPort)) - mount, err := cleanMountPoint(args[0]) + turnOff := "off" == args[len(args)-1] + + if len(args) < 2 || (srcType == "https" && !turnOff && len(args) < 3) { + fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n") + return flag.ErrHelp + } + + srcPort, err := parsePort(srcPortStr) if err != nil { return err } - if e.remove { - return e.handleWebServeRemove(ctx, mount) + switch srcType { + case "https": + mount, err := cleanMountPoint(args[1]) + if err != nil { + return err + } + if turnOff { + return e.handleWebServeRemove(ctx, srcPort, mount) + } + return e.handleWebServe(ctx, srcPort, mount, args[2]) + case "tcp", "tls-terminated-tcp": + if turnOff { + return e.handleTCPServeRemove(ctx, srcPort) + } + 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) + return flag.ErrHelp } +} +// handleWebServe handles the "tailscale serve https:..." subcommand. +// It configures the serve config to forward HTTPS connections to the +// given source. +// +// Examples: +// - 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 { h := new(ipn.HTTPHandler) - switch args[1] { - case "path": + ts, _, _ := strings.Cut(source, ":") + switch { + case ts == "text": + text := strings.TrimPrefix(source, "text:") + if text == "" { + return errors.New("unable to serve; text cannot be an empty string") + } + h.Text = text + case isProxyTarget(source): + t, err := expandProxyTarget(source) + if err != nil { + return err + } + h.Proxy = t + default: // assume path if version.IsSandboxedMacOS() { // don't allow path serving for now on macOS (2022-11-15) return fmt.Errorf("path serving is not supported if sandboxed on macOS") } - if !filepath.IsAbs(args[2]) { + if !filepath.IsAbs(source) { fmt.Fprintf(os.Stderr, "error: path must be absolute\n\n") return flag.ErrHelp } - fi, err := os.Stat(args[2]) + source = filepath.Clean(source) + fi, err := os.Stat(source) if err != nil { fmt.Fprintf(os.Stderr, "error: invalid path: %v\n\n", err) return flag.ErrHelp @@ -265,21 +285,7 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error { // for relative file links to work mount += "/" } - h.Path = args[2] - case "proxy": - t, err := expandProxyTarget(args[2]) - if err != nil { - return err - } - h.Proxy = t - case "text": - if args[2] == "" { - return errors.New("unable to serve; text cannot be an empty string") - } - h.Text = args[2] - default: - fmt.Fprintf(os.Stderr, "error: unknown serve type %q\n\n", args[1]) - return flag.ErrHelp + h.Path = source } cursc, err := e.lc.GetServeConfig(ctx) @@ -294,7 +300,7 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error { if err != nil { return err } - hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr)) + hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort)))) if sc.IsTCPForwardingOnPort(srvPort) { fmt.Fprintf(os.Stderr, "error: cannot serve web; already serving TCP\n") @@ -333,12 +339,36 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error { return nil } -func (e *serveEnv) handleWebServeRemove(ctx context.Context, mount string) error { - srvPort, err := e.validateServePort() - if err != nil { - return err +// isProxyTarget reports whether source is a valid proxy target. +func isProxyTarget(source string) bool { + if strings.HasPrefix(source, "http://") || + strings.HasPrefix(source, "https://") || + strings.HasPrefix(source, "https+insecure://") { + return true + } + // support "localhost:3000", for example + _, portStr, ok := strings.Cut(source, ":") + if ok && allNumeric(portStr) { + return true + } + return false +} + +// allNumeric reports whether s only comprises of digits +// and has at least one digit. +func allNumeric(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } } - srvPortStr := strconv.Itoa(int(srvPort)) + return s != "" +} + +// handleWebServeRemove removes a web handler from the serve config. +// The srvPort argument is the serving port and the mount argument is +// the mount point or registered path to remove. +func (e *serveEnv) handleWebServeRemove(ctx context.Context, srvPort uint16, mount string) error { sc, err := e.lc.GetServeConfig(ctx) if err != nil { return err @@ -353,9 +383,9 @@ func (e *serveEnv) handleWebServeRemove(ctx context.Context, mount string) error if sc.IsTCPForwardingOnPort(srvPort) { return errors.New("cannot remove web handler; currently serving TCP") } - hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr)) + hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort)))) if !sc.WebHandlerExists(hp, mount) { - return errors.New("error: serve config does not exist") + return errors.New("error: handler does not exist") } // delete existing handler, then cascade delete if empty delete(sc.Web[hp].Handlers, mount) @@ -390,18 +420,11 @@ func cleanMountPoint(mount string) (string, error) { return "", fmt.Errorf("invalid mount point %q", mount) } -func expandProxyTarget(target string) (string, error) { - if allNumeric(target) { - p, err := strconv.ParseUint(target, 10, 16) - if p == 0 || err != nil { - return "", fmt.Errorf("invalid port %q", target) - } - return "http://127.0.0.1:" + target, nil +func expandProxyTarget(source string) (string, error) { + if !strings.Contains(source, "://") { + source = "http://" + source } - if !strings.Contains(target, "://") { - target = "http://" + target - } - u, err := url.ParseRequestURI(target) + u, err := url.ParseRequestURI(source) if err != nil { return "", fmt.Errorf("parsing url: %w", err) } @@ -411,9 +434,14 @@ func expandProxyTarget(target string) (string, error) { default: return "", fmt.Errorf("must be a URL starting with http://, https://, or https+insecure://") } + + port, err := strconv.ParseUint(u.Port(), 10, 16) + if port == 0 || err != nil { + return "", fmt.Errorf("invalid port %q: %w", u.Port(), err) + } + host := u.Hostname() switch host { - // TODO(shayne,bradfitz): do we want to do this? case "localhost", "127.0.0.1": host = "127.0.0.1" default: @@ -426,16 +454,111 @@ func expandProxyTarget(target string) (string, error) { return url, nil } -func allNumeric(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] < '0' || s[i] > '9' { - return false +// handleTCPServe handles the "tailscale serve tls-terminated-tcp:..." subcommand. +// It configures the serve config to forward TCP connections to the +// given source. +// +// Examples: +// - tailscale serve tcp:2222 tcp://localhost:22 +// - tailscale serve tls-terminated-tcp:8443 tcp://localhost:8080 +func (e *serveEnv) handleTCPServe(ctx context.Context, srcType string, srcPort uint16, dest string) error { + var terminateTLS bool + switch srcType { + case "tcp": + terminateTLS = false + case "tls-terminated-tcp": + terminateTLS = true + default: + fmt.Fprintf(os.Stderr, "error: invalid TCP source %q\n\n", dest) + return flag.ErrHelp + } + + dstURL, err := url.Parse(dest) + if err != nil { + fmt.Fprintf(os.Stderr, "error: invalid TCP source %q: %v\n\n", dest, err) + return flag.ErrHelp + } + host, dstPortStr, err := net.SplitHostPort(dstURL.Host) + if err != nil { + fmt.Fprintf(os.Stderr, "error: invalid TCP source %q: %v\n\n", dest, err) + return flag.ErrHelp + } + + switch host { + case "localhost", "127.0.0.1": + // ok + default: + fmt.Fprintf(os.Stderr, "error: invalid TCP source %q\n", dest) + fmt.Fprint(os.Stderr, "must be one of: localhost or 127.0.0.1\n\n", dest) + return flag.ErrHelp + } + + if p, err := strconv.ParseUint(dstPortStr, 10, 16); p == 0 || err != nil { + fmt.Fprintf(os.Stderr, "error: invalid port %q\n\n", dstPortStr) + return flag.ErrHelp + } + + cursc, err := e.lc.GetServeConfig(ctx) + if err != nil { + return err + } + sc := cursc.Clone() // nil if no config + if sc == nil { + sc = new(ipn.ServeConfig) + } + + fwdAddr := "127.0.0.1:" + dstPortStr + + if sc.IsServingWeb(srcPort) { + return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort) + } + + mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: fwdAddr}) + + dnsName, err := e.getSelfDNSName(ctx) + if err != nil { + return err + } + if terminateTLS { + sc.TCP[srcPort].TerminateTLS = dnsName + } + + if !reflect.DeepEqual(cursc, sc) { + if err := e.lc.SetServeConfig(ctx, sc); err != nil { + return err } } - return s != "" + + return nil +} + +// handleTCPServeRemove removes the TCP forwarding configuration for the +// given srvPort, or serving port. +func (e *serveEnv) handleTCPServeRemove(ctx context.Context, src uint16) error { + cursc, err := e.lc.GetServeConfig(ctx) + if err != nil { + return err + } + sc := cursc.Clone() // nil if no config + if sc == nil { + sc = new(ipn.ServeConfig) + } + if sc.IsServingWeb(src) { + return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src) + } + if ph := sc.GetTCPPortHandler(src); ph != nil { + delete(sc.TCP, src) + // clear map mostly for testing + if len(sc.TCP) == 0 { + sc.TCP = nil + } + return e.lc.SetServeConfig(ctx, sc) + } + return errors.New("error: serve config does not exist") } -// runServeStatus prints the current serve config. +// runServeStatus is the entry point for the "serve status" +// subcommand and prints the current serve config. // // Examples: // - tailscale status @@ -454,6 +577,7 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error { e.stdout().Write(j) return nil } + printFunnelStatus(ctx) if sc == nil || (len(sc.TCP) == 0 && len(sc.Web) == 0 && len(sc.AllowFunnel) == 0) { printf("No serve config\n") return nil @@ -472,17 +596,7 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error { printWebStatusTree(sc, hp) printf("\n") } - // warn when funnel on without handlers - for hp, a := range sc.AllowFunnel { - if !a { - continue - } - _, portStr, _ := net.SplitHostPort(string(hp)) - p, _ := strconv.ParseUint(portStr, 10, 16) - if _, ok := sc.TCP[uint16(p)]; !ok { - printf("WARNING: funnel=on for %s, but no serve config\n", hp) - } - } + printFunnelWarning(sc) return nil } @@ -566,133 +680,3 @@ func elipticallyTruncate(s string, max int) string { } return s[:max-3] + "..." } - -// runServeTCP is the entry point for the "serve tcp" subcommand and -// manages the serve config for TCP forwarding. -// -// Examples: -// - tailscale serve tcp 5432 -// - tailscale serve --serve-port=8443 tcp 4430 -// - tailscale serve --serve-port=10000 tcp --terminate-tls 8080 -func (e *serveEnv) runServeTCP(ctx context.Context, args []string) error { - if len(args) != 1 { - fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n") - return flag.ErrHelp - } - - srvPort, err := e.validateServePort() - if err != nil { - return err - } - - portStr := args[0] - p, err := strconv.ParseUint(portStr, 10, 16) - if p == 0 || err != nil { - fmt.Fprintf(os.Stderr, "error: invalid port %q\n\n", portStr) - } - - cursc, err := e.lc.GetServeConfig(ctx) - if err != nil { - return err - } - sc := cursc.Clone() // nil if no config - if sc == nil { - sc = new(ipn.ServeConfig) - } - - fwdAddr := "127.0.0.1:" + portStr - - if sc.IsServingWeb(srvPort) { - if e.remove { - return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", srvPort) - } - return fmt.Errorf("cannot serve TCP; already serving web on %d", srvPort) - } - - if e.remove { - if ph := sc.GetTCPPortHandler(srvPort); ph != nil && ph.TCPForward == fwdAddr { - delete(sc.TCP, srvPort) - // clear map mostly for testing - if len(sc.TCP) == 0 { - sc.TCP = nil - } - return e.lc.SetServeConfig(ctx, sc) - } - return errors.New("error: serve config does not exist") - } - - mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{TCPForward: fwdAddr}) - - dnsName, err := e.getSelfDNSName(ctx) - if err != nil { - return err - } - if e.terminateTLS { - sc.TCP[srvPort].TerminateTLS = dnsName - } - - if !reflect.DeepEqual(cursc, sc) { - if err := e.lc.SetServeConfig(ctx, sc); err != nil { - return err - } - } - - return nil -} - -// runServeFunnel is the entry point for the "serve funnel" subcommand and -// manages turning on/off funnel. Funnel is off by default. -// -// Note: funnel is only supported on single DNS name for now. (2022-11-15) -func (e *serveEnv) runServeFunnel(ctx context.Context, args []string) error { - if len(args) != 1 { - return flag.ErrHelp - } - - srvPort, err := e.validateServePort() - if err != nil { - return err - } - srvPortStr := strconv.Itoa(int(srvPort)) - - var on bool - switch args[0] { - case "on", "off": - on = args[0] == "on" - default: - return flag.ErrHelp - } - sc, err := e.lc.GetServeConfig(ctx) - if err != nil { - return err - } - if sc == nil { - sc = new(ipn.ServeConfig) - } - st, err := e.getLocalClientStatus(ctx) - if err != nil { - return fmt.Errorf("getting client status: %w", err) - } - if err := ipn.CheckFunnelAccess(srvPort, st.Self.Capabilities); err != nil { - return err - } - dnsName := strings.TrimSuffix(st.Self.DNSName, ".") - hp := ipn.HostPort(dnsName + ":" + srvPortStr) - if on == sc.AllowFunnel[hp] { - // Nothing to do. - return nil - } - if on { - mak.Set(&sc.AllowFunnel, hp, true) - } else { - delete(sc.AllowFunnel, hp) - // clear map mostly for testing - if len(sc.AllowFunnel) == 0 { - sc.AllowFunnel = nil - } - } - if err := e.lc.SetServeConfig(ctx, sc); err != nil { - return err - } - return nil -} diff --git a/cmd/tailscale/cli/serve_test.go b/cmd/tailscale/cli/serve_test.go index 145bbd8c0..c94f4ca9c 100644 --- a/cmd/tailscale/cli/serve_test.go +++ b/cmd/tailscale/cli/serve_test.go @@ -15,6 +15,7 @@ import ( "strings" "testing" + "github.com/peterbourgon/ff/v3/ffcli" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" @@ -56,6 +57,8 @@ func TestServeConfigMutations(t *testing.T) { want *ipn.ServeConfig // non-nil means we want a save of this value wantErr func(error) (badErrMsg string) // nil means no error is wanted line int // line number of addStep call, for error messages + + debugBreak func() } var steps []step add := func(s step) { @@ -66,19 +69,19 @@ func TestServeConfigMutations(t *testing.T) { // funnel add(step{reset: true}) add(step{ - command: cmd("funnel on"), + command: cmd("funnel 443 on"), want: &ipn.ServeConfig{AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}}, }) add(step{ - command: cmd("funnel on"), + command: cmd("funnel 443 on"), want: nil, // nothing to save }) add(step{ - command: cmd("funnel off"), + command: cmd("funnel 443 off"), want: &ipn.ServeConfig{}, }) add(step{ - command: cmd("funnel off"), + command: cmd("funnel 443 off"), want: nil, // nothing to save }) add(step{ @@ -89,27 +92,48 @@ func TestServeConfigMutations(t *testing.T) { // https add(step{reset: true}) add(step{ - command: cmd("/ proxy 0"), // invalid port, too low + command: cmd("https:443 / http://localhost:0"), // invalid port, too low wantErr: anyErr(), }) add(step{ - command: cmd("/ proxy 65536"), // invalid port, too high + command: cmd("https:443 / http://localhost:65536"), // invalid port, too high wantErr: anyErr(), }) add(step{ - command: cmd("/ proxy somehost"), // invalid host + command: cmd("https:443 / http://somehost:3000"), // invalid host wantErr: anyErr(), }) add(step{ - command: cmd("/ proxy http://otherhost"), // invalid host + command: cmd("https:443 / httpz://127.0.0.1"), // invalid scheme wantErr: anyErr(), }) - add(step{ - command: cmd("/ proxy httpz://127.0.0.1"), // invalid scheme - wantErr: anyErr(), + add(step{ // allow omitting port (default to 443) + command: cmd("https / http://localhost:3000"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + }, + }, + }) + add(step{ // support non Funnel port + command: cmd("https:9999 /abc http://localhost:3001"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 9999: {HTTPS: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:443": {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("/ proxy 3000"), + command: cmd("https:9999 /abc off"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -120,7 +144,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("--serve-port=8443 /abc proxy 3001"), + command: cmd("https:8443 /abc http://127.0.0.1:3001"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -134,7 +158,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("--serve-port=10000 / text hi"), + command: cmd("https:10000 / text:hi"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 443: {HTTPS: true}, 8443: {HTTPS: true}, 10000: {HTTPS: true}}, @@ -152,12 +176,12 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("--remove /foo"), + command: cmd("https:443 /foo off"), want: nil, // nothing to save wantErr: anyErr(), }) // handler doesn't exist, so we get an error add(step{ - command: cmd("--remove --serve-port=10000 /"), + command: cmd("https:10000 / off"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -171,7 +195,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("--remove /"), + command: cmd("https:443 / off"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -182,11 +206,11 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("--remove --serve-port=8443 /abc"), + command: cmd("https:8443 /abc off"), want: &ipn.ServeConfig{}, }) - add(step{ - command: cmd("bar proxy https://127.0.0.1:8443"), + add(step{ // clean mount: "bar" becomes "/bar" + command: cmd("https:443 bar https://127.0.0.1:8443"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -197,12 +221,12 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("bar proxy https://127.0.0.1:8443"), + command: cmd("https:443 bar https://127.0.0.1:8443"), want: nil, // nothing to save }) add(step{reset: true}) add(step{ - command: cmd("/ proxy https+insecure://127.0.0.1:3001"), + command: cmd("https:443 / https+insecure://127.0.0.1:3001"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -214,7 +238,7 @@ func TestServeConfigMutations(t *testing.T) { }) add(step{reset: true}) add(step{ - command: cmd("/foo proxy localhost:3000"), + command: cmd("https:443 /foo localhost:3000"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -225,7 +249,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // test a second handler on the same port - command: cmd("--serve-port=8443 /foo proxy localhost:3000"), + command: cmd("https:8443 /foo localhost:3000"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -241,16 +265,35 @@ func TestServeConfigMutations(t *testing.T) { // tcp add(step{reset: true}) + add(step{ // must include scheme for tcp + command: cmd("tls-terminated-tcp:443 localhost:5432"), + wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), + }) + add(step{ // !somehost, must be localhost or 127.0.0.1 + command: cmd("tls-terminated-tcp:443 tcp://somehost:5432"), + wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), + }) + add(step{ // bad target port, too low + command: cmd("tls-terminated-tcp:443 tcp://somehost:0"), + wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), + }) + add(step{ // bad target port, too high + command: cmd("tls-terminated-tcp:443 tcp://somehost:65536"), + wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), + }) add(step{ - command: cmd("tcp 5432"), + command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {TCPForward: "127.0.0.1:5432"}, + 443: { + TCPForward: "127.0.0.1:5432", + TerminateTLS: "foo.test.ts.net", + }, }, }, }) add(step{ - command: cmd("tcp -terminate-tls 8443"), + command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8443"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 443: { @@ -261,11 +304,11 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("tcp -terminate-tls 8443"), + command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8443"), want: nil, // nothing to save }) add(step{ - command: cmd("tcp --terminate-tls 8444"), + command: cmd("tls-terminated-tcp:443 tcp://localhost:8444"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ 443: { @@ -276,35 +319,41 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("tcp -terminate-tls=false 8445"), + command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8445"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {TCPForward: "127.0.0.1:8445"}, + 443: { + TCPForward: "127.0.0.1:8445", + TerminateTLS: "foo.test.ts.net", + }, }, }, }) add(step{reset: true}) add(step{ - command: cmd("tcp 123"), + command: cmd("tls-terminated-tcp:443 tcp://localhost:123"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {TCPForward: "127.0.0.1:123"}, + 443: { + TCPForward: "127.0.0.1:123", + TerminateTLS: "foo.test.ts.net", + }, }, }, }) - add(step{ - command: cmd("--remove tcp 321"), + add(step{ // handler doesn't exist, so we get an error + command: cmd("tls-terminated-tcp:8443 off"), wantErr: anyErr(), - }) // handler doesn't exist, so we get an error + }) add(step{ - command: cmd("--remove tcp 123"), + command: cmd("tls-terminated-tcp:443 off"), want: &ipn.ServeConfig{}, }) // text add(step{reset: true}) add(step{ - command: cmd("/ text hello"), + command: cmd("https:443 / text:hello"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -325,7 +374,7 @@ func TestServeConfigMutations(t *testing.T) { add(step{reset: true}) writeFile("foo", "this is foo") add(step{ - command: cmd("/ path " + filepath.Join(td, "foo")), + command: cmd("https:443 / " + filepath.Join(td, "foo")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -338,7 +387,7 @@ func TestServeConfigMutations(t *testing.T) { os.MkdirAll(filepath.Join(td, "subdir"), 0700) writeFile("subdir/file-a", "this is A") add(step{ - command: cmd("/some/where path " + filepath.Join(td, "subdir/file-a")), + command: cmd("https:443 /some/where " + filepath.Join(td, "subdir/file-a")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -349,13 +398,13 @@ func TestServeConfigMutations(t *testing.T) { }, }, }) - add(step{ - command: cmd("/ path missing"), + add(step{ // bad path + command: cmd("https:443 / bad/path"), wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), }) add(step{reset: true}) add(step{ - command: cmd("/ path " + filepath.Join(td, "subdir")), + command: cmd("https:443 / " + filepath.Join(td, "subdir")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -366,14 +415,14 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("--remove /"), + command: cmd("https:443 / off"), want: &ipn.ServeConfig{}, }) // combos add(step{reset: true}) add(step{ - command: cmd("/ proxy 3000"), + command: cmd("https:443 / localhost:3000"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -384,7 +433,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ - command: cmd("funnel on"), + command: cmd("funnel 443 on"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, @@ -396,7 +445,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // serving on secondary port doesn't change funnel - command: cmd("--serve-port=8443 /bar proxy 3001"), + command: cmd("https:8443 /bar localhost:3001"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, @@ -411,7 +460,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // turn funnel on for secondary port - command: cmd("--serve-port=8443 funnel on"), + command: cmd("funnel 8443 on"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true, "foo.test.ts.net:8443": true}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, @@ -426,7 +475,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // turn funnel off for primary port 443 - command: cmd("funnel off"), + command: cmd("funnel 443 off"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}}, @@ -441,7 +490,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // remove secondary port - command: cmd("--serve-port=8443 --remove /bar"), + command: cmd("https:8443 /bar off"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, @@ -453,7 +502,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // start a tcp forwarder on 8443 - command: cmd("--serve-port=8443 tcp 5432"), + command: cmd("tcp:8443 tcp://localhost:5432"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true}, TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "127.0.0.1:5432"}}, @@ -465,27 +514,27 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // remove primary port http handler - command: cmd("--remove /"), + command: cmd("https:443 / off"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true}, TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "127.0.0.1:5432"}}, }, }) add(step{ // remove tcp forwarder - command: cmd("--serve-port=8443 --remove tcp 5432"), + command: cmd("tls-terminated-tcp:8443 off"), want: &ipn.ServeConfig{ AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true}, }, }) add(step{ // turn off funnel - command: cmd("--serve-port=8443 funnel off"), + command: cmd("funnel 8443 off"), want: &ipn.ServeConfig{}, }) // tricky steps add(step{reset: true}) add(step{ // a directory with a trailing slash mount point - command: cmd("/dir path " + filepath.Join(td, "subdir")), + command: cmd("https:443 /dir " + filepath.Join(td, "subdir")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -496,7 +545,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // this should overwrite the previous one - command: cmd("/dir path " + filepath.Join(td, "foo")), + command: cmd("https:443 /dir " + filepath.Join(td, "foo")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -508,7 +557,7 @@ func TestServeConfigMutations(t *testing.T) { }) add(step{reset: true}) // reset and do the opposite add(step{ // a file without a trailing slash mount point - command: cmd("/dir path " + filepath.Join(td, "foo")), + command: cmd("https:443 /dir " + filepath.Join(td, "foo")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -519,7 +568,7 @@ func TestServeConfigMutations(t *testing.T) { }, }) add(step{ // this should overwrite the previous one - command: cmd("/dir path " + filepath.Join(td, "subdir")), + command: cmd("https:443 /dir " + filepath.Join(td, "subdir")), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -532,37 +581,24 @@ func TestServeConfigMutations(t *testing.T) { // error states add(step{reset: true}) - add(step{ // make sure we can't add "tcp" as if it was a mount - command: cmd("tcp text foo"), - wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), - }) - add(step{ // "/tcp" is fine though as a mount - command: cmd("/tcp text foo"), - want: &ipn.ServeConfig{ - TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, - Web: map[ipn.HostPort]*ipn.WebServerConfig{ - "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ - "/tcp": {Text: "foo"}, - }}, - }, - }, - }) - add(step{reset: true}) add(step{ // tcp forward 5432 on serve port 443 - command: cmd("tcp 5432"), + command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{ - 443: {TCPForward: "127.0.0.1:5432"}, + 443: { + TCPForward: "127.0.0.1:5432", + TerminateTLS: "foo.test.ts.net", + }, }, }, }) add(step{ // try to start a web handler on the same port - command: cmd("/ proxy 3000"), + command: cmd("https:443 / localhost:3000"), wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), }) add(step{reset: true}) add(step{ // start a web handler on port 443 - command: cmd("/ proxy 3000"), + command: cmd("https:443 / localhost:3000"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ @@ -572,14 +608,17 @@ func TestServeConfigMutations(t *testing.T) { }, }, }) - add(step{ // try to start a tcp forwarder on the same serve port (443 default) - command: cmd("tcp 5432"), + add(step{ // try to start a tcp forwarder on the same serve port + command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"), wantErr: anyErr(), }) lc := &fakeLocalServeClient{} // And now run the steps above. for i, st := range steps { + if st.debugBreak != nil { + st.debugBreak() + } if st.reset { t.Logf("Executing step #%d, line %v: [reset]", i, st.line) lc.config = nil @@ -597,8 +636,16 @@ func TestServeConfigMutations(t *testing.T) { testStdout: &stdout, } lastCount := lc.setCount - cmd := newServeCommand(e) - err := cmd.ParseAndRun(context.Background(), st.command) + var cmd *ffcli.Command + var args []string + if st.command[0] == "funnel" { + cmd = newFunnelCommand(e) + args = st.command[1:] + } else { + cmd = newServeCommand(e) + args = st.command + } + err := cmd.ParseAndRun(context.Background(), args) if flagOut.Len() > 0 { t.Logf("flag package output: %q", flagOut.Bytes()) } @@ -689,7 +736,5 @@ func anyErr() func(error) string { } func cmd(s string) []string { - cmds := strings.Fields(s) - fmt.Printf("cmd: %v", cmds) - return cmds + return strings.Fields(s) } diff --git a/cmd/tailscale/cli/status.go b/cmd/tailscale/cli/status.go index 54211d186..53fb99975 100644 --- a/cmd/tailscale/cli/status.go +++ b/cmd/tailscale/cli/status.go @@ -258,6 +258,7 @@ func printFunnelStatus(ctx context.Context) { } printf("# - %s\n", url) } + outln() } // isRunningOrStarting reports whether st is in state Running or Starting.