/** * Tailscale Serve Provider (Issue #1937, R3). * * Implements §6.1–§6.3 of `docs/design/remote-qr-pairing-1937.md`. §6.4's * Tailscale table was written as a guess on a machine that had no `tailscale` * at all (U-2). Everything below marked "measured" was measured on 2026-08-29 * against Tailscale 1.102.3 (standalone/macsys, `/usr/local/bin/tailscale`) on * macOS; the raw session is in `dev-reports/issue/1937/u2-tailscale-serve.md`. * Where the two disagree, the measurement wins. * * ## The one thing this file exists to get right * * Serve configuration is persistent state owned by tailscaled. The user may * already be publishing their own services through it, and **there is no undo * and no backup**. So the whole design here is about the blast radius of * teardown, and the measurement below is the reason it is not obvious: * * | invocation | measured effect on a node serving `/` and `/u2-existing-user` | * |---|---| * | `serve --https= --set-path off` | removes exactly that one handler | * | the same command with the path passed positionally instead | **wiped both handlers; config back to `{}`** | * | the same command with no path at all | **wiped both handlers; config back to `{}`** | * * The middle row matters most, because the untargeted form is *what Tailscale * itself prints* after a successful `serve`: its success banner recommends * re-running `serve` with only `--https=` and the word "off", no path. * Following that hint would delete the user's configuration along with ours, * silently, with exit status 0. (The banner's exact wording is quoted in the * dev report; it is paraphrased here so this file does not trip the guard that * forbids the shape — which is itself a small positive control.) * * Hence {@link buildServeOffArgs} is the only place an `off` argv is built, it * always carries `--set-path`, and it derives that path from the handler key it * is undoing rather than from anything ambient. * * ## Why `off` at all, when `serve --help` never mentions it * * 1.102.3's help lists `status` / `reset` / `drain` / `clear` / `advertise` / * `get-config` / `set-config` and no `off`. Two alternatives were measured and * rejected before settling on the undocumented-but-live `off`: * * - `get-config` + `set-config` looked like a "snapshot and restore" route, but * measuring their help shows both are **service**-scoped (`--service`, or * `--all` meaning "all services"). They do not address node-level Serve * handlers at all, so they cannot express "remove this one handler". * - `drain` / `clear` are likewise service-scoped, and both are *destructive* * at service granularity. They are now forbidden outright by * `tests/unit/config/remote-destructive-command-guard.test.ts`. * * That leaves `off`, which is undocumented in `--help` yet is the exact string * the CLI recommends in its own success output, and which measured clean: * exit 0, only the named handler removed, and the `TCP` entry for the port * cleaned up automatically once the last handler on it is gone. * * ## What `start()` refuses to do * * Measured: running `serve` for a path that already has a handler **overwrites * it, prints the new mapping, and exits 0**. Nothing warns, and the previous * upstream is unrecoverable. `preexisting` cannot help after the fact — §6.3-2 * protects the user's entries from `stop()`, not from `start()`. So `start()` * reads the snapshot first and refuses when its target key is already taken. * * Deliberately absent: `reset()` / `cleanupAll()` (§6.3-1), and any command * that operates on "the current configuration" rather than on a handle. * * This module imports Node builtins and `./types` only. No `@/` alias, so it * resolves under `tsconfig.cli.json` (which sets `paths: {}`). */ import { type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from 'child_process'; import { type PreexistingSnapshot, type RemoteProvider } from './types'; /** The executable. Looked up on PATH by `spawnSync`, never through a shell. */ export declare const TAILSCALE_BIN = "tailscale"; /** * Mirrors `PreflightChecker.checkDependency()`: one argument, passed in an * array. Measured: `tailscale version` prints `1.102.3` as its first line, * while `tailscale --version` prints the same number followed by four lines of * commit hashes. Both work; the subcommand is the tidier one to parse. */ export declare const TAILSCALE_VERSION_ARG = "version"; /** * The only host this Provider ever names. * * A constant rather than a parameter, so "the upstream is always loopback" is a * property of the code and not of every call site. §9.2 pins it from outside as * well, by asserting the argv `start()` builds. * * Measured: passing a bare port (`tailscale serve --bg 19001`) also resolves to * `http://127.0.0.1:19001`. The full URL is passed anyway, so the guarantee is * visible in argv instead of relying on a Tailscale-side default. */ export declare const LOOPBACK_HOST = "127.0.0.1"; /** * The HTTPS port Serve publishes on. * * 443 is the port `tailscale serve ` uses by default and the one the * tailnet's HTTPS certificate covers (measured: `status --json` reports * `CertDomains: ["..ts.net"]`, so no extra enablement step is * needed on this machine). */ export declare const SERVE_HTTPS_PORT = 443; /** * The path CommandMate publishes on. * * Root, not a prefix: the Next.js app is served without a `basePath`, so a * handler mounted at `/commandmate` would return an app whose own asset and API * URLs all point back at `/`. */ export declare const SERVE_PATH = "/"; /** Same 5s budget `PreflightChecker` gives every other dependency probe. */ export declare const DETECT_TIMEOUT_MS = 5000; /** * Budget for the Serve mutations. * * Longer than the probe: `serve --bg` talks to tailscaled and, the first time a * tailnet uses HTTPS, can provision a certificate. Measured latency on an * already-provisioned node was well under a second. */ export declare const SERVE_TIMEOUT_MS = 30000; /** `BackendState` from `tailscale status --json` when the node is usable. */ export declare const BACKEND_STATE_RUNNING = "Running"; /** The subset of `tailscale serve status --json` this module reads. */ export interface ServeTcpEntry { HTTPS?: boolean; HTTP?: boolean; TCPForward?: string; TerminateTLS?: string; } /** One `host:port` block of the `Web` map. */ export interface ServeWebEntry { Handlers?: Record; } /** * `tailscale serve status --json`, as measured. * * With nothing configured the command prints `{}` and exits 0 (the human form * says `No serve config`). With two handlers on 443 it prints * `{"TCP":{"443":{"HTTPS":true}},"Web":{"..ts.net:443": * {"Handlers":{"/":{"Proxy":"http://127.0.0.1:19002"}, ...}}}}`. */ export interface ServeConfig { TCP?: Record; Web?: Record; } /** The fields of `tailscale status --json` readiness is decided from. */ export interface TailscaleStatus { BackendState?: string; MagicDNSSuffix?: string; Self?: { DNSName?: string; } | null; Version?: string; } /** The seam that lets this Provider be tested without a tailnet. */ export interface TailscaleProviderDeps { spawnSync: (command: string, args: readonly string[], options: SpawnSyncOptionsWithStringEncoding) => SpawnSyncReturns; /** HTTPS port to publish on. Overridable so tests can prove it reaches argv. */ servePort: number; /** Path to publish on. Same reason. */ servePath: string; detectTimeoutMs: number; serveTimeoutMs: number; } export declare const defaultTailscaleDeps: TailscaleProviderDeps; /** * Drops the trailing dot from a MagicDNS name. * * Measured: `Self.DNSName` is `maenomac-studio.taile4f402.ts.net.` — fully * qualified, with the root dot — while the key Serve uses in its own `Web` map * is `maenomac-studio.taile4f402.ts.net:443`, without it. The two must agree * or every snapshot key would miss. */ export declare function normalizeDnsName(name: string): string; /** * The key one Serve handler is known by, in the keyspace `PreexistingSnapshot` * and `RemoteHandle.owned.revert` share (§6.3-2). * * `:` — byte-for-byte the `Web` map key with the handler path * appended, so a snapshot key and an owned key are comparable by construction * rather than by convention. */ export declare function serveHandlerKey(host: string, port: number, path: string): string; /** The parts of a handler key an undo command needs. */ export interface ParsedServeHandlerKey { host: string; port: number; path: string; } /** * Inverse of {@link serveHandlerKey}. * * Returns null rather than guessing. A key that cannot be parsed is a key whose * undo command cannot be built safely, and the caller reports that instead of * running a less specific command — which, per the measurement in the file * header, is how a whole-port wipe happens. */ export declare function parseServeHandlerKey(key: string): ParsedServeHandlerKey | null; /** * Parses `tailscale serve status --json`. * * Empty output is an empty configuration, not a failure: measured, the command * prints `{}` when nothing is served, and a Provider that treated "no config" * as "could not read config" would refuse to start on a clean machine. */ export declare function parseServeConfig(text: string): ServeConfig | null; /** * Every handler currently configured, as keys, sorted. * * This is the list §6.3-2 protects. Anything in here existed before CommandMate * ran and must survive `stop()`. */ export declare function serveHandlerKeys(config: ServeConfig): string[]; /** Wraps a parsed config as the snapshot shape the shared skip rule reads. */ export declare function snapshotServeConfig(config: ServeConfig): PreexistingSnapshot; /** * The argv that publishes `http://127.0.0.1:`. * * `LOOPBACK_HOST` is a constant here and `upstreamPort` is validated before it * arrives, so the only caller-supplied value that reaches the command line is a * port number. `--yes` keeps tailscaled from ever asking a question on a * non-interactive run. */ export declare function buildServeArgs(opts: { servePort: number; servePath: string; upstreamPort: number; }): string[]; /** * The argv that removes exactly one handler. * * **The `--set-path` is load-bearing.** Measured on 1.102.3, dropping it — or * passing the path positionally, the way older Tailscale documentation shows — * removes every handler on the port and exits 0. See the table in the file * header. This is the only function in the codebase that builds an `off` argv, * and it cannot build one without a path. */ export declare function buildServeOffArgs(parsed: ParsedServeHandlerKey): string[]; /** The public URL a handler on `:` answers on. */ export declare function buildServeUrl(host: string, port: number): string; /** What `tailscale status --json` says about this node being able to serve. */ export interface ServeReadiness { ready: boolean; /** The MagicDNS name, without its trailing dot, when there is one. */ dnsName: string | null; reason?: string; } /** * Decides `ready` from `tailscale status --json`. * * Two signals, both measured present on a working node: `BackendState` is * `Running`, and `Self.DNSName` carries the MagicDNS name Serve publishes on. * They are checked separately because they fail separately — a logged-out node * reports a non-`Running` state, while a tailnet with MagicDNS disabled is * `Running` with no name to serve under. */ export declare function readServeReadiness(text: string): ServeReadiness; /** * Builds the Provider. * * The returned object has exactly the four members `RemoteProvider` declares — * the seams stay closed over in here rather than becoming a fifth key, because * the contract test pins that list and §6.3-1 is the reason it does. */ export declare function createTailscaleProvider(overrides?: Partial): RemoteProvider; /** The shipped Provider, wired to the real `tailscale`. */ export declare const tailscaleProvider: RemoteProvider; //# sourceMappingURL=tailscale.d.ts.map