diff --git a/internal/app/tailscale.go b/internal/app/tailscale.go index 5f416bbe..f7903ae0 100644 --- a/internal/app/tailscale.go +++ b/internal/app/tailscale.go @@ -117,6 +117,23 @@ 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 ":". +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() @@ -124,61 +141,30 @@ func tailscaleServeRuleState(ctx context.Context, bin, port, target string) (ser 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 } diff --git a/internal/app/tailscale_test.go b/internal/app/tailscale_test.go index 3ddf42fc..8f3fa5f7 100644 --- a/internal/app/tailscale_test.go +++ b/internal/app/tailscale_test.go @@ -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[":"]. 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") @@ -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 @@ -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 @@ -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