From 70a9854b390ba0593d827ed41331ac68e876c0ce Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Thu, 7 Sep 2023 15:07:53 -0700 Subject: [PATCH] cmd/tailscale: add background mode to serve/funnel wip (#9202) > **Note** > Behind the `TAILSCALE_FUNNEL_DEV` flag * Expose additional listeners through flags * Add a --bg flag to run in the background * --set-path to set a path for a specific target (assumes running in background) See the parent issue for more context. Updates #8489 Signed-off-by: Tyler Smalley --- cmd/tailscale/cli/funnel.go | 2 +- cmd/tailscale/cli/serve.go | 13 +- cmd/tailscale/cli/serve_dev.go | 600 ++++++++++++++++++-- cmd/tailscale/cli/serve_dev_test.go | 848 ++++++++++++++++++++++++++++ 4 files changed, 1421 insertions(+), 42 deletions(-) create mode 100644 cmd/tailscale/cli/serve_dev_test.go diff --git a/cmd/tailscale/cli/funnel.go b/cmd/tailscale/cli/funnel.go index c745d5088..52180a1ed 100644 --- a/cmd/tailscale/cli/funnel.go +++ b/cmd/tailscale/cli/funnel.go @@ -27,7 +27,7 @@ // implementation of the tailscale funnel command. // See https://github.com/tailscale/tailscale/issues/7844 if envknob.UseWIPCode() { - return newServeDevCommand(se, "funnel") + return newServeDevCommand(se, funnel) } return newFunnelCommand(se) } diff --git a/cmd/tailscale/cli/serve.go b/cmd/tailscale/cli/serve.go index ab1a16913..03df9d6da 100644 --- a/cmd/tailscale/cli/serve.go +++ b/cmd/tailscale/cli/serve.go @@ -39,7 +39,7 @@ // implementation of the tailscale funnel command. // See https://github.com/tailscale/tailscale/issues/7844 if envknob.UseWIPCode() { - return newServeDevCommand(se, "serve") + return newServeDevCommand(se, serve) } return newServeCommand(se) } @@ -158,9 +158,18 @@ type localServeClient interface { // // It also contains the flags, as registered with newServeCommand. type serveEnv struct { - // flags + // v1 flags json bool // output JSON (status only for now) + // v2 specific flags + bg bool // background mode + setPath string // serve path + https string // HTTP port + http string // HTTP port + tcp string // TCP port + tlsTerminatedTcp string // a TLS terminated TCP port + subcmd serveMode // subcommand + lc localServeClient // localClient interface, specific to serve // optional stuff for tests: diff --git a/cmd/tailscale/cli/serve_dev.go b/cmd/tailscale/cli/serve_dev.go index 06ef559b9..d67938789 100644 --- a/cmd/tailscale/cli/serve_dev.go +++ b/cmd/tailscale/cli/serve_dev.go @@ -9,61 +9,110 @@ "flag" "fmt" "log" + "net" + "net/url" "os" "os/signal" + "path/filepath" + "slices" + "sort" "strconv" "strings" "github.com/peterbourgon/ff/v3/ffcli" "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" "tailscale.com/util/mak" + "tailscale.com/version" ) type execFunc func(ctx context.Context, args []string) error type commandInfo struct { + Name string ShortHelp string LongHelp string } -var infoMap = map[string]commandInfo{ - "serve": { +var serveHelpCommon = strings.TrimSpace(` + can be a port number (e.g., 3000), a partial URL (e.g., localhost:3000), or a +full URL including a path (e.g., http://localhost:3000/foo, https+insecure://localhost:3000/foo). + +EXAMPLES + - Mount a local web server at 127.0.0.1:3000 in the foreground: + $ tailscale %s localhost:3000 + + - Mount a local web server at 127.0.0.1:3000 in the background: + $ tailscale %s --bg localhost:3000 + +For more examples and use cases visit our docs site https://tailscale.com/kb/1247/funnel-serve-use-cases +`) + +type serveMode int + +const ( + serve serveMode = iota + funnel +) + +var infoMap = map[serveMode]commandInfo{ + serve: { + Name: "serve", ShortHelp: "Serve content and local servers on your tailnet", LongHelp: strings.Join([]string{ - "Serve lets you share a local server securely within your tailnet.", - `To share a local server on the internet, use "tailscale funnel"`, + "Serve enables you to share a local server securely within your tailnet.\n", + "To share a local server on the internet, use `tailscale funnel`\n\n", }, "\n"), }, - "funnel": { + funnel: { + Name: "funnel", ShortHelp: "Serve content and local servers on the internet", LongHelp: strings.Join([]string{ - "Funnel lets you share a local server on the internet using Tailscale.", - `To share only within your tailnet, use "tailscale serve"`, + "Funnel enables you to share a local server on the internet using Tailscale.\n", + "To share only within your tailnet, use `tailscale serve`\n\n", }, "\n"), }, } +func buildShortUsage(subcmd string) string { + return strings.Join([]string{ + subcmd + " [flags] [off]", + subcmd + " status [--json]", + subcmd + " reset", + }, "\n ") +} + // newServeDevCommand returns a new "serve" subcommand using e as its environment. -func newServeDevCommand(e *serveEnv, subcmd string) *ffcli.Command { - if subcmd != "serve" && subcmd != "funnel" { +func newServeDevCommand(e *serveEnv, subcmd serveMode) *ffcli.Command { + if subcmd != serve && subcmd != funnel { log.Fatalf("newServeDevCommand called with unknown subcmd %q", subcmd) } info := infoMap[subcmd] return &ffcli.Command{ - Name: subcmd, + Name: info.Name, ShortHelp: info.ShortHelp, ShortUsage: strings.Join([]string{ - fmt.Sprintf("%s ", subcmd), - fmt.Sprintf("%s status [--json]", subcmd), - fmt.Sprintf("%s reset", subcmd), + fmt.Sprintf("%s ", info.Name), + fmt.Sprintf("%s status [--json]", info.Name), + fmt.Sprintf("%s reset", info.Name), }, "\n "), - LongHelp: info.LongHelp, - Exec: e.runServeDev(subcmd == "funnel"), + LongHelp: info.LongHelp + fmt.Sprintf(strings.TrimSpace(serveHelpCommon), subcmd, subcmd), + Exec: e.runServeCombined(subcmd), + + FlagSet: e.newFlags("serve-set", func(fs *flag.FlagSet) { + fs.BoolVar(&e.bg, "bg", false, "run the command in the background") + fs.StringVar(&e.setPath, "set-path", "", "set a path for a specific target and run in the background") + fs.StringVar(&e.https, "https", "", "default; HTTPS listener") + fs.StringVar(&e.http, "http", "", "HTTP listener") + fs.StringVar(&e.tcp, "tcp", "", "TCP listener") + fs.StringVar(&e.tlsTerminatedTcp, "tls-terminated-tcp", "", "TLS terminated TCP listener") + + }), UsageFunc: usageFunc, Subcommands: []*ffcli.Command{ - // TODO(tyler+marwan-at-work) Implement set, unset, and logs subcommands { Name: "status", Exec: e.runServeStatus, @@ -84,52 +133,97 @@ func newServeDevCommand(e *serveEnv, subcmd string) *ffcli.Command { } } -// runServeDev is the entry point for the "tailscale {serve,funnel}" commands. -func (e *serveEnv) runServeDev(funnel bool) execFunc { +// runServeCombined is the entry point for the "tailscale {serve,funnel}" commands. +func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc { + e.subcmd = subcmd + return func(ctx context.Context, args []string) error { - ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) - defer cancel() - if len(args) != 1 { + if len(args) == 0 { return flag.ErrHelp } - var source string - port64, err := strconv.ParseUint(args[0], 10, 16) - if err == nil { - source = fmt.Sprintf("http://127.0.0.1:%d", port64) - } else { - source, err = expandProxyTarget(args[0]) - } + + funnel := subcmd == funnel + + err := checkLegacyServeInvocation(subcmd, args) if err != nil { - return err + fmt.Fprintf(os.Stderr, "error: the CLI for serve and funnel has changed.\n") + fmt.Fprintf(os.Stderr, "Please see https://tailscale.com/kb/1242/tailscale-serve for more information.\n\n") + + return errHelp } + if len(args) > 2 { + fmt.Fprintf(os.Stderr, "error: invalid number of arguments (%d)\n\n", len(args)) + return errHelp + } + + turnOff := "off" == args[len(args)-1] + + // support passing in a port number as the target + // TODO(tylersmalley) move to expandProxyTarget when we remove the legacy serve invocation + target := args[0] + port, err := strconv.ParseUint(args[0], 10, 16) + if err == nil { + target = fmt.Sprintf("http://127.0.0.1:%d", port) + } + + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) + defer cancel() + st, err := e.getLocalClientStatusWithoutPeers(ctx) if err != nil { return fmt.Errorf("getting client status: %w", err) } if funnel { + // verify node has funnel capabilities if err := e.verifyFunnelEnabled(ctx, st, 443); err != nil { return err } } + // default mount point to "/" + mount := e.setPath + if mount == "" { + mount = "/" + } + + if e.bg || turnOff || e.setPath != "" { + srvType, srvPort, err := srvTypeAndPortFromFlags(e) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n\n", err) + return errHelp + } + + if turnOff { + err := e.unsetServe(ctx, srvType, srvPort, mount) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n\n", err) + return errHelp + } + return nil + } + + err = e.setServe(ctx, st, srvType, srvPort, mount, target, funnel) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n\n", err) + return errHelp + } + + return nil + } + dnsName := strings.TrimSuffix(st.Self.DNSName, ".") hp := ipn.HostPort(dnsName + ":443") // TODO(marwan-at-work): support the 2 other ports - // In the streaming case, the process stays running in the - // foreground and prints out connections to the HostPort. - // - // The local backend handles updating the ServeConfig as - // necessary, then restores it to its original state once - // the process's context is closed or the client turns off - // Tailscale. - // TODO(tyler+marwan-at-work) support flag to run in the background + // TODO(marwan-at-work): combine this with the above setServe code. + // Foreground and background should be the same, we just pass + // a foreground config instead of the top level background one. return e.streamServe(ctx, ipn.ServeStreamRequest{ Funnel: funnel, HostPort: hp, - Source: source, - MountPoint: "/", // TODO(marwan-at-work): support multiple mount points + Source: target, + MountPoint: mount, }) } } @@ -188,3 +282,431 @@ func setHandler(sc *ipn.ServeConfig, req ipn.ServeStreamRequest, sessionID strin }) mak.Set(&fconf.AllowFunnel, req.HostPort, true) } + +func (e *serveEnv) setServe(ctx context.Context, st *ipnstate.Status, srvType string, srvPort uint16, mount string, target string, allowFunnel bool) error { + if srvType == "https" { + // Running serve with https requires that the tailnet has enabled + // https cert provisioning. Send users through an interactive flow + // to enable this if not already done. + // + // TODO(sonia,tailscale/corp#10577): The interactive feature flow + // is behind a control flag. If the tailnet doesn't have the flag + // on, enableFeatureInteractive will error. For now, we hide that + // error and maintain the previous behavior (prior to 2023-08-15) + // of letting them edit the serve config before enabling certs. + e.enableFeatureInteractive(ctx, "serve", func(caps []string) bool { + return slices.Contains(caps, tailcfg.CapabilityHTTPS) + }) + } + + // get serve config + sc, err := e.lc.GetServeConfig(ctx) + if err != nil { + return err + } + + dnsName, err := e.getSelfDNSName(ctx) + if err != nil { + return err + } + + // nil if no config + if sc == nil { + sc = new(ipn.ServeConfig) + } + + // update serve config based on the type + switch srvType { + case "https", "http": + mount, err := cleanMountPoint(mount) + if err != nil { + return fmt.Errorf("failed to clean the mount point: %w", err) + } + useTLS := srvType == "https" + err = e.applyWebServe(sc, dnsName, srvPort, useTLS, mount, target) + if err != nil { + return fmt.Errorf("failed apply web serve: %w", err) + } + case "tcp", "tls-terminated-tcp": + err = e.applyTCPServe(sc, dnsName, srvType, srvPort, target) + if err != nil { + return fmt.Errorf("failed to apply TCP serve: %w", err) + } + default: + return fmt.Errorf("invalid type %q", srvType) + } + + // update the serve config based on if funnel is enabled + e.applyFunnel(sc, dnsName, srvPort, allowFunnel) + + // persist the serve config changes + if err := e.lc.SetServeConfig(ctx, sc); err != nil { + return err + } + + // notify the user of the change + m, err := e.messageForPort(ctx, sc, st, dnsName, srvPort) + if err != nil { + return err + } + + fmt.Fprintln(os.Stderr, m) + + return nil +} + +func (e *serveEnv) messageForPort(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvPort uint16) (string, error) { + var output strings.Builder + + hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort)))) + + if sc.AllowFunnel[hp] == true { + output.WriteString("Available on the internet:\n") + } else { + output.WriteString("Available within your tailnet:\n") + } + + scheme := "https" + if sc.IsServingHTTP(srvPort) { + scheme = "http" + } + + portPart := ":" + fmt.Sprint(srvPort) + if scheme == "http" && srvPort == 80 || + scheme == "https" && srvPort == 443 { + portPart = "" + } + + output.WriteString(fmt.Sprintf("%s://%s%s\n\n", scheme, dnsName, portPart)) + + 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 "", "" + } + + if sc.Web[hp] != nil { + 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) + output.WriteString(fmt.Sprintf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d)) + } + } else if sc.TCP[srvPort] != nil { + h := sc.TCP[srvPort] + + tlsStatus := "TLS over TCP" + if h.TerminateTLS != "" { + tlsStatus = "TLS terminated" + } + + output.WriteString(fmt.Sprintf("|-- tcp://%s (%s)\n", hp, tlsStatus)) + for _, a := range st.TailscaleIPs { + ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(srvPort))) + output.WriteString(fmt.Sprintf("|-- tcp://%s\n", ipp)) + } + output.WriteString(fmt.Sprintf("|--> tcp://%s\n", h.TCPForward)) + } + + output.WriteString("\nServe started and running in the background.\n") + output.WriteString(fmt.Sprintf("To disable the proxy, run: tailscale %s off", infoMap[e.subcmd].Name)) + + return output.String(), nil +} + +func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target string) error { + h := new(ipn.HTTPHandler) + + // TODO: use strings.Cut as the prefix OR use strings.HasPrefix + ts, _, _ := strings.Cut(target, ":") + switch { + case ts == "text": + text := strings.TrimPrefix(target, "text:") + if text == "" { + return errors.New("unable to serve; text cannot be an empty string") + } + h.Text = text + case isProxyTarget(target): + t, err := expandProxyTarget(target) + 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 errors.New("path serving is not supported if sandboxed on macOS") + } + if !filepath.IsAbs(target) { + return errors.New("path must be absolute") + } + target = filepath.Clean(target) + fi, err := os.Stat(target) + if err != nil { + return errors.New("invalid path") + } + + // TODO: need to understand this further + if fi.IsDir() && !strings.HasSuffix(mount, "/") { + // dir mount points must end in / + // for relative file links to work + mount += "/" + } + h.Path = target + } + + // TODO: validation needs to check nested foreground configs + if sc.IsTCPForwardingOnPort(srvPort) { + return errors.New("cannot serve web; already serving TCP") + } + + mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS}) + + hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort)))) + if _, ok := sc.Web[hp]; !ok { + mak.Set(&sc.Web, hp, new(ipn.WebServerConfig)) + } + mak.Set(&sc.Web[hp].Handlers, mount, h) + + // TODO: handle multiple web handlers from foreground mode + 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) + } + } + + return nil +} + +func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType string, srcPort uint16, target string) error { + var terminateTLS bool + switch srcType { + case "tcp": + terminateTLS = false + case "tls-terminated-tcp": + terminateTLS = true + default: + return fmt.Errorf("invalid TCP target %q", target) + } + + dstURL, err := url.Parse(target) + if err != nil { + return fmt.Errorf("invalid TCP target %q: %v", target, err) + } + host, dstPortStr, err := net.SplitHostPort(dstURL.Host) + if err != nil { + return fmt.Errorf("invalid TCP target %q: %v", target, err) + } + + switch host { + case "localhost", "127.0.0.1": + // ok + default: + return fmt.Errorf("invalid TCP target %q, must be one of localhost or 127.0.0.1", target) + } + + if p, err := strconv.ParseUint(dstPortStr, 10, 16); p == 0 || err != nil { + return fmt.Errorf("invalid port %q", dstPortStr) + } + + fwdAddr := "127.0.0.1:" + dstPortStr + + // TODO: needs to account for multiple configs from foreground mode + 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}) + + if terminateTLS { + sc.TCP[srcPort].TerminateTLS = dnsName + } + + return nil +} + +func (e *serveEnv) applyFunnel(sc *ipn.ServeConfig, dnsName string, srvPort uint16, allowFunnel bool) { + hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort)))) + + // TODO: Should we return an error? Should not be possible. + // nil if no config + if sc == nil { + sc = new(ipn.ServeConfig) + } + + // TODO: should ensure there is no other conflicting funnel + // TODO: add error handling for if toggling for existing sc + if allowFunnel { + mak.Set(&sc.AllowFunnel, hp, true) + } +} + +// TODO(tylersmalley) Refactor into setServe so handleWebServeFunnelRemove and handleTCPServeRemove. +// apply serve config changes and we print a status message. +func (e *serveEnv) unsetServe(ctx context.Context, srvType string, srvPort uint16, mount string) error { + switch srvType { + case "https", "http": + mount, err := cleanMountPoint(mount) + if err != nil { + return fmt.Errorf("failed to clean the mount point: %w", err) + } + err = e.handleWebServeFunnelRemove(ctx, srvPort, mount) + if err != nil { + return err + } + + return nil + case "tcp", "tls-terminated-tcp": + // TODO(tylersmalley) should remove funnel + return e.removeTCPServe(ctx, srvPort) + default: + return fmt.Errorf("invalid type %q", srvType) + } +} + +func srvTypeAndPortFromFlags(e *serveEnv) (srvType string, srvPort uint16, err error) { + sourceMap := map[string]string{ + "http": e.http, + "https": e.https, + "tcp": e.tcp, + "tls-terminated-tcp": e.tlsTerminatedTcp, + } + + var srcTypeCount int + var srcValue string + + for k, v := range sourceMap { + if v != "" { + srcTypeCount++ + srvType = k + srcValue = v + } + } + + if srcTypeCount > 1 { + return "", 0, fmt.Errorf("cannot serve multiple types for a single mount point") + } else if srcTypeCount == 0 { + srvType = "https" + srcValue = "443" + } + + srvPort, err = parseServePort(srcValue) + if err != nil { + return "", 0, fmt.Errorf("invalid port %q: %w", srcValue, err) + } + + return srvType, srvPort, nil +} + +func checkLegacyServeInvocation(subcmd serveMode, args []string) error { + if subcmd == serve && len(args) == 2 { + prefixes := []string{"http:", "https:", "tls:", "tls-terminated-tcp:"} + + for _, prefix := range prefixes { + if strings.HasPrefix(args[0], prefix) { + return errors.New("invalid invocation") + } + } + } + + return nil +} + +// handleWebServeFunnelRemove removes a web handler from the serve config +// and removes funnel if no remaining mounts exist for the serve port. +// The srvPort argument is the serving port and the mount argument is +// the mount point or registered path to remove. +// TODO(tylersmalley): fork of handleWebServeRemove, return name once dev work is merged +func (e *serveEnv) handleWebServeFunnelRemove(ctx context.Context, srvPort uint16, mount string) error { + sc, err := e.lc.GetServeConfig(ctx) + if err != nil { + return err + } + if sc == nil { + return errors.New("error: serve config does not exist") + } + dnsName, err := e.getSelfDNSName(ctx) + if err != nil { + return err + } + if sc.IsTCPForwardingOnPort(srvPort) { + return errors.New("cannot remove web handler; currently serving TCP") + } + hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort)))) + if !sc.WebHandlerExists(hp, mount) { + return errors.New("error: handler 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 + } + + // disable funnel if no remaining mounts exist for the serve port + if sc.Web == nil && sc.TCP == nil { + delete(sc.AllowFunnel, hp) + } + + if err := e.lc.SetServeConfig(ctx, sc); err != nil { + return err + } + + return nil +} + +// removeTCPServe removes the TCP forwarding configuration for the +// given srvPort, or serving port. +func (e *serveEnv) removeTCPServe(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") +} diff --git a/cmd/tailscale/cli/serve_dev_test.go b/cmd/tailscale/cli/serve_dev_test.go new file mode 100644 index 000000000..e1181441d --- /dev/null +++ b/cmd/tailscale/cli/serve_dev_test.go @@ -0,0 +1,848 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "reflect" + "runtime" + "testing" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/ipn" + "tailscale.com/types/logger" +) + +func TestServeDevConfigMutations(t *testing.T) { + // Stateful mutations, starting from an empty config. + type step struct { + command []string // serve args; nil means no command to run (only reset) + reset bool // if true, reset all ServeConfig state + 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) { + _, _, s.line, _ = runtime.Caller(1) + steps = append(steps, s) + } + + // using port number + add(step{reset: true}) + add(step{ + command: cmd("funnel --bg 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"}, + }}, + }, + AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}, + }, + }) + + // funnel background + add(step{reset: true}) + add(step{ + command: cmd("funnel --bg 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"}, + }}, + }, + AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}, + }, + }) + + // serve background + add(step{reset: true}) + add(step{ + command: cmd("serve --bg 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"}, + }}, + }, + }, + }) + + // --set-path runs in background + add(step{reset: true}) + add(step{ + command: cmd("serve --set-path=/ 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"}, + }}, + }, + }, + }) + + // using http listener + add(step{reset: true}) + add(step{ + command: cmd("serve --bg --http=80 localhost:3000"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + }, + }, + }) + + // using https listener with a valid port + add(step{reset: true}) + add(step{ + command: cmd("serve --bg --https=8443 localhost:3000"), + 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{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + }, + }, + }) + + // https + add(step{reset: true}) + add(step{ // allow omitting port (default to 80) + command: cmd("serve --http=80 --bg http://localhost:3000"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + }, + }, + }) + add(step{ // support non Funnel port + command: cmd("serve --http=9999 --set-path=/abc http://localhost:3001"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 9999: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + "foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{ + "/abc": {Proxy: "http://127.0.0.1:3001"}, + }}, + }, + }, + }) + add(step{ + command: cmd("serve --http=9999 --set-path=/abc off"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + }, + }, + }) + add(step{ + command: cmd("serve --http=8080 --set-path=/abc http://127.0.0.1:3001"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}, 8080: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": {Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://127.0.0.1:3000"}, + }}, + "foo.test.ts.net:8080": {Handlers: map[string]*ipn.HTTPHandler{ + "/abc": {Proxy: "http://127.0.0.1:3001"}, + }}, + }, + }, + }) + + // // https + add(step{reset: true}) + add(step{ + command: cmd("serve --https=443 --bg http://localhost:0"), // invalid port, too low + wantErr: anyErr(), + }) + add(step{ + command: cmd("serve --https=443 --bg http://localhost:65536"), // invalid port, too high + wantErr: anyErr(), + }) + add(step{ + command: cmd("serve --https=443 --bg http://somehost:3000"), // invalid host + wantErr: anyErr(), + }) + add(step{ + command: cmd("serve --https=443 --bg httpz://127.0.0.1"), // invalid scheme + wantErr: anyErr(), + }) + add(step{ // allow omitting port (default to 443) + command: cmd("serve --https=443 --bg 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("serve --https=9999 --set-path=/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("serve --https=9999 --set-path=/abc off"), + 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 --https=8443 --set-path=/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{ + "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 --https=10000 --bg 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("serve --https=443 --set-path=/foo off"), + want: nil, // nothing to save + wantErr: anyErr(), + }) // handler doesn't exist, so we get an error + add(step{ + command: cmd("serve --https=10000 off"), + 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 --https=443 off"), + 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("serve --https=8443 --set-path=/abc off"), + want: &ipn.ServeConfig{}, + }) + add(step{ // clean mount: "bar" becomes "/bar" + command: cmd("serve --https=443 --set-path=bar 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("serve --https=443 --set-path=bar https://127.0.0.1:8443"), + // want: nil, // nothing to save + // }) + add(step{ // try resetting using reset command + command: cmd("serve reset"), + want: &ipn.ServeConfig{}, + }) + add(step{ + command: cmd("serve --https=443 --bg 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("serve --https=443 --set-path=/foo 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 --https=8443 --set-path=/foo 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"}, + }}, + }, + }, + }) + add(step{reset: true}) + add(step{ // support path in proxy + command: cmd("serve --https=443 --bg http://127.0.0.1:3000/foo/bar"), + 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/foo/bar"}, + }}, + }, + }, + }) + + // // tcp + add(step{reset: true}) + add(step{ // must include scheme for tcp + command: cmd("serve --tls-terminated-tcp=443 --bg localhost:5432"), + wantErr: exactErr(errHelp, "errHelp"), + }) + add(step{ // !somehost, must be localhost or 127.0.0.1 + command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:5432"), + wantErr: exactErr(errHelp, "errHelp"), + }) + add(step{ // bad target port, too low + command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:0"), + wantErr: exactErr(errHelp, "errHelp"), + }) + add(step{ // bad target port, too high + command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:65536"), + wantErr: exactErr(errHelp, "errHelp"), + }) + add(step{ + command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 443: { + TCPForward: "127.0.0.1:5432", + TerminateTLS: "foo.test.ts.net", + }, + }, + }, + }) + add(step{ + command: cmd("serve --tls-terminated-tcp=443 --bg tcp://127.0.0.1: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("serve --tls-terminated-tcp=443 --bg tcp://127.0.0.1:8443"), + // want: nil, // nothing to save + // }) + add(step{ + command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost: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("serve --tls-terminated-tcp=443 --bg tcp://127.0.0.1:8445"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 443: { + TCPForward: "127.0.0.1:8445", + TerminateTLS: "foo.test.ts.net", + }, + }, + }, + }) + add(step{reset: true}) + add(step{ + command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:123"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 443: { + TCPForward: "127.0.0.1:123", + TerminateTLS: "foo.test.ts.net", + }, + }, + }, + }) + add(step{ // handler doesn't exist, so we get an error + command: cmd("serve --tls-terminated-tcp=8443 off"), + wantErr: anyErr(), + }) + add(step{ + command: cmd("serve --tls-terminated-tcp=443 off"), + want: &ipn.ServeConfig{}, + }) + + // // text + add(step{reset: true}) + add(step{ + command: cmd("serve --https=443 --bg 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("serve --https=443 --bg " + 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("serve --https=443 --set-path=/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{ + "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{ // bad path + command: cmd("serve --https=443 --bg bad/path"), + wantErr: exactErr(errHelp, "errHelp"), + }) + add(step{reset: true}) + add(step{ + command: cmd("serve --https=443 --bg " + 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("serve --https=443 off"), + want: &ipn.ServeConfig{}, + }) + + // // combos + add(step{reset: true}) + add(step{ + command: cmd("serve --bg 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{ // enable funnel for primary port + command: cmd("funnel --bg localhost:3000"), + 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 on primary port + command: cmd("serve --https=8443 --set-path=/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}}, + 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("funnel --https=8443 --set-path=/bar localhost:3001"), + 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"}, + }}, + }, + }, + }) + // TODO(tylersmalley) resolve these failures + // add(step{ // turn funnel off for primary port 443 + // command: cmd("serve --https=443 --set-path=/bar localhost:3001"), + // 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("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}}, + // 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("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"}}, + // 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("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("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("funnel 8443 off"), + // want: &ipn.ServeConfig{}, + // }) + + // // tricky steps + add(step{reset: true}) + add(step{ // a directory with a trailing slash mount point + command: cmd("serve --https=443 --set-path=/dir " + 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("serve --https=443 --set-path=/dir " + 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("serve --https=443 --set-path=/dir " + 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("serve --https=443 --set-path=/dir " + 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{ // tcp forward 5432 on serve port 443 + command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"), + want: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{ + 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("serve --https=443 --bg localhost:3000"), + wantErr: exactErr(errHelp, "errHelp"), + }) + add(step{reset: true}) + add(step{ // start a web handler on port 443 + command: cmd("serve --https=443 --bg 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{ // try to start a tcp forwarder on the same serve port + command: cmd("serve --tls-terminated-tcp=443 --bg 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 + } + if st.command == nil { + continue + } + t.Logf("Executing step #%d, line %v: %q ... ", i, st.line, st.command) + + var stdout bytes.Buffer + var flagOut bytes.Buffer + e := &serveEnv{ + lc: lc, + testFlagOut: &flagOut, + testStdout: &stdout, + } + lastCount := lc.setCount + var cmd *ffcli.Command + var args []string + + mode := serve + if st.command[0] == "funnel" { + mode = funnel + } + cmd = newServeDevCommand(e, mode) + args = st.command[1:] + + err := cmd.ParseAndRun(context.Background(), args) + if flagOut.Len() > 0 { + t.Logf("flag package output: %q", flagOut.Bytes()) + } + if err != nil { + if st.wantErr == nil { + t.Fatalf("step #%d, line %v: unexpected error: %v", i, st.line, err) + } + if bad := st.wantErr(err); bad != "" { + t.Fatalf("step #%d, line %v: unexpected error: %v", i, st.line, bad) + } + continue + } + if st.wantErr != nil { + t.Fatalf("step #%d, line %v: got success (saved=%v), but wanted an error", i, st.line, lc.config != nil) + } + var got *ipn.ServeConfig = nil + if lc.setCount > lastCount { + got = lc.config + } + if !reflect.DeepEqual(got, st.want) { + t.Fatalf("[%d] %v: bad state. got:\n%v\n\nwant:\n%v\n", + i, st.command, logger.AsJSON(got), logger.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, got, st.want) + } + } +} + +func TestSrcTypeFromFlags(t *testing.T) { + tests := []struct { + name string + env *serveEnv + expectedType string + expectedPort uint16 + expectedErr bool + }{ + { + name: "only http set", + env: &serveEnv{http: "80"}, + expectedType: "http", + expectedPort: 80, + expectedErr: false, + }, + { + name: "only https set", + env: &serveEnv{https: "10000"}, + expectedType: "https", + expectedPort: 10000, + expectedErr: false, + }, + { + name: "only tcp set", + env: &serveEnv{tcp: "8000"}, + expectedType: "tcp", + expectedPort: 8000, + expectedErr: false, + }, + { + name: "only tls-terminated-tcp set", + env: &serveEnv{tlsTerminatedTcp: "8080"}, + expectedType: "tls-terminated-tcp", + expectedPort: 8080, + expectedErr: false, + }, + { + name: "defaults to https, port 443", + env: &serveEnv{}, + expectedType: "https", + expectedPort: 443, + expectedErr: false, + }, + { + name: "multiple types set", + env: &serveEnv{http: "80", https: "443"}, + expectedType: "", + expectedPort: 0, + expectedErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srcType, srcPort, err := srvTypeAndPortFromFlags(tt.env) + if (err != nil) != tt.expectedErr { + t.Errorf("Expected error: %v, got: %v", tt.expectedErr, err) + } + if srcType != tt.expectedType { + t.Errorf("Expected srcType: %s, got: %s", tt.expectedType, srcType) + } + if srcPort != tt.expectedPort { + t.Errorf("Expected srcPort: %d, got: %d", tt.expectedPort, srcPort) + } + }) + } +}