// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package cli import ( "bytes" "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 TestCheckHasAccess(t *testing.T) { tests := []struct { caps []string wantErr bool }{ {[]string{}, true}, // No "funnel" attribute {[]string{tailcfg.CapabilityWarnFunnelNoInvite}, true}, {[]string{tailcfg.CapabilityWarnFunnelNoHTTPS}, true}, {[]string{tailcfg.NodeAttrFunnel}, false}, } for _, tt := range tests { err := checkHasAccess(tt.caps) switch { case err != nil && tt.wantErr, err == nil && !tt.wantErr: continue case tt.wantErr: t.Fatalf("got no error, want error") case !tt.wantErr: t.Fatalf("got error %v, want no error", err) } } } func TestServeConfigMutations(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 } var steps []step add := func(s step) { _, _, s.line, _ = runtime.Caller(1) steps = append(steps, s) } // funnel add(step{reset: true}) add(step{ command: cmd("funnel on"), want: &ipn.ServeConfig{AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}}, }) add(step{ command: cmd("funnel on"), want: nil, // nothing to save }) add(step{ command: cmd("funnel off"), want: &ipn.ServeConfig{}, }) add(step{ command: cmd("funnel off"), want: nil, // nothing to save }) add(step{ command: cmd("funnel"), wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"), }) // https add(step{reset: true}) add(step{ command: cmd("/ proxy 0"), // invalid port, too low wantErr: anyErr(), }) add(step{ command: cmd("/ proxy 65536"), // invalid port, too high wantErr: anyErr(), }) add(step{ command: cmd("/ proxy somehost"), // invalid host wantErr: anyErr(), }) add(step{ command: cmd("/ proxy http://otherhost"), // invalid host wantErr: anyErr(), }) add(step{ command: cmd("/ proxy httpz://127.0.0.1"), // invalid scheme wantErr: anyErr(), }) add(step{ command: cmd("/ proxy 3000"), want: &ipn.ServeConfig{ TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}}, Web: map[ipn.HostPort]*ipn.WebServerConfig{ "foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{ "/": {Proxy: "http://127.0.0.1:3000"}, }}, }, }, }) add(step{ // invalid port command: cmd("--serve-port=9999 /abc proxy 3001"), wantErr: anyErr(), }) 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 serve port (443 default) command: cmd("tcp 5432"), wantErr: anyErr(), }) lc := &fakeLocalServeClient{} // And now run the steps above. for i, st := range steps { 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 cmd := newServeCommand(e) err := cmd.ParseAndRun(context.Background(), st.command) 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%s\n\nwant:\n%s\n", i, st.command, asJSON(got), 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) } } } // fakeLocalServeClient is a fake tailscale.LocalClient for tests. // It's not a full implementation, just enough to test the serve command. // // The fake client is stateful, and is used to test manipulating // ServeConfig state. This implementation cannot be used concurrently. type fakeLocalServeClient struct { config *ipn.ServeConfig setCount int // counts calls to SetServeConfig } // fakeStatus is a fake ipnstate.Status value for tests. // It's not a full implementation, just enough to test the serve command. // // It returns a state that's running, with a fake DNSName and the Funnel // node attribute capability. var fakeStatus = &ipnstate.Status{ BackendState: ipn.Running.String(), Self: &ipnstate.PeerStatus{ DNSName: "foo.test.ts.net", Capabilities: []string{tailcfg.NodeAttrFunnel}, }, } func (lc *fakeLocalServeClient) Status(ctx context.Context) (*ipnstate.Status, error) { return fakeStatus, nil } func (lc *fakeLocalServeClient) GetServeConfig(ctx context.Context) (*ipn.ServeConfig, error) { return lc.config.Clone(), nil } func (lc *fakeLocalServeClient) SetServeConfig(ctx context.Context, config *ipn.ServeConfig) error { lc.setCount += 1 lc.config = config.Clone() return nil } // exactError returns an error checker that wants exactly the provided want error. // If optName is non-empty, it's used in the error message. func exactErr(want error, optName ...string) func(error) string { return func(got error) string { if got == want { return "" } if len(optName) > 0 { return fmt.Sprintf("got error %v, want %v", got, optName[0]) } return fmt.Sprintf("got error %v, want %v", got, want) } } // 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 }