From a97369f09793a9b942f3ca87f0d3135d8539cbcf Mon Sep 17 00:00:00 2001 From: shayne Date: Thu, 17 Nov 2022 16:09:43 -0500 Subject: [PATCH] cmd/tailscale/cli: flesh out serve CLI and tests (#6304) Signed-off-by: Shayne Sweeney --- cmd/tailscale/cli/cli.go | 5 +- cmd/tailscale/cli/serve.go | 646 ++++++++++++++++++++++++++++++-- cmd/tailscale/cli/serve_test.go | 568 +++++++++++++++++++++++++++- ipn/ipn_clone.go | 14 +- ipn/ipn_view.go | 10 +- ipn/ipnlocal/local.go | 4 +- ipn/ipnlocal/serve.go | 2 +- ipn/store.go | 6 +- tailcfg/tailcfg.go | 5 + 9 files changed, 1200 insertions(+), 60 deletions(-) diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 866036df0..6845cbd93 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -190,11 +190,10 @@ func Run(args []string) (err error) { 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 @@ func Run(args []string) (err error) { 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) diff --git a/cmd/tailscale/cli/serve.go b/cmd/tailscale/cli/serve.go index 0f6482179..fe24d4e9e 100644 --- a/cmd/tailscale/cli/serve.go +++ b/cmd/tailscale/cli/serve.go @@ -7,14 +7,27 @@ 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 @@ // 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} ", - 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] {proxy|path|text} + serve [flags] [sub-flags] `), + 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 (e *serveEnv) runServeTCP(ctx context.Context, args []string) error { - panic("TODO") +func httpHandlerExists(sc *ipn.ServeConfig, hp ipn.HostPort, mount string) bool { + h := getHTTPHandler(sc, hp, mount) + return h != nil } -func (e *serveEnv) runServeIngress(ctx context.Context, args []string) error { +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 { + 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 +} + +// 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 + } } - return e.setServeConfig(ctx, sc) + if err := e.setServeConfig(ctx, sc); err != nil { + return err + } + return nil +} + +func isFunnelOn(sc *ipn.ServeConfig, hp ipn.HostPort) bool { + return sc != nil && sc.AllowFunnel[hp] } diff --git a/cmd/tailscale/cli/serve_test.go b/cmd/tailscale/cli/serve_test.go index 50a4dd21b..d3a271ce3 100644 --- a/cmd/tailscale/cli/serve_test.go +++ b/cmd/tailscale/cli/serve_test.go @@ -9,14 +9,46 @@ "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 @@ type step struct { 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("ingress off"), + command: cmd("funnel off"), want: nil, // nothing to save }) add(step{ - command: cmd("ingress"), + 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("--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("/ 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 @@ type step struct { 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 @@ type step struct { 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 { } } -func cmd(s string) []string { - return strings.Fields(s) +// 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 { + cmds := strings.Fields(s) + fmt.Printf("cmd: %v", cmds) + return cmds } diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index 51d855fe6..8971b7b90 100644 --- a/ipn/ipn_clone.go +++ b/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. diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 44260ad07..c117148da 100644 --- a/ipn/ipn_view.go +++ b/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. diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 1e6625655..1de7418b6 100644 --- a/ipn/ipnlocal/local.go +++ b/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 diff --git a/ipn/ipnlocal/serve.go b/ipn/ipnlocal/serve.go index b84259957..caac249f1 100644 --- a/ipn/ipnlocal/serve.go +++ b/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 diff --git a/ipn/store.go b/ipn/store.go index caf863a81..4420a8e54 100644 --- a/ipn/store.go +++ b/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 ServeConfig struct { // 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 diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 580af8c7f..36fba7d05 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -1682,6 +1682,11 @@ type Oauth2Token struct { 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