Skip to content

fix(tailscale): match the Serve rule by proxy value, not port-as-key - #120

Merged
setkyar merged 1 commit into
mainfrom
fix/tailscale-serve-rule-value-match
Sep 17, 2026
Merged

setkyar merged 1 commit into
mainfrom
fix/tailscale-serve-rule-value-match

Conversation

@setkyar

@setkyar setkyar commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

tailscaleServeRuleState (internal/app/tailscale.go) looked for the port as an object key anywhere in tailscale serve status --json, then compared the strings under it to http://127.0.0.1:<port>. In the real ipn.ServeConfig the only key equal to the port sits under TCP and holds no strings; the proxy lives under Web["<magicdns-name>:<port>"]:

{ "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 any rule existed on that port, serveRuleConflict was the only reachable outcome — including for a rule pi-web created itself. Every start (token set, no --host) logged already configured for another service; the reporter counted 1135 occurrences. tailscaleUrl stayed empty in pi-web-state.json and authMiddleware.AllowHost(tsURL) was never called.

The fix parses the config into a typed struct and decides on the proxy value:

  • a Web key ending :<port> with a handler proxying to our target → serveRuleSame
  • a Web hit on that port without that proxy → serveRuleConflict
  • no Web hit but a bare TCP[port] entry (raw TCPForward, or an HTTPS terminator we did not create) → serveRuleConflict
  • neither → serveRuleMissing

findJSONKey and collectJSONStrings had no other callers and are removed.

Behavior note: the old recursive search would incidentally match ports nested inside Foreground serve sessions; the typed parse will not. Foreground serve is interactive and ephemeral, and the old outcome there was the same spurious conflict error, so this is not a regression.

Related issue

Closes #118

(Split out of #112, secondary finding 2, reported by @laulpogan. The storm itself was fixed in #113.)

Type of change

  • fix — bug fix

Live vs. Export

  • Not applicable — this PR doesn't touch session rendering

Testing

  • make check passes (test + build + vet)
  • Frontend tests (vitest) cover the change
  • Go tests (go test ./...) cover the change
  • UI changes verified in a browser

The three existing fixtures in tailscale_test.go used a fabricated {"HTTPS":{"31415":{...}}} shape in which the port is an exact key holding the proxy string — which is why this bug shipped green. They now use the real ServeConfig shape, so the same-rule test is the reported case verbatim and fails against the old implementation. Added two cases: a raw TCPForward on the port (conflict, no overwrite) and a rule on an unrelated port (correctly ignored, our rule still created).

tailscaleServeRuleState looked for the port as an object key anywhere in
`tailscale serve status --json`, then compared the strings under it to
http://127.0.0.1:<port>. In the real ipn.ServeConfig the only key equal to
the port is under TCP, which holds no strings; the proxy lives under
Web["<magicdns-name>:<port>"]. So once any rule existed on that port,
serveRuleConflict was the only reachable outcome -- including for a rule
pi-web created itself.

Every start then logged "already configured for another service" (1135
occurrences in the reporter's log), tailscaleUrl stayed empty in
pi-web-state.json, and authMiddleware.AllowHost(tsURL) was never called.

Parse the config into a typed struct and decide on the proxy value:
a Web key ending ":<port>" whose handler proxies to our target is
serveRuleSame; a Web hit without that proxy, or a bare TCP entry on the
port (a raw TCPForward, or an HTTPS terminator we did not create), is
serveRuleConflict; neither is serveRuleMissing. findJSONKey and
collectJSONStrings had no other callers and are removed.

The three existing fixtures used a fabricated {"HTTPS":{"<port>":...}}
shape in which the port *is* an exact key holding the proxy string, which
is why this shipped green; they now use the real shape. The same-rule test
is the reported case verbatim and fails against the old implementation.
Added coverage for a raw TCPForward and for rules on unrelated ports.

Closes #118
@setkyar
setkyar merged commit 2981001 into main Sep 17, 2026
6 checks passed
@setkyar
setkyar deleted the fix/tailscale-serve-rule-value-match branch September 17, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tailscaleServeRuleState matches the port as a JSON key, so a correct Serve rule always reports serveRuleConflict

1 participant