ソースを参照

cmd/tailscale/cli: [serve] rework commands based on feedback (#6521)

```
$ tailscale serve https:<port> <mount-point> <source> [off]
$ tailscale serve tcp:<port> tcp://localhost:<local-port> [off]
$ tailscale serve tls-terminated-tcp:<port> tcp://localhost:<local-port> [off]
$ tailscale serve status [--json]

$ tailscale funnel <serve-port> {on|off}
$ tailscale funnel status [--json]
```

Fixes: #6674

Signed-off-by: Shayne Sweeney <[email protected]>
shayne 3 年 前
コミット
2b892ad6e7

+ 2 - 0
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"):

+ 138 - 0
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 <serve-port> {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")
+	}
+}

+ 242 - 258
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] <mount-point> {proxy|path|text} <arg>
-  serve [flags] <sub-command> [sub-flags] <args>`),
+serve https:<port> <mount-point> <source> [off]
+  serve tcp:<port> tcp://localhost:<local-port> [off]
+  serve tls-terminated-tcp:<port> tcp://localhost:<local-port> [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:<port>, tcp:<port> or tls-terminated-tcp:<port>\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
-}

+ 130 - 85
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)
 }

+ 1 - 0
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.