Просмотр исходного кода

cmd/tailscale/cli: flesh out serve CLI and tests (#6304)

Signed-off-by: Shayne Sweeney <[email protected]>
shayne 3 лет назад
Родитель
Сommit
a97369f097

+ 3 - 2
cmd/tailscale/cli/cli.go

@@ -190,11 +190,10 @@ change in the future.
 	if envknob.UseWIPCode() {
 		rootCmd.Subcommands = append(rootCmd.Subcommands,
 			idTokenCmd,
-			serveCmd,
 		)
 	}
 
-	// Don't advertise the debug command, but it exists.
+	// Don't advertise these commands, but they're still explicitly available.
 	switch {
 	case slices.Contains(args, "debug"):
 		rootCmd.Subcommands = append(rootCmd.Subcommands, debugCmd)
@@ -202,6 +201,8 @@ change in the future.
 		rootCmd.Subcommands = append(rootCmd.Subcommands, loginCmd)
 	case slices.Contains(args, "switch"):
 		rootCmd.Subcommands = append(rootCmd.Subcommands, switchCmd)
+	case slices.Contains(args, "serve"):
+		rootCmd.Subcommands = append(rootCmd.Subcommands, serveCmd)
 	}
 	if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
 		rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)

+ 614 - 30
cmd/tailscale/cli/serve.go

@@ -7,14 +7,27 @@ package cli
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"flag"
 	"fmt"
 	"io"
+	"net"
+	"net/url"
 	"os"
+	"path"
+	"path/filepath"
+	"reflect"
+	"sort"
+	"strconv"
+	"strings"
 
 	"github.com/peterbourgon/ff/v3/ffcli"
+	"golang.org/x/exp/slices"
 	"tailscale.com/ipn"
+	"tailscale.com/ipn/ipnstate"
+	"tailscale.com/tailcfg"
 	"tailscale.com/util/mak"
+	"tailscale.com/version"
 )
 
 var serveCmd = newServeCommand(&serveEnv{})
@@ -22,31 +35,80 @@ var serveCmd = newServeCommand(&serveEnv{})
 // newServeCommand returns a new "serve" subcommand using e as its environmment.
 func newServeCommand(e *serveEnv) *ffcli.Command {
 	return &ffcli.Command{
-		Name:       "serve",
-		ShortHelp:  "TODO",
-		ShortUsage: "serve {show-config|https|tcp|ingress} <args>",
-		LongHelp:   "", // TODO
-		Exec:       e.runServe,
-		FlagSet:    e.newFlags("serve", func(fs *flag.FlagSet) {}),
+		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>`),
+		LongHelp: strings.TrimSpace(`
+*** ALPHA; all of this is subject to change ***
+
+The 'tailscale serve' set of commands allows you to serve
+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
+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
+
+  - 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
+
+  - To serve simple static text:
+    $ tailscale serve / text "Hello, world!"
+`),
+		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)")
+		}),
+		UsageFunc: usageFunc,
 		Subcommands: []*ffcli.Command{
 			{
-				Name:      "show-config",
-				Exec:      e.runServeShowConfig,
-				ShortHelp: "show current serve config",
+				Name:      "status",
+				Exec:      e.runServeStatus,
+				ShortHelp: "show current serve 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 --terminate-tls tcp 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:      "ingress",
-				Exec:      e.runServeIngress,
-				ShortHelp: "enable or disable ingress",
-				FlagSet:   e.newFlags("serve-ingress", func(fs *flag.FlagSet) {}),
+				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,
 			},
 		},
 	}
@@ -58,13 +120,44 @@ func newServeCommand(e *serveEnv) *ffcli.Command {
 // 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)
 
 	// optional stuff for tests:
-	testFlagOut        io.Writer
-	testGetServeConfig func(context.Context) (*ipn.ServeConfig, error)
-	testSetServeConfig func(context.Context, *ipn.ServeConfig) error
-	testStdout         io.Writer
+	testFlagOut              io.Writer
+	testGetServeConfig       func(context.Context) (*ipn.ServeConfig, error)
+	testSetServeConfig       func(context.Context, *ipn.ServeConfig) error
+	testGetLocalClientStatus func(context.Context) (*ipnstate.Status, error)
+	testStdout               io.Writer
+}
+
+func (e *serveEnv) getSelfDNSName(ctx context.Context) (string, error) {
+	st, err := e.getLocalClientStatus(ctx)
+	if err != nil {
+		return "", fmt.Errorf("getting client status: %w", err)
+	}
+	return strings.TrimSuffix(st.Self.DNSName, "."), nil
+}
+
+func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status, error) {
+	if e.testGetLocalClientStatus != nil {
+		return e.testGetLocalClientStatus(ctx)
+	}
+	st, err := localClient.Status(ctx)
+	if err != nil {
+		return nil, fixTailscaledConnectError(err)
+	}
+	description, ok := isRunningOrStarting(st)
+	if !ok {
+		fmt.Fprintf(os.Stderr, "%s\n", description)
+		os.Exit(1)
+	}
+	if st.Self == nil {
+		return nil, errors.New("no self node")
+	}
+	return st, nil
 }
 
 func (e *serveEnv) newFlags(name string, setup func(fs *flag.FlagSet)) *flag.FlagSet {
@@ -101,7 +194,39 @@ func (e *serveEnv) stdout() io.Writer {
 	return os.Stdout
 }
 
+// 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)
+	}
+	// make sure e.servePort is 443, 8443 or 10000
+	if port != 443 && port != 8443 && port != 10000 {
+		return 0, fmt.Errorf("serve-port %d is invalid; must be 443, 8443 or 10000", 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!"
 func (e *serveEnv) runServe(ctx context.Context, args []string) error {
+	if len(args) == 0 {
+		return flag.ErrHelp
+	}
+
+	srvPort, err := e.validateServePort()
+	if err != nil {
+		return err
+	}
+	srvPortStr := strconv.Itoa(int(srvPort))
+
 	// Undocumented debug command (not using ffcli subcommands) to set raw
 	// configs from stdin for now (2022-11-13).
 	if len(args) == 1 && args[0] == "set-raw" {
@@ -115,31 +240,471 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
 		}
 		return localClient.SetServeConfig(ctx, sc)
 	}
-	panic("TODO")
+
+	if !(len(args) == 3 || (e.remove && len(args) >= 1)) {
+		fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
+		return flag.ErrHelp
+	}
+
+	mount, err := cleanMountPoint(args[0])
+	if err != nil {
+		return err
+	}
+
+	if e.remove {
+		return e.handleWebServeRemove(ctx, mount)
+	}
+
+	h := new(ipn.HTTPHandler)
+
+	switch args[1] {
+	case "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]) {
+			fmt.Fprintf(os.Stderr, "error: path must be absolute\n\n")
+			return flag.ErrHelp
+		}
+		fi, err := os.Stat(args[2])
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "error: invalid path: %v\n\n", err)
+			return flag.ErrHelp
+		}
+		if fi.IsDir() && !strings.HasSuffix(mount, "/") {
+			// dir mount points must end in /
+			// 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":
+		h.Text = args[2]
+	default:
+		fmt.Fprintf(os.Stderr, "error: unknown serve type %q\n\n", args[1])
+		return flag.ErrHelp
+	}
+
+	cursc, err := e.getServeConfig(ctx)
+	if err != nil {
+		return err
+	}
+	sc := cursc.Clone() // nil if no config
+	if sc == nil {
+		sc = new(ipn.ServeConfig)
+	}
+	dnsName, err := e.getSelfDNSName(ctx)
+	if err != nil {
+		return err
+	}
+	hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr))
+
+	if isTCPForwardingOnPort(sc, srvPort) {
+		fmt.Fprintf(os.Stderr, "error: cannot serve web; already serving TCP\n")
+		return flag.ErrHelp
+	}
+
+	mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: true})
+
+	if _, ok := sc.Web[hp]; !ok {
+		mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
+	}
+	mak.Set(&sc.Web[hp].Handlers, mount, h)
+
+	for k, v := range sc.Web[hp].Handlers {
+		if v == h {
+			continue
+		}
+		// If the new mount point ends in / and another mount point
+		// shares the same prefix, remove the other handler.
+		// (e.g. /foo/ overwrites /foo)
+		// The opposite example is also handled.
+		m1 := strings.TrimSuffix(mount, "/")
+		m2 := strings.TrimSuffix(k, "/")
+		if m1 == m2 {
+			delete(sc.Web[hp].Handlers, k)
+			continue
+		}
+	}
+
+	if !reflect.DeepEqual(cursc, sc) {
+		if err := e.setServeConfig(ctx, sc); err != nil {
+			return err
+		}
+	}
+
+	return nil
 }
 
-func (e *serveEnv) runServeShowConfig(ctx context.Context, args []string) error {
+func (e *serveEnv) handleWebServeRemove(ctx context.Context, mount string) error {
+	srvPort, err := e.validateServePort()
+	if err != nil {
+		return err
+	}
+	srvPortStr := strconv.Itoa(int(srvPort))
 	sc, err := e.getServeConfig(ctx)
 	if err != nil {
 		return err
 	}
-	j, err := json.MarshalIndent(sc, "", "  ")
+	if sc == nil {
+		return errors.New("error: serve config does not exist")
+	}
+	dnsName, err := e.getSelfDNSName(ctx)
 	if err != nil {
 		return err
 	}
-	j = append(j, '\n')
-	e.stdout().Write(j)
+	if isTCPForwardingOnPort(sc, srvPort) {
+		return errors.New("cannot remove web handler; currently serving TCP")
+	}
+	hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr))
+	if !httpHandlerExists(sc, hp, mount) {
+		return errors.New("error: serve config does not exist")
+	}
+	// delete existing handler, then cascade delete if empty
+	delete(sc.Web[hp].Handlers, mount)
+	if len(sc.Web[hp].Handlers) == 0 {
+		delete(sc.Web, hp)
+		delete(sc.TCP, srvPort)
+	}
+	// clear empty maps mostly for testing
+	if len(sc.Web) == 0 {
+		sc.Web = nil
+	}
+	if len(sc.TCP) == 0 {
+		sc.TCP = nil
+	}
+	if err := e.setServeConfig(ctx, sc); err != nil {
+		return err
+	}
+	return nil
+}
+
+func httpHandlerExists(sc *ipn.ServeConfig, hp ipn.HostPort, mount string) bool {
+	h := getHTTPHandler(sc, hp, mount)
+	return h != nil
+}
+
+func getHTTPHandler(sc *ipn.ServeConfig, hp ipn.HostPort, mount string) *ipn.HTTPHandler {
+	if sc != nil && sc.Web[hp] != nil {
+		return sc.Web[hp].Handlers[mount]
+	}
 	return nil
 }
 
+func cleanMountPoint(mount string) (string, error) {
+	if mount == "" {
+		return "", errors.New("mount point cannot be empty")
+	}
+	if !strings.HasPrefix(mount, "/") {
+		mount = "/" + mount
+	}
+	c := path.Clean(mount)
+	if mount == c || mount == c+"/" {
+		return mount, nil
+	}
+	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
+	}
+	if !strings.Contains(target, "://") {
+		target = "http://" + target
+	}
+	u, err := url.ParseRequestURI(target)
+	if err != nil {
+		return "", fmt.Errorf("parsing url: %w", err)
+	}
+	switch u.Scheme {
+	case "http", "https", "https+insecure":
+		// ok
+	default:
+		return "", fmt.Errorf("must be a URL starting with http://, https://, or https+insecure://")
+	}
+	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:
+		return "", fmt.Errorf("only localhost or 127.0.0.1 proxies are currently supported")
+	}
+	url := u.Scheme + "://" + host
+	if u.Port() != "" {
+		url += ":" + u.Port()
+	}
+	return url, nil
+}
+
+// isTCPForwardingAny checks if any TCP port is being forwarded.
+func isTCPForwardingAny(sc *ipn.ServeConfig) bool {
+	if sc == nil || len(sc.TCP) == 0 {
+		return false
+	}
+	for _, h := range sc.TCP {
+		if h.TCPForward != "" {
+			return true
+		}
+	}
+	return false
+}
+
+// isTCPForwardingOnPort checks serve config to see if
+// we're specifically forwarding TCP on the given port.
+func isTCPForwardingOnPort(sc *ipn.ServeConfig, port uint16) bool {
+	if sc == nil || sc.TCP[port] == nil {
+		return false
+	}
+	return !sc.TCP[port].HTTPS
+}
+
+// isServingWeb checks serve config to see if
+// we're serving a web handler on the given port.
+func isServingWeb(sc *ipn.ServeConfig, port uint16) bool {
+	if sc == nil || sc.Web == nil || sc.TCP == nil ||
+		sc.TCP[port] == nil || sc.TCP[port].HTTPS == false {
+		// not listening on port
+		return false
+	}
+	return true
+}
+
+func allNumeric(s string) bool {
+	for i := 0; i < len(s); i++ {
+		if s[i] < '0' || s[i] > '9' {
+			return false
+		}
+	}
+	return s != ""
+}
+
+// runServeStatus prints the current serve config.
+//
+// Examples:
+//   - tailscale status
+//   - tailscale status --json
+func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
+	sc, err := e.getServeConfig(ctx)
+	if err != nil {
+		return err
+	}
+	if e.json {
+		j, err := json.MarshalIndent(sc, "", "  ")
+		if err != nil {
+			return err
+		}
+		j = append(j, '\n')
+		e.stdout().Write(j)
+		return nil
+	}
+	if sc == nil || (len(sc.TCP) == 0 && len(sc.Web) == 0 && len(sc.AllowFunnel) == 0) {
+		printf("No serve config\n")
+		return nil
+	}
+	st, err := e.getLocalClientStatus(ctx)
+	if err != nil {
+		return err
+	}
+	if isTCPForwardingAny(sc) {
+		if err := printTCPStatusTree(ctx, sc, st); err != nil {
+			return err
+		}
+		printf("\n")
+	}
+	for hp := range sc.Web {
+		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)
+		}
+	}
+	return nil
+}
+
+func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.Status) error {
+	dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
+	for p, h := range sc.TCP {
+		if h.TCPForward == "" {
+			continue
+		}
+		hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(p))))
+		tlsStatus := "TLS over TCP"
+		if h.TerminateTLS != "" {
+			tlsStatus = "TLS terminated"
+		}
+		fStatus := "tailnet only"
+		if isFunnelOn(sc, hp) {
+			fStatus = "Funnel on"
+		}
+		printf("|-- tcp://%s (%s, %s)\n", hp, tlsStatus, fStatus)
+		for _, a := range st.TailscaleIPs {
+			ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(p)))
+			printf("|-- tcp://%s\n", ipp)
+		}
+		printf("|--> tcp://%s\n", h.TCPForward)
+	}
+	return nil
+}
+
+func printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) {
+	if sc == nil {
+		return
+	}
+	fStatus := "tailnet only"
+	if isFunnelOn(sc, 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)
+	}
+	srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) {
+		switch {
+		case h.Path != "":
+			return "path", h.Path
+		case h.Proxy != "":
+			return "proxy", h.Proxy
+		case h.Text != "":
+			return "text", "\"" + elipticallyTruncate(h.Text, 20) + "\""
+		}
+		return "", ""
+	}
+
+	var mounts []string
+	for k := range sc.Web[hp].Handlers {
+		mounts = append(mounts, k)
+	}
+	sort.Slice(mounts, func(i, j int) bool {
+		return len(mounts[i]) < len(mounts[j])
+	})
+	maxLen := len(mounts[len(mounts)-1])
+
+	for _, m := range mounts {
+		h := sc.Web[hp].Handlers[m]
+		t, d := srvTypeAndDesc(h)
+		printf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d)
+	}
+}
+
+func elipticallyTruncate(s string, max int) string {
+	if len(s) <= max {
+		return s
+	}
+	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-port=8443 tcp 4430
+//   - tailscale --serve-port=10000 --terminate-tls tcp 8080
 func (e *serveEnv) runServeTCP(ctx context.Context, args []string) error {
-	panic("TODO")
+	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.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 e.remove {
+		if isServingWeb(sc, srvPort) {
+			return errors.New("cannot remove TCP port; currently serving web")
+		}
+		if sc.TCP != nil && sc.TCP[srvPort] != nil &&
+			sc.TCP[srvPort].TCPForward == fwdAddr {
+			delete(sc.TCP, srvPort)
+			// clear map mostly for testing
+			if len(sc.TCP) == 0 {
+				sc.TCP = nil
+			}
+			return e.setServeConfig(ctx, sc)
+		}
+
+		return errors.New("error: serve config does not exist")
+	}
+
+	if isServingWeb(sc, srvPort) {
+		fmt.Fprintf(os.Stderr, "error: cannot serve TCP; already serving Web\n\n")
+		return flag.ErrHelp
+	}
+
+	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.setServeConfig(ctx, sc); err != nil {
+			return err
+		}
+	}
+
+	return nil
 }
 
-func (e *serveEnv) runServeIngress(ctx context.Context, args []string) error {
+// 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":
@@ -151,9 +716,17 @@ func (e *serveEnv) runServeIngress(ctx context.Context, args []string) error {
 	if err != nil {
 		return err
 	}
-	var key ipn.HostPort = "foo:123" // TODO(bradfitz,shayne): fix
-	if on && sc != nil && sc.AllowIngress[key] ||
-		!on && (sc == nil || !sc.AllowIngress[key]) {
+	st, err := e.getLocalClientStatus(ctx)
+	if err != nil {
+		return fmt.Errorf("getting client status: %w", err)
+	}
+	if !slices.Contains(st.Self.Capabilities, tailcfg.NodeAttrFunnel) {
+		return errors.New("Funnel not available. See https://tailscale.com/s/no-funnel")
+	}
+	dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
+	hp := ipn.HostPort(dnsName + ":" + srvPortStr)
+	if on && sc != nil && sc.AllowFunnel[hp] ||
+		!on && (sc == nil || !sc.AllowFunnel[hp]) {
 		// Nothing to do.
 		return nil
 	}
@@ -161,9 +734,20 @@ func (e *serveEnv) runServeIngress(ctx context.Context, args []string) error {
 		sc = &ipn.ServeConfig{}
 	}
 	if on {
-		mak.Set(&sc.AllowIngress, "foo:123", true)
+		mak.Set(&sc.AllowFunnel, hp, true)
 	} else {
-		delete(sc.AllowIngress, "foo:123")
+		delete(sc.AllowFunnel, hp)
+		// clear map mostly for testing
+		if len(sc.AllowFunnel) == 0 {
+			sc.AllowFunnel = nil
+		}
+	}
+	if err := e.setServeConfig(ctx, sc); err != nil {
+		return err
 	}
-	return e.setServeConfig(ctx, sc)
+	return nil
+}
+
+func isFunnelOn(sc *ipn.ServeConfig, hp ipn.HostPort) bool {
+	return sc != nil && sc.AllowFunnel[hp]
 }

+ 558 - 8
cmd/tailscale/cli/serve_test.go

@@ -9,14 +9,46 @@ import (
 	"context"
 	"flag"
 	"fmt"
+	"os"
+	"path/filepath"
 	"reflect"
 	"runtime"
 	"strings"
 	"testing"
 
 	"tailscale.com/ipn"
+	"tailscale.com/ipn/ipnstate"
+	"tailscale.com/tailcfg"
 )
 
+func TestCleanMountPoint(t *testing.T) {
+	tests := []struct {
+		mount   string
+		want    string
+		wantErr bool
+	}{
+		{"foo", "/foo", false},              // missing prefix
+		{"/foo/", "/foo/", false},           // keep trailing slash
+		{"////foo", "", true},               // too many slashes
+		{"/foo//", "", true},                // too many slashes
+		{"", "", true},                      // empty
+		{"https://tailscale.com", "", true}, // not a path
+	}
+	for _, tt := range tests {
+		mp, err := cleanMountPoint(tt.mount)
+		if err != nil && tt.wantErr {
+			continue
+		}
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if mp != tt.want {
+			t.Fatalf("got %q, want %q", mp, tt.want)
+		}
+	}
+}
+
 func TestServeConfigMutations(t *testing.T) {
 	// Stateful mutations, starting from an empty config.
 	type step struct {
@@ -32,25 +64,521 @@ func TestServeConfigMutations(t *testing.T) {
 		steps = append(steps, s)
 	}
 
+	// funnel
 	add(step{reset: true})
 	add(step{
-		command: cmd("ingress on"),
-		want:    &ipn.ServeConfig{AllowIngress: map[ipn.HostPort]bool{"foo:123": true}},
+		command: cmd("funnel on"),
+		want:    &ipn.ServeConfig{AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}},
 	})
 	add(step{
-		command: cmd("ingress on"),
+		command: cmd("funnel on"),
 		want:    nil, // nothing to save
 	})
 	add(step{
-		command: cmd("ingress off"),
-		want:    &ipn.ServeConfig{AllowIngress: map[ipn.HostPort]bool{}},
+		command: cmd("funnel off"),
+		want:    &ipn.ServeConfig{},
+	})
+	add(step{
+		command: cmd("funnel off"),
+		want:    nil, // nothing to save
+	})
+	add(step{
+		command: cmd("funnel"),
+		wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
+	})
+
+	// https
+	add(step{reset: true})
+	add(step{
+		command: cmd("/ proxy 0"), // invalid port, too low
+		wantErr: anyErr(),
+	})
+	add(step{
+		command: cmd("/ proxy 65536"), // invalid port, too high
+		wantErr: anyErr(),
+	})
+	add(step{
+		command: cmd("/ proxy somehost"), // invalid host
+		wantErr: anyErr(),
+	})
+	add(step{
+		command: cmd("/ proxy http://otherhost"), // invalid host
+		wantErr: anyErr(),
+	})
+	add(step{
+		command: cmd("/ proxy httpz://127.0.0.1"), // invalid scheme
+		wantErr: anyErr(),
+	})
+	add(step{
+		command: cmd("/ proxy 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{
+		command: cmd("--serve-port=9999 /abc proxy 3001"),
+		wantErr: anyErr(),
+	}) // invalid port
+	add(step{
+		command: cmd("--serve-port=8443 /abc proxy 3001"),
+		want: &ipn.ServeConfig{
+			TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {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:8443": {Handlers: map[string]*ipn.HTTPHandler{
+					"/abc": {Proxy: "http://127.0.0.1:3001"},
+				}},
+			},
+		},
 	})
 	add(step{
-		command: cmd("ingress off"),
+		command: cmd("--serve-port=10000 / text hi"),
+		want: &ipn.ServeConfig{
+			TCP: map[uint16]*ipn.TCPPortHandler{
+				443: {HTTPS: true}, 8443: {HTTPS: true}, 10000: {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:8443": {Handlers: map[string]*ipn.HTTPHandler{
+					"/abc": {Proxy: "http://127.0.0.1:3001"},
+				}},
+				"foo.test.ts.net:10000": {Handlers: map[string]*ipn.HTTPHandler{
+					"/": {Text: "hi"},
+				}},
+			},
+		},
+	})
+	add(step{
+		command: cmd("--remove /foo"),
 		want:    nil, // nothing to save
+		wantErr: anyErr(),
+	}) // handler doesn't exist, so we get an error
+	add(step{
+		command: cmd("--remove --serve-port=10000 /"),
+		want: &ipn.ServeConfig{
+			TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {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:8443": {Handlers: map[string]*ipn.HTTPHandler{
+					"/abc": {Proxy: "http://127.0.0.1:3001"},
+				}},
+			},
+		},
+	})
+	add(step{
+		command: cmd("--remove /"),
+		want: &ipn.ServeConfig{
+			TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}},
+			Web: map[ipn.HostPort]*ipn.WebServerConfig{
+				"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
+					"/abc": {Proxy: "http://127.0.0.1:3001"},
+				}},
+			},
+		},
+	})
+	add(step{
+		command: cmd("--remove --serve-port=8443 /abc"),
+		want:    &ipn.ServeConfig{},
+	})
+	add(step{
+		command: cmd("bar proxy https://127.0.0.1:8443"),
+		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{
+					"/bar": {Proxy: "https://127.0.0.1:8443"},
+				}},
+			},
+		},
+	})
+	add(step{
+		command: cmd("bar proxy 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"),
+		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: "https+insecure://127.0.0.1:3001"},
+				}},
+			},
+		},
+	})
+	add(step{reset: true})
+	add(step{
+		command: cmd("/foo proxy 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{
+					"/foo": {Proxy: "http://127.0.0.1:3000"},
+				}},
+			},
+		},
+	})
+	add(step{ // test a second handler on the same port
+		command: cmd("--serve-port=8443 /foo proxy localhost:3000"),
+		want: &ipn.ServeConfig{
+			TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
+			Web: map[ipn.HostPort]*ipn.WebServerConfig{
+				"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
+					"/foo": {Proxy: "http://127.0.0.1:3000"},
+				}},
+				"foo.test.ts.net:8443": {Handlers: map[string]*ipn.HTTPHandler{
+					"/foo": {Proxy: "http://127.0.0.1:3000"},
+				}},
+			},
+		},
+	})
+
+	// tcp
+	add(step{reset: true})
+	add(step{
+		command: cmd("tcp 5432"),
+		want: &ipn.ServeConfig{
+			TCP: map[uint16]*ipn.TCPPortHandler{
+				443: {TCPForward: "127.0.0.1:5432"},
+			},
+		},
+	})
+	add(step{
+		command: cmd("tcp -terminate-tls 8443"),
+		want: &ipn.ServeConfig{
+			TCP: map[uint16]*ipn.TCPPortHandler{
+				443: {
+					TCPForward:   "127.0.0.1:8443",
+					TerminateTLS: "foo.test.ts.net",
+				},
+			},
+		},
+	})
+	add(step{
+		command: cmd("tcp -terminate-tls 8443"),
+		want:    nil, // nothing to save
+	})
+	add(step{
+		command: cmd("tcp --terminate-tls 8444"),
+		want: &ipn.ServeConfig{
+			TCP: map[uint16]*ipn.TCPPortHandler{
+				443: {
+					TCPForward:   "127.0.0.1:8444",
+					TerminateTLS: "foo.test.ts.net",
+				},
+			},
+		},
+	})
+	add(step{
+		command: cmd("tcp -terminate-tls=false 8445"),
+		want: &ipn.ServeConfig{
+			TCP: map[uint16]*ipn.TCPPortHandler{
+				443: {TCPForward: "127.0.0.1:8445"},
+			},
+		},
+	})
+	add(step{reset: true})
+	add(step{
+		command: cmd("tcp 123"),
+		want: &ipn.ServeConfig{
+			TCP: map[uint16]*ipn.TCPPortHandler{
+				443: {TCPForward: "127.0.0.1:123"},
+			},
+		},
+	})
+	add(step{
+		command: cmd("--remove tcp 321"),
+		wantErr: anyErr(),
+	}) // handler doesn't exist, so we get an error
+	add(step{
+		command: cmd("--remove tcp 123"),
+		want:    &ipn.ServeConfig{},
+	})
+
+	// text
+	add(step{reset: true})
+	add(step{
+		command: cmd("/ text hello"),
+		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{
+					"/": {Text: "hello"},
+				}},
+			},
+		},
+	})
+
+	// path
+	td := t.TempDir()
+	writeFile := func(suffix, contents string) {
+		if err := os.WriteFile(filepath.Join(td, suffix), []byte(contents), 0600); err != nil {
+			t.Fatal(err)
+		}
+	}
+	add(step{reset: true})
+	writeFile("foo", "this is foo")
+	add(step{
+		command: cmd("/ path " + filepath.Join(td, "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{
+					"/": {Path: filepath.Join(td, "foo")},
+				}},
+			},
+		},
+	})
+	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")),
+		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{
+					"/":           {Path: filepath.Join(td, "foo")},
+					"/some/where": {Path: filepath.Join(td, "subdir/file-a")},
+				}},
+			},
+		},
 	})
 	add(step{
-		command: cmd("ingress"),
+		command: cmd("/ path missing"),
+		wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
+	})
+	add(step{reset: true})
+	add(step{
+		command: cmd("/ path " + filepath.Join(td, "subdir")),
+		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{
+					"/": {Path: filepath.Join(td, "subdir/")},
+				}},
+			},
+		},
+	})
+	add(step{
+		command: cmd("--remove /"),
+		want:    &ipn.ServeConfig{},
+	})
+
+	// combos
+	add(step{reset: true})
+	add(step{
+		command: cmd("/ proxy 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{
+		command: cmd("funnel on"),
+		want: &ipn.ServeConfig{
+			AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
+			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{ // serving on secondary port doesn't change funnel
+		command: cmd("--serve-port=8443 /bar proxy 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}},
+			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:8443": {Handlers: map[string]*ipn.HTTPHandler{
+					"/bar": {Proxy: "http://127.0.0.1:3001"},
+				}},
+			},
+		},
+	})
+	add(step{ // turn funnel on for secondary port
+		command: cmd("--serve-port=8443 funnel 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}},
+			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:8443": {Handlers: map[string]*ipn.HTTPHandler{
+					"/bar": {Proxy: "http://127.0.0.1:3001"},
+				}},
+			},
+		},
+	})
+	add(step{ // turn funnel off for primary port 443
+		command: cmd("funnel 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}},
+			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:8443": {Handlers: map[string]*ipn.HTTPHandler{
+					"/bar": {Proxy: "http://127.0.0.1:3001"},
+				}},
+			},
+		},
+	})
+	add(step{ // remove secondary port
+		command: cmd("--serve-port=8443 --remove /bar"),
+		want: &ipn.ServeConfig{
+			AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
+			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{ // start a tcp forwarder on 8443
+		command: cmd("--serve-port=8443 tcp 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"}},
+			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{ // remove primary port http handler
+		command: cmd("--remove /"),
+		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"),
+		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"),
+		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")),
+		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{
+					"/dir/": {Path: filepath.Join(td, "subdir/")},
+				}},
+			},
+		},
+	})
+	add(step{ // this should overwrite the previous one
+		command: cmd("/dir path " + filepath.Join(td, "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{
+					"/dir": {Path: filepath.Join(td, "foo")},
+				}},
+			},
+		},
+	})
+	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")),
+		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{
+					"/dir": {Path: filepath.Join(td, "foo")},
+				}},
+			},
+		},
+	})
+	add(step{ // this should overwrite the previous one
+		command: cmd("/dir path " + filepath.Join(td, "subdir")),
+		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{
+					"/dir/": {Path: filepath.Join(td, "subdir/")},
+				}},
+			},
+		},
+	})
+
+	// 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"),
+		want: &ipn.ServeConfig{
+			TCP: map[uint16]*ipn.TCPPortHandler{
+				443: {TCPForward: "127.0.0.1:5432"},
+			},
+		},
+	})
+	add(step{ // try to start a web handler on the same port
+		command: cmd("/ proxy 3000"),
+		wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
+	})
+	add(step{reset: true})
+	add(step{ // start a web handler on port 443
+		command: cmd("/ proxy 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{ // try to start a tcp forwarder on the same port
+		command: cmd("tcp 5432"),
 		wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
 	})
 
@@ -72,6 +600,14 @@ func TestServeConfigMutations(t *testing.T) {
 		e := &serveEnv{
 			testFlagOut: &flagOut,
 			testStdout:  &stdout,
+			testGetLocalClientStatus: func(context.Context) (*ipnstate.Status, error) {
+				return &ipnstate.Status{
+					Self: &ipnstate.PeerStatus{
+						DNSName:      "foo.test.ts.net",
+						Capabilities: []string{tailcfg.NodeAttrFunnel},
+					},
+				}, nil
+			},
 			testGetServeConfig: func(context.Context) (*ipn.ServeConfig, error) {
 				return current, nil
 			},
@@ -100,6 +636,11 @@ func TestServeConfigMutations(t *testing.T) {
 		if !reflect.DeepEqual(newState, st.want) {
 			t.Fatalf("[%d] %v: bad state. got:\n%s\n\nwant:\n%s\n",
 				i, st.command, asJSON(newState), asJSON(st.want))
+			// NOTE: asJSON will omit empty fields, which might make
+			// result in bad state got/want diffs being the same, even
+			// though the actual state is different. Use below to debug:
+			// t.Fatalf("[%d] %v: bad state. got:\n%+v\n\nwant:\n%+v\n",
+			// i, st.command, newState, st.want)
 		}
 		if newState != nil {
 			current = newState
@@ -121,6 +662,15 @@ func exactErr(want error, optName ...string) func(error) string {
 	}
 }
 
+// anyErr returns an error checker that wants any error.
+func anyErr() func(error) string {
+	return func(got error) string {
+		return ""
+	}
+}
+
 func cmd(s string) []string {
-	return strings.Fields(s)
+	cmds := strings.Fields(s)
+	fmt.Printf("cmd: %v", cmds)
+	return cmds
 }

+ 7 - 7
ipn/ipn_clone.go

@@ -76,10 +76,10 @@ func (src *ServeConfig) Clone() *ServeConfig {
 			dst.Web[k] = v.Clone()
 		}
 	}
-	if dst.AllowIngress != nil {
-		dst.AllowIngress = map[HostPort]bool{}
-		for k, v := range src.AllowIngress {
-			dst.AllowIngress[k] = v
+	if dst.AllowFunnel != nil {
+		dst.AllowFunnel = map[HostPort]bool{}
+		for k, v := range src.AllowFunnel {
+			dst.AllowFunnel[k] = v
 		}
 	}
 	return dst
@@ -87,9 +87,9 @@ func (src *ServeConfig) Clone() *ServeConfig {
 
 // A compilation failure here means this code must be regenerated, with the command at the top of this file.
 var _ServeConfigCloneNeedsRegeneration = ServeConfig(struct {
-	TCP          map[uint16]*TCPPortHandler
-	Web          map[HostPort]*WebServerConfig
-	AllowIngress map[HostPort]bool
+	TCP         map[uint16]*TCPPortHandler
+	Web         map[HostPort]*WebServerConfig
+	AllowFunnel map[HostPort]bool
 }{})
 
 // Clone makes a deep copy of TCPPortHandler.

+ 5 - 5
ipn/ipn_view.go

@@ -176,15 +176,15 @@ func (v ServeConfigView) Web() views.MapFn[HostPort, *WebServerConfig, WebServer
 	})
 }
 
-func (v ServeConfigView) AllowIngress() views.Map[HostPort, bool] {
-	return views.MapOf(v.ж.AllowIngress)
+func (v ServeConfigView) AllowFunnel() views.Map[HostPort, bool] {
+	return views.MapOf(v.ж.AllowFunnel)
 }
 
 // A compilation failure here means this code must be regenerated, with the command at the top of this file.
 var _ServeConfigViewNeedsRegeneration = ServeConfig(struct {
-	TCP          map[uint16]*TCPPortHandler
-	Web          map[HostPort]*WebServerConfig
-	AllowIngress map[HostPort]bool
+	TCP         map[uint16]*TCPPortHandler
+	Web         map[HostPort]*WebServerConfig
+	AllowFunnel map[HostPort]bool
 }{})
 
 // View returns a readonly view of TCPPortHandler.

+ 2 - 2
ipn/ipnlocal/local.go

@@ -2236,13 +2236,13 @@ func (b *LocalBackend) SetPrefs(newp *ipn.Prefs) {
 // optimization hint to know primarily which nodes are NOT using ingress, to
 // avoid doing work for regular nodes.
 //
-// Even if the user's ServeConfig.AllowIngress map was manually edited in raw
+// Even if the user's ServeConfig.AllowFunnel map was manually edited in raw
 // mode and contains map entries with false values, sending true (from Len > 0)
 // is still fine. This is only an optimization hint for the control plane and
 // doesn't affect security or correctness. And we also don't expect people to
 // modify their ServeConfig in raw mode.
 func (b *LocalBackend) wantIngressLocked() bool {
-	return b.serveConfig.Valid() && b.serveConfig.AllowIngress().Len() > 0
+	return b.serveConfig.Valid() && b.serveConfig.AllowFunnel().Len() > 0
 }
 
 // setPrefsLockedOnEntry requires b.mu be held to call it, but it

+ 1 - 1
ipn/ipnlocal/serve.go

@@ -234,7 +234,7 @@ func (b *LocalBackend) HandleIngressTCPConn(ingressPeer *tailcfg.Node, target ip
 		return
 	}
 
-	if !sc.AllowIngress().Get(target) {
+	if !sc.AllowFunnel().Get(target) {
 		b.logf("localbackend: got ingress conn for unconfigured %q; rejecting", target)
 		sendRST()
 		return

+ 3 - 3
ipn/store.go

@@ -108,9 +108,9 @@ type ServeConfig struct {
 	// keyed by mount point ("/", "/foo", etc)
 	Web map[HostPort]*WebServerConfig `json:",omitempty"`
 
-	// AllowIngress is the set of SNI:port values for which ingress
+	// AllowFunnel is the set of SNI:port values for which funnel
 	// traffic is allowed, from trusted ingress peers.
-	AllowIngress map[HostPort]bool `json:",omitempty"`
+	AllowFunnel map[HostPort]bool `json:",omitempty"`
 }
 
 // HostPort is an SNI name and port number, joined by a colon.
@@ -119,7 +119,7 @@ type HostPort string
 
 // WebServerConfig describes a web server's configuration.
 type WebServerConfig struct {
-	Handlers map[string]*HTTPHandler
+	Handlers map[string]*HTTPHandler // mountPoint => handler
 }
 
 // TCPPortHandler describes what to do when handling a TCP

+ 5 - 0
tailcfg/tailcfg.go

@@ -1682,6 +1682,11 @@ const (
 	CapabilityIngress = "https://tailscale.com/cap/ingress"
 )
 
+const (
+	// NodeAttrFunnel grants the ability for a node to host ingress traffic.
+	NodeAttrFunnel = "funnel"
+)
+
 // SetDNSRequest is a request to add a DNS record.
 //
 // This is used for ACME DNS-01 challenges (so people can use