tailscale/cmd/tailscale/cli/serve_v2_test.go

1290 lines
36 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
)
func TestServeDevConfigMutations(t *testing.T) {
// step is a stateful mutation within a group
type step struct {
command []string // serve args; nil means no command to run (only reset)
want *ipn.ServeConfig // non-nil means we want a save of this value
wantErr func(error) (badErrMsg string) // nil means no error is wanted
}
// group is a group of steps that share the same
// config mutation, but always starts from an empty config
type group struct {
name string
steps []step
}
// creaet a temporary directory for path-based destinations
td := t.TempDir()
writeFile := func(suffix, contents string) {
if err := os.WriteFile(filepath.Join(td, suffix), []byte(contents), 0600); err != nil {
t.Fatal(err)
}
}
writeFile("foo", "this is foo")
err := os.MkdirAll(filepath.Join(td, "subdir"), 0700)
if err != nil {
t.Fatal(err)
}
writeFile("subdir/file-a", "this is subdir")
groups := [...]group{
{
name: "using_port_number",
steps: []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},
},
}},
},
{
name: "funnel_background",
steps: []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},
},
}},
},
{
name: "serve_background",
steps: []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"},
}},
},
},
}},
},
{
name: "set_path_bg",
steps: []step{{
command: cmd("serve --set-path=/ --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"},
}},
},
},
}},
},
{
name: "http_listener",
steps: []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"},
}},
},
},
}},
},
{
name: "https_listener_valid_port",
steps: []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"},
}},
},
},
}},
},
{
name: "multiple_http_with_off",
steps: []step{
{
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"},
}},
},
},
},
{ // support non Funnel port
command: cmd("serve --bg --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"},
}},
},
},
},
{ // turn off one handler
command: cmd("serve --bg --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 another handler
command: cmd("serve --bg --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"},
}},
},
},
},
},
},
{
name: "invalid_port_too_low",
steps: []step{{
command: cmd("serve --https=443 --bg http://localhost:0"), // invalid port, too low
wantErr: anyErr(),
}},
},
{
name: "invalid_port_too_high",
steps: []step{{
command: cmd("serve --https=443 --bg http://localhost:65536"), // invalid port, too high
wantErr: anyErr(),
}},
},
{
name: "invalid_mount_port_too_high",
steps: []step{{
command: cmd("serve --https=65536 --bg http://localhost:3000"), // invalid port, too high
wantErr: anyErr(),
}},
},
{
name: "invalid_host",
steps: []step{{
command: cmd("serve --https=443 --bg http://somehost:3000"), // invalid host
wantErr: anyErr(),
}},
},
{
name: "invalid_scheme",
steps: []step{{
command: cmd("serve --https=443 --bg httpz://127.0.0.1"), // invalid scheme
wantErr: anyErr(),
}},
},
{
name: "turn_off_https",
steps: []step{
{
command: cmd("serve --bg --https=443 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"},
}},
},
},
},
{
command: cmd("serve --bg --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"},
}},
},
},
},
{
command: cmd("serve --bg --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"},
}},
},
},
},
{
command: cmd("serve --bg --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"},
}},
},
},
},
},
},
{
name: "https_text_bg",
steps: []step{{
command: cmd("serve --bg --https=10000 text:hi"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{10000: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:10000": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Text: "hi"},
}},
},
},
}},
},
{
name: "handler_not_found",
steps: []step{{
command: cmd("serve --https=443 --set-path=/foo off"),
want: nil, // nothing to save
wantErr: anyErr(),
}},
},
{
name: "clean_mount", // "bar" becomes "/bar"
steps: []step{{
command: cmd("serve --bg --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"},
}},
},
},
}},
},
{
name: "serve_reset",
steps: []step{
{
command: cmd("serve --bg --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"},
}},
},
},
},
{
command: cmd("serve reset"),
want: &ipn.ServeConfig{},
},
},
},
{
name: "https_insecure",
steps: []step{{
command: cmd("serve --bg --https=443 https+insecure://127.0.0.1:3001"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "https+insecure://127.0.0.1:3001"},
}},
},
},
}},
},
{
name: "two_ports_same_dest",
steps: []step{
{
command: cmd("serve --bg --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"},
}},
},
},
},
{
command: cmd("serve --bg --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"},
}},
},
},
},
},
},
{
name: "path_in_dest",
steps: []step{{
command: cmd("serve --bg --https=443 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"},
}},
},
},
}},
},
{
name: "unknown_host_tcp",
steps: []step{{
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:5432"),
wantErr: exactErrMsg(errHelp),
}},
},
{
name: "tcp_port_too_low",
steps: []step{{
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:0"),
wantErr: exactErrMsg(errHelp),
}},
},
{
name: "tcp_port_too_high",
steps: []step{{
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://somehost:65536"),
wantErr: exactErrMsg(errHelp),
}},
},
{
name: "tcp_shorthand",
steps: []step{{
command: cmd("serve --tls-terminated-tcp=443 --bg 5432"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
TCPForward: "127.0.0.1:5432",
TerminateTLS: "foo.test.ts.net",
},
},
},
}},
},
{
name: "tls_terminated_tcp",
steps: []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",
},
},
},
},
{
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",
},
},
},
},
},
},
{
name: "tcp_off",
steps: []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",
},
},
},
},
{ // handler doesn't exist
command: cmd("serve --tls-terminated-tcp=8443 off"),
wantErr: anyErr(),
},
{
command: cmd("serve --tls-terminated-tcp=443 off"),
want: &ipn.ServeConfig{},
},
},
},
{
name: "text",
steps: []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"},
}},
},
},
}},
},
{
name: "path",
steps: []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")},
}},
},
},
},
{
command: cmd("serve --bg --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")},
}},
},
},
},
},
},
{
name: "bad_path",
steps: []step{{
command: cmd("serve --bg --https=443 bad/path"),
wantErr: exactErrMsg(errHelp),
}},
},
{
name: "path_off",
steps: []step{
{
command: cmd("serve --bg --https=443 " + 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/")},
}},
},
},
},
{
command: cmd("serve --bg --https=443 off"),
want: &ipn.ServeConfig{},
},
},
},
{
name: "combos",
steps: []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"},
}},
},
},
},
{ // 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"},
}},
},
},
},
{ // serving on secondary port doesn't change funnel on primary port
command: cmd("serve --bg --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"},
}},
},
},
},
{ // turn funnel on for secondary port
command: cmd("funnel --bg --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"},
}},
},
},
},
{ // turn funnel off for primary port 443
command: cmd("serve --bg localhost:3000"),
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"},
}},
},
},
},
{ // remove secondary port
command: cmd("serve --bg --https=8443 --set-path=/bar 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"},
}},
},
},
},
{ // start a tcp forwarder on 8443
command: cmd("serve --bg --tcp=8443 tcp://localhost:5432"),
want: &ipn.ServeConfig{
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"},
}},
},
},
},
{ // remove primary port http handler
command: cmd("serve off"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "127.0.0.1:5432"}},
},
},
{ // remove tcp forwarder
command: cmd("serve --tls-terminated-tcp=8443 off"),
want: &ipn.ServeConfig{},
},
},
},
{
name: "tricky_steps",
steps: []step{
{ // a directory with a trailing slash mount point
command: cmd("serve --bg --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/")},
}},
},
},
},
{ // this should overwrite the previous one
command: cmd("serve --bg --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")},
}},
},
},
},
{ // reset and do opposite
command: cmd("serve reset"),
want: &ipn.ServeConfig{},
},
{ // a file without a trailing slash mount point
command: cmd("serve --bg --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")},
}},
},
},
},
{ // this should overwrite the previous one
command: cmd("serve --bg --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/")},
}},
},
},
},
},
},
{
name: "cannot_override_tcp_with_http",
steps: []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",
},
},
},
},
{
command: cmd("serve --https=443 --bg localhost:3000"),
wantErr: anyErr(),
},
},
},
{
name: "cannot_override_http_with_tcp",
steps: []step{
{
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"},
}},
},
},
},
{ // try to start a tcp forwarder on the same serve port
command: cmd("serve --tls-terminated-tcp=443 --bg tcp://localhost:5432"),
wantErr: anyErr(),
},
},
},
{
name: "turn_off_multiple_handlers",
steps: []step{
{
command: cmd("serve --https=4545 --set-path=/foo --bg localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{4545: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:4545": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
},
{
command: cmd("serve --https=4545 --set-path=/bar --bg localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{4545: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:4545": {Handlers: map[string]*ipn.HTTPHandler{
"/foo": {Proxy: "http://127.0.0.1:3000"},
"/bar": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
},
{
command: cmd("serve --https=4545 --bg --yes localhost:3000 off"),
want: &ipn.ServeConfig{},
},
},
},
{
name: "no_http_with_funnel",
steps: []step{
{
command: cmd("funnel --http=80 3000"),
// error parsing commandline arguments: flag provided but not defined: -http
wantErr: anyErr(),
},
},
},
{
name: "forground_with_bg_conflict",
steps: []step{
{
command: cmd("serve --bg --http=3000 localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{3000: {HTTP: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:3000": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
},
{
command: cmd("serve --http=3000 localhost:3000"),
wantErr: exactErrMsg(fmt.Errorf(backgroundExistsMsg, "serve", "http", 3000)),
},
},
},
}
for _, group := range groups {
t.Run(group.name, func(t *testing.T) {
lc := &fakeLocalServeClient{}
for i, st := range group.steps {
var stderr bytes.Buffer
var stdout bytes.Buffer
var flagOut bytes.Buffer
e := &serveEnv{
lc: lc,
testFlagOut: &flagOut,
testStdout: &stdout,
testStderr: &stderr,
}
lastCount := lc.setCount
var cmd *ffcli.Command
var args []string
mode := serve
if st.command[0] == "funnel" {
mode = funnel
}
cmd = newServeV2Command(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: unexpected error: %v", i, err)
}
if bad := st.wantErr(err); bad != "" {
t.Fatalf("step #%d: unexpected error: %v", i, bad)
}
continue
}
if st.wantErr != nil {
t.Fatalf("step #%d: got success (saved=%v), but wanted an error", i, lc.config != nil)
}
var got *ipn.ServeConfig = nil
if lc.setCount > lastCount {
got = lc.config
}
if !reflect.DeepEqual(got, st.want) {
gotbts, _ := json.MarshalIndent(got, "", "\t")
wantbts, _ := json.MarshalIndent(st.want, "", "\t")
t.Fatalf("step: %d, cmd: %v, diff:\n%s", i, st.command, cmp.Diff(string(gotbts), string(wantbts)))
}
}
})
}
}
func TestValidateConfig(t *testing.T) {
tests := [...]struct {
name string
desc string
cfg *ipn.ServeConfig
servePort uint16
serveType serveType
bg bool
wantErr bool
}{
{
name: "nil_config",
desc: "when config is nil, all requests valid",
cfg: nil,
servePort: 3000,
serveType: serveTypeHTTPS,
},
{
name: "new_bg_tcp",
desc: "no error when config exists but we're adding a new bg tcp port",
cfg: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
},
},
bg: true,
servePort: 10000,
serveType: serveTypeHTTPS,
},
{
name: "override_bg_tcp",
desc: "no error when overwriting previous port under the same serve type",
cfg: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {TCPForward: "http://localhost:4545"},
},
},
bg: true,
servePort: 443,
serveType: serveTypeTCP,
},
{
name: "override_bg_tcp",
desc: "error when overwriting previous port under a different serve type",
cfg: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
},
},
bg: true,
servePort: 443,
serveType: serveTypeHTTP,
wantErr: true,
},
{
name: "new_fg_port",
desc: "no error when serving a new foreground port",
cfg: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true},
},
Foreground: map[string]*ipn.ServeConfig{
"abc123": {
TCP: map[uint16]*ipn.TCPPortHandler{
3000: {HTTPS: true},
},
},
},
},
servePort: 4040,
serveType: serveTypeTCP,
},
{
name: "same_fg_port",
desc: "error when overwriting a previous fg port",
cfg: &ipn.ServeConfig{
Foreground: map[string]*ipn.ServeConfig{
"abc123": {
TCP: map[uint16]*ipn.TCPPortHandler{
3000: {HTTPS: true},
},
},
},
},
servePort: 3000,
serveType: serveTypeTCP,
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
se := serveEnv{bg: tc.bg}
err := se.validateConfig(tc.cfg, tc.servePort, tc.serveType)
if err == nil && tc.wantErr {
t.Fatal("expected an error but got nil")
}
if err != nil && !tc.wantErr {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}
func TestSrcTypeFromFlags(t *testing.T) {
tests := []struct {
name string
env *serveEnv
expectedType serveType
expectedPort uint16
expectedErr bool
}{
{
name: "only http set",
env: &serveEnv{http: 80},
expectedType: serveTypeHTTP,
expectedPort: 80,
expectedErr: false,
},
{
name: "only https set",
env: &serveEnv{https: 10000},
expectedType: serveTypeHTTPS,
expectedPort: 10000,
expectedErr: false,
},
{
name: "only tcp set",
env: &serveEnv{tcp: 8000},
expectedType: serveTypeTCP,
expectedPort: 8000,
expectedErr: false,
},
{
name: "only tls-terminated-tcp set",
env: &serveEnv{tlsTerminatedTCP: 8080},
expectedType: serveTypeTLSTerminatedTCP,
expectedPort: 8080,
expectedErr: false,
},
{
name: "defaults to https, port 443",
env: &serveEnv{},
expectedType: serveTypeHTTPS,
expectedPort: 443,
expectedErr: false,
},
{
name: "multiple types set",
env: &serveEnv{http: 80, https: 443},
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.String(), srcType)
}
if srcPort != tt.expectedPort {
t.Errorf("Expected srcPort: %d, got: %d", tt.expectedPort, srcPort)
}
})
}
}
func TestCleanURLPath(t *testing.T) {
tests := []struct {
input string
expected string
wantErr bool
}{
{input: "", expected: "/"},
{input: "/", expected: "/"},
{input: "/foo", expected: "/foo"},
{input: "/foo/", expected: "/foo/"},
{input: "/../bar", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
actual, err := cleanURLPath(tt.input)
if tt.wantErr == true && err == nil {
t.Errorf("Expected an error but got none")
return
}
if tt.wantErr == false && err != nil {
t.Errorf("Got an error, but didn't expect one: %v", err)
return
}
if actual != tt.expected {
t.Errorf("Got: %q; expected: %q", actual, tt.expected)
}
})
}
}
func TestMessageForPort(t *testing.T) {
tests := []struct {
name string
subcmd serveMode
serveConfig *ipn.ServeConfig
status *ipnstate.Status
dnsName string
srvType serveType
srvPort uint16
expected string
}{
{
name: "funnel-https",
subcmd: funnel,
serveConfig: &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,
},
},
status: &ipnstate.Status{},
dnsName: "foo.test.ts.net",
srvType: serveTypeHTTPS,
srvPort: 443,
expected: strings.Join([]string{
msgFunnelAvailable,
"",
"https://foo.test.ts.net/",
"|-- proxy http://127.0.0.1:3000",
"",
fmt.Sprintf(msgRunningInBackground, "Funnel"),
fmt.Sprintf(msgDisableProxy, "funnel", "https", 443),
}, "\n"),
},
{
name: "serve-http",
subcmd: serve,
serveConfig: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {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"},
},
},
},
},
status: &ipnstate.Status{},
dnsName: "foo.test.ts.net",
srvType: serveTypeHTTP,
srvPort: 80,
expected: strings.Join([]string{
msgServeAvailable,
"",
"https://foo.test.ts.net:80/",
"|-- proxy http://127.0.0.1:3000",
"",
fmt.Sprintf(msgRunningInBackground, "Serve"),
fmt.Sprintf(msgDisableProxy, "serve", "http", 80),
}, "\n"),
},
}
for _, tt := range tests {
e := &serveEnv{bg: true, subcmd: tt.subcmd}
t.Run(tt.name, func(t *testing.T) {
actual := e.messageForPort(tt.serveConfig, tt.status, tt.dnsName, tt.srvType, tt.srvPort)
if actual == "" {
t.Errorf("Got empty message")
}
if actual != tt.expected {
t.Errorf("\nGot: %q\nExpected: %q", actual, tt.expected)
}
})
}
}
func TestIsLegacyInvocation(t *testing.T) {
tests := []struct {
subcmd serveMode
args []string
expected bool
translation string
}{
{
subcmd: serve,
args: []string{"https", "/", "localhost:3000"},
expected: true,
translation: "tailscale serve --bg localhost:3000",
},
{
subcmd: serve,
args: []string{"https", "/", "localhost:3000", "off"},
expected: true,
translation: "tailscale serve --bg localhost:3000 off",
},
{
subcmd: serve,
args: []string{"https", "/", "off"},
expected: true,
translation: "tailscale serve --bg off",
},
{
subcmd: serve,
args: []string{"https:4545", "/foo", "off"},
expected: true,
translation: "tailscale serve --bg --https 4545 --set-path /foo off",
},
{
subcmd: serve,
args: []string{"https:4545", "/foo", "localhost:9090", "off"},
expected: true,
translation: "tailscale serve --bg --https 4545 --set-path /foo localhost:9090 off",
},
{
subcmd: serve,
args: []string{"https:8443", "/", "localhost:3000"},
expected: true,
translation: "tailscale serve --bg --https 8443 localhost:3000",
},
{
subcmd: serve,
args: []string{"http", "/", "localhost:3000"},
expected: true,
translation: "tailscale serve --bg --http 80 localhost:3000",
},
{
subcmd: serve,
args: []string{"http:80", "/", "localhost:3000"},
expected: true,
translation: "tailscale serve --bg --http 80 localhost:3000",
},
{
subcmd: serve,
args: []string{"https:10000", "/motd.txt", `text:Hello, world!`},
expected: true,
translation: `tailscale serve --bg --https 10000 --set-path /motd.txt "text:Hello, world!"`,
},
{
subcmd: serve,
args: []string{"tcp:2222", "tcp://localhost:22"},
expected: true,
translation: "tailscale serve --bg --tcp 2222 tcp://localhost:22",
},
{
subcmd: serve,
args: []string{"tls-terminated-tcp:443", "tcp://localhost:80"},
expected: true,
translation: "tailscale serve --bg --tls-terminated-tcp 443 tcp://localhost:80",
},
{
subcmd: funnel,
args: []string{"443", "on"},
expected: true,
},
{
subcmd: funnel,
args: []string{"443", "off"},
expected: true,
},
{
subcmd: serve,
args: []string{"3000"},
expected: false,
},
{
subcmd: serve,
args: []string{"localhost:3000"},
expected: false,
},
}
for idx, tt := range tests {
t.Run(strconv.Itoa(idx), func(t *testing.T) {
gotTranslation, actual := isLegacyInvocation(tt.subcmd, tt.args)
if actual != tt.expected {
t.Fatalf("got: %v; expected: %v", actual, tt.expected)
}
if gotTranslation != tt.translation {
t.Fatalf("expected translaction to be %q but got %q", tt.translation, gotTranslation)
}
})
}
}
// exactErrMsg returns an error checker that wants exactly the provided want error.
// If optName is non-empty, it's used in the error message.
func exactErrMsg(want error) func(error) string {
return func(got error) string {
if got.Error() == want.Error() {
return ""
}
return fmt.Sprintf("\ngot: %v\nwant: %v\n", got, want)
}
}