Split out of #112 (secondary finding 2), which was closed by #113 — that PR only addressed the restart storm. Reported by @laulpogan.
Problem
tailscaleServeRuleState (internal/app/tailscale.go:120) looks for the port as an object key anywhere in the JSON, then compares the strings under that key to http://127.0.0.1:<port>:
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
In real tailscale serve status --json, the only key equal to the port is under TCP, and it holds no strings. The matching proxy lives under Web, whose key is <host>.ts.net:<port> — never equal to "31415":
So once a rule exists on that port, serveRuleConflict is the only reachable outcome — including for a rule pi-web created itself via configureTailscaleServe.
Consequence
Every start (when a token is set and --host is not, internal/app/app.go:139) prints:
Tailscale Serve unavailable: tailscale HTTPS port 31415 is already configured for another service;
not overwriting it. To replace it, run: tailscale serve --bg --https=31415 http://127.0.0.1:31415
Reporter saw 1135 occurrences in their error log. A rule that is already correct is reported as someone else's, tailscaleUrl stays empty in pi-web-state.json, and authMiddleware.AllowHost(tsURL) (app.go:146) is never called. No user-visible breakage was demonstrated from the missing AllowHost — token auth over the tailnet works today — so that part is worth checking, not a proven failure. The confirmed cost is the noise plus pi-web never adopting its own Serve rule.
Fix
Match on the value rather than the port-as-key: walk the JSON for any string equal to target, or read Web["<dns-name>:<port>"].Handlers[*].Proxy explicitly. Treating TCP[port] == {"HTTPS": true} with a matching Web proxy as serveRuleSame also works.
Verified against current main (1040251): findJSONKey at tailscale.go:144 and collectJSONStrings at :164 behave as described.
Split out of #112 (secondary finding 2), which was closed by #113 — that PR only addressed the restart storm. Reported by @laulpogan.
Problem
tailscaleServeRuleState(internal/app/tailscale.go:120) looks for the port as an object key anywhere in the JSON, then compares the strings under that key tohttp://127.0.0.1:<port>:In real
tailscale serve status --json, the only key equal to the port is underTCP, and it holds no strings. The matching proxy lives underWeb, whose key is<host>.ts.net:<port>— never equal to"31415":{ "TCP": { "31415": { "HTTPS": true } }, // exact-key match; 0 strings "Web": { "host.example.ts.net:31415": // key contains the port, never matches { "Handlers": { "/": { "Proxy": "http://127.0.0.1:31415" } } } } }So once a rule exists on that port,
serveRuleConflictis the only reachable outcome — including for a rule pi-web created itself viaconfigureTailscaleServe.Consequence
Every start (when a token is set and
--hostis not,internal/app/app.go:139) prints:Reporter saw 1135 occurrences in their error log. A rule that is already correct is reported as someone else's,
tailscaleUrlstays empty inpi-web-state.json, andauthMiddleware.AllowHost(tsURL)(app.go:146) is never called. No user-visible breakage was demonstrated from the missingAllowHost— token auth over the tailnet works today — so that part is worth checking, not a proven failure. The confirmed cost is the noise plus pi-web never adopting its own Serve rule.Fix
Match on the value rather than the port-as-key: walk the JSON for any string equal to
target, or readWeb["<dns-name>:<port>"].Handlers[*].Proxyexplicitly. TreatingTCP[port] == {"HTTPS": true}with a matchingWebproxy asserveRuleSamealso works.Verified against current
main(1040251):findJSONKeyattailscale.go:144andcollectJSONStringsat:164behave as described.