Repository navigation
fix(server): Tailscale Serve no longer takes a live server's route and repairs one left on a dead port #35042
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,6 +1,6 @@ | ||||||
| import { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | ||||||
| import * as HostProcess from "@t3tools/shared/HostProcess"; | ||||||
| import { normalizeHostname } from "@t3tools/shared/hostClassification"; | ||||||
| import { isLocalLoopbackHost, normalizeHostname } from "@t3tools/shared/hostClassification"; | ||||||
| import * as Duration from "effect/Duration"; | ||||||
| import * as Effect from "effect/Effect"; | ||||||
| import * as Option from "effect/Option"; | ||||||
|
|
@@ -656,6 +656,60 @@ export const readTailscaleServeRoutesTo = (target: { | |||||
| }), | ||||||
| ); | ||||||
|
|
||||||
| /** A handler at the root of a Serve port: this server's, or another with the loopback port it dials. */ | ||||||
| export type TailscaleServeRootHolder = | ||||||
| | { readonly ours: true } | ||||||
| | { readonly ours: false; readonly loopback?: { readonly host: string; readonly port: number } }; | ||||||
|
|
||||||
| /** | ||||||
| * What answers at the root of `servePort` outside any Tailscale Service, where | ||||||
| * `ensureTailscaleServe` publishes, or undefined when the status cannot be read. | ||||||
| */ | ||||||
| export const parseTailscaleServeRootHolders = ( | ||||||
| rawServeStatusJson: string, | ||||||
| servePort: number, | ||||||
| target: { readonly host: string; readonly port: number }, | ||||||
| ): ReadonlyArray<TailscaleServeRootHolder> | undefined => { | ||||||
| const status = decodeTailscaleServeStatusJson(rawServeStatusJson); | ||||||
| if (Option.isNone(status)) return undefined; | ||||||
| const config = status.value ?? undefined; | ||||||
| const tcp: ReadonlyArray<TailscaleServeRootHolder> = | ||||||
| config?.TCP?.[String(servePort)]?.TCPForward === undefined ? [] : [{ ours: false }]; | ||||||
| const web = Object.entries(config?.Web ?? {}).flatMap( | ||||||
| ([hostPort, site]): ReadonlyArray<TailscaleServeRootHolder> => { | ||||||
| const root = site.Handlers?.["/"]; | ||||||
| if (Number(hostPort.slice(hostPort.lastIndexOf(":") + 1)) !== servePort || root === undefined) | ||||||
| return []; | ||||||
| if (root.Proxy === undefined) return [{ ours: false }]; | ||||||
| const url = expandProxy(root.Proxy); | ||||||
| if (reachesTarget(url, target) !== undefined) return [{ ours: true }]; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High A root proxy such as
Suggested change
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not classify an ambiguous hostname as this server's route. A proxy to Startup treats Ownership needs an exact match on this branch. Keep the Add a parser case in
|
||||||
| const dialed = dialedHostPort(url); | ||||||
| return [ | ||||||
| dialed !== undefined && isLocalLoopbackHost(dialed.host) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium An expanded IPv6 loopback proxy such as 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||||||
| ? { | ||||||
| ours: false, | ||||||
| loopback: { host: normalizeHostname(dialed.host), port: Number(dialed.port) }, | ||||||
| } | ||||||
| : { ours: false }, | ||||||
| ]; | ||||||
| }, | ||||||
| ); | ||||||
| return [...tcp, ...web]; | ||||||
| }; | ||||||
|
|
||||||
| export const readTailscaleServeRootHolders = ( | ||||||
| servePort: number, | ||||||
| target: { readonly host: string; readonly port: number }, | ||||||
| ) => | ||||||
| readTailscaleOutput("serve", ["serve", "status", "--json"]).pipe( | ||||||
| Effect.flatMap((status) => { | ||||||
| const holders = parseTailscaleServeRootHolders(status, servePort, target); | ||||||
| return holders === undefined | ||||||
| ? Effect.fail(new TailscaleStatusParseError({ cause: "unreadable serve status" })) | ||||||
| : Effect.succeed(holders); | ||||||
| }), | ||||||
| ); | ||||||
|
|
||||||
| export const probeTailscaleHttpsEndpoint = Effect.fn("tailscale.probeTailscaleHttpsEndpoint")( | ||||||
| function* (input: { readonly baseUrl: string; readonly timeout?: Duration.Input }) { | ||||||
| const client = yield* HttpClient.HttpClient; | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check other holders before accepting an owned holder.
If a former DNS name points to this server while the current DNS name points to another live application, the parser returns both holders. This early return skips the live application's listener check. Startup then calls
ensureTailscaleServeand replaces the current route.Do not let one owned holder override another protected holder. Evaluate the holders for the route that the write will replace, or conservatively preserve the configuration when any relevant holder belongs to another application.