Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 35 additions & 49 deletions internal/app/tailscale.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,68 +117,54 @@ func configureTailscaleServe(ctx context.Context, port string) (string, bool, er
return url, true, nil
}

// tailscaleServeConfig is the subset of `tailscale serve status --json`
// (ipn.ServeConfig) that decides whether our rule is already in place. The
// port appears as a bare key only under TCP, which holds no proxy target; the
// proxy lives under Web, keyed "<magicdns-name>:<port>".
type tailscaleServeConfig struct {
TCP map[string]struct {
HTTPS bool `json:"HTTPS"`
HTTP bool `json:"HTTP"`
TCPForward string `json:"TCPForward"`
} `json:"TCP"`
Web map[string]struct {
Handlers map[string]struct {
Proxy string `json:"Proxy"`
} `json:"Handlers"`
} `json:"Web"`
}

func tailscaleServeRuleState(ctx context.Context, bin, port, target string) (serveRuleState, error) {
cmdCtx, cancel := context.WithTimeout(ctx, tailscaleCommandTimeout)
defer cancel()
out, err := exec.CommandContext(cmdCtx, bin, "serve", "status", "--json").Output()
if err != nil {
return serveRuleMissing, fmt.Errorf("tailscale serve status failed: %w", err)
}
var status any
if err := json.Unmarshal(out, &status); err != nil {
var cfg tailscaleServeConfig
if err := json.Unmarshal(out, &cfg); err != nil {
return serveRuleMissing, fmt.Errorf("parse tailscale serve status: %w", err)
}
rule, ok := findJSONKey(status, port)
if !ok {
return serveRuleMissing, nil
}
strings := collectJSONStrings(rule)
for _, s := range strings {
if s == target {
return serveRuleSame, nil
}
}
return serveRuleConflict, nil
}

func findJSONKey(v any, key string) (any, bool) {
switch x := v.(type) {
case map[string]any:
if child, ok := x[key]; ok {
return child, true
claimed := false
for hostPort, web := range cfg.Web {
if !strings.HasSuffix(hostPort, ":"+port) {
continue
}
for _, child := range x {
if found, ok := findJSONKey(child, key); ok {
return found, true
}
}
case []any:
for _, child := range x {
if found, ok := findJSONKey(child, key); ok {
return found, true
claimed = true
for _, handler := range web.Handlers {
if handler.Proxy == target {
return serveRuleSame, nil
}
}
}
return nil, false
}

func collectJSONStrings(v any) []string {
var out []string
var walk func(any)
walk = func(x any) {
switch y := x.(type) {
case string:
out = append(out, y)
case map[string]any:
for _, child := range y {
walk(child)
}
case []any:
for _, child := range y {
walk(child)
}
}
if claimed {
return serveRuleConflict, nil
}
// A TCP entry with no matching Web proxy is someone else's rule (a raw
// TCPForward, or an HTTPS terminator we did not create): do not overwrite it.
if _, ok := cfg.TCP[port]; ok {
return serveRuleConflict, nil
}
walk(v)
return out
return serveRuleMissing, nil
}
75 changes: 73 additions & 2 deletions internal/app/tailscale_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ exit 2
}
}

// The serve status fixture here is the real ipn.ServeConfig shape: the port is
// a bare key only under TCP (which holds no proxy), while our proxy lives under
// Web["<magicdns-name>:<port>"]. Matching the port as a JSON key reported this
// as a conflict on every start (#118).
func TestConfigureTailscaleServeDoesNothingWhenSameRuleExists(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "tailscale-args.log")
Expand All @@ -77,7 +81,7 @@ if [ "$1" = "status" ] && [ "$2" = "--json" ]; then
exit 0
fi
if [ "$1" = "serve" ] && [ "$2" = "status" ] && [ "$3" = "--json" ]; then
printf '%s\n' '{"HTTPS":{"31415":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:31415"}}}}}'
printf '%s\n' '{"TCP":{"31415":{"HTTPS":true}},"Web":{"macbook.tailnet.ts.net:31415":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:31415"}}}}}'
exit 0
fi
if [ "$1" = "serve" ]; then
Expand Down Expand Up @@ -111,7 +115,7 @@ if [ "$1" = "status" ] && [ "$2" = "--json" ]; then
exit 0
fi
if [ "$1" = "serve" ] && [ "$2" = "status" ] && [ "$3" = "--json" ]; then
printf '%s\n' '{"HTTPS":{"31415":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:9000"}}}}}'
printf '%s\n' '{"TCP":{"31415":{"HTTPS":true}},"Web":{"macbook.tailnet.ts.net:31415":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:9000"}}}}}'
exit 0
fi
if [ "$1" = "serve" ]; then
Expand All @@ -133,6 +137,73 @@ exit 2
}
}

func TestConfigureTailscaleServeDoesNotOverwriteRawTCPForward(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "tailscale-args.log")
writeFakeTailscale(t, dir, `#!/bin/sh
if [ "$1" = "status" ] && [ "$2" = "--json" ]; then
printf '%s\n' '{"BackendState":"Running","Self":{"DNSName":"macbook.tailnet.ts.net."}}'
exit 0
fi
if [ "$1" = "serve" ] && [ "$2" = "status" ] && [ "$3" = "--json" ]; then
printf '%s\n' '{"TCP":{"31415":{"TCPForward":"127.0.0.1:9000"}}}'
exit 0
fi
if [ "$1" = "serve" ]; then
printf '%s\n' "$*" > "`+logPath+`"
exit 0
fi
exit 2
`)

_, ok, err := configureTailscaleServe(context.Background(), "31415")
if err == nil || !strings.Contains(err.Error(), "already configured") {
t.Fatalf("configureTailscaleServe error = %v, want conflict", err)
}
if ok {
t.Fatalf("ok = true, want false")
}
if _, err := os.Stat(logPath); !os.IsNotExist(err) {
t.Fatalf("tailscale serve was run despite an existing TCP forward")
}
}

func TestConfigureTailscaleServeIgnoresRulesOnOtherPorts(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "tailscale-args.log")
writeFakeTailscale(t, dir, `#!/bin/sh
if [ "$1" = "status" ] && [ "$2" = "--json" ]; then
printf '%s\n' '{"BackendState":"Running","Self":{"DNSName":"macbook.tailnet.ts.net."}}'
exit 0
fi
if [ "$1" = "serve" ] && [ "$2" = "status" ] && [ "$3" = "--json" ]; then
printf '%s\n' '{"TCP":{"443":{"HTTPS":true}},"Web":{"macbook.tailnet.ts.net:443":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:8080"}}}}}'
exit 0
fi
if [ "$1" = "serve" ]; then
printf '%s\n' "$*" > "`+logPath+`"
exit 0
fi
exit 2
`)

_, ok, err := configureTailscaleServe(context.Background(), "31415")
if err != nil {
t.Fatalf("configureTailscaleServe returned error: %v", err)
}
if !ok {
t.Fatalf("ok = false, want true")
}
logged, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read fake tailscale log: %v", err)
}
want := "serve --bg --https=31415 http://127.0.0.1:31415"
if got := strings.TrimSpace(string(logged)); got != want {
t.Fatalf("tailscale serve args = %q, want %q", got, want)
}
}

func TestTailscaleSelfDNSRejectsStoppedBackend(t *testing.T) {
dir := t.TempDir()
writeFakeTailscale(t, dir, `#!/bin/sh
Expand Down