From 9db62f877c71d0a29392729d00135602475056a0 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 13 Oct 2026 07:52:18 -0400 Subject: [PATCH 1/4] docs: design pluggable transports --- .../2026-10-13-pluggable-transports-design.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/specs/2026-10-13-pluggable-transports-design.md diff --git a/docs/specs/2026-10-13-pluggable-transports-design.md b/docs/specs/2026-10-13-pluggable-transports-design.md new file mode 100644 index 000000000..1ec288ab6 --- /dev/null +++ b/docs/specs/2026-10-13-pluggable-transports-design.md @@ -0,0 +1,130 @@ +# Pluggable transports + +Date: 2026-10-13. Status: accepted. + +## Summary + +stim-server reaches phones and other machines only through Tailscale. This +design splits that into a transport contract, `@stim-cli/transport`, and one +package per backend: `@stim-cli/transport-tailscale`, `@stim-cli/transport-local`, +`@stim-cli/transport-ngrok`, and `@stim-cli/transport-cloudflared`. It follows +the cache provider contract in `@stim-cli/cache`: a versioned module, a bag of +optional capabilities, a loader that never throws, and a contract test kit. + +`stim` and `@stim-cli/server` depend on every first-party transport for now, +and Stim Desktop bundles them. They may become optional installs later. + +## What Tailscale does today + +Stim uses Tailscale for four separate things. The contract keeps them +separate, because the other backends provide only some of them. + +| Role | Today | Contract capability | +|---|---|---| +| Exposure and TLS | `tailscale serve` on a tailnet-only HTTPS port | `expose`, `setup` | +| Peer identity | `tailscale whois` gives the node a device token is bound to | `identify` | +| Discovery and pinning | `tailscale status --json` lists peer machines; a name that moves to another node is refused | `discover`, `resolve` | +| Bind addresses | The server also listens on the Tailscale addresses; the hosted Metro gateway trusts WireGuard source addresses | `bindAddresses`, `traits.privateNetwork` | + +The pairing core does not depend on the transport and does not change: the QR +payload, `hello` with a pairing token and then a device token, the token +registry, and grant and revoke. + +## Decisions + +- Transports are machine settings, not project settings. stim-server is one + process per machine and ships inside Stim Desktop, so transports resolve from + the server's own dependencies. A cache provider, by contrast, resolves from + the project that names it. `network.transports` is an ordered list, so one + server can answer a simulator on loopback and a phone on the tailnet. +- Stim adds its own peer identity, so every transport gets a second factor + beside the device token. At pairing the client sends a public key. Later + `hello` calls answer a server challenge with that key, and the server proves + the key the QR code names. A transport identity, such as a Tailscale node, + remains an extra check where the transport has one. +- Identity comes from the transport that owns the listener, never from request + headers. `tailscale serve`, ngrok, and cloudflared all proxy from loopback with + `X-Forwarded-For`, so the header cannot tell them apart. +- ngrok and cloudflared decrypt traffic at their edge. This reverses the + "Transport alternatives" note of the stim-server design: these backends ship, + but only when the user turns them on, and Desktop and the docs say that the + provider can read the traffic. Application-layer encryption may follow. +- Transports without `privateNetwork` cannot carry hosted Metro, whose gateway + trusts source addresses, until hosted Metro moves onto the authenticated + WebSocket. +- Metro reach (`metro.tunnel`) moves onto the same plugins, through + `expose({ purpose: 'metro' })`, after the server side is done. + +## Contract + +```ts +export const apiVersion = 1; +export const id: string; +export const optionsSchema: JSONSchema; // x-stim annotations, rendered by Desktop +export function createTransport(input: { stimHome: string; env: NodeJS.ProcessEnv; options: Record }): + Transport | Promise; + +interface Transport { + id: string; + traits: { privateNetwork: boolean; tls: 'end-to-end' | 'edge' | 'none'; peerIdentity: boolean }; + status(): Promise; + watch?(listener: () => void): () => void; + expose?(input: { port: number; purpose: 'server' | 'metro' }): Promise; + setup?(input: { port: number; action: string }): Promise; + identify?(request: IncomingMessage): Promise; + bindAddresses?(): string[]; + discover?(): Promise; + resolve?(target: { machine: string; peerId: string }): Promise; + close?(): void; +} +``` + +`TransportStatus` is a list of checks, each with a state (`waiting`, +`working`, `ok`, `problem`), a title, an optional detail, and an optional +remedy (a command to copy, a URL to open, or a `setup` action). Desktop already +models pairing steps this way, so it renders any transport's checks without +transport-specific code. + +## Apps + +Stim Desktop is a Swift app and cannot load plugins, so the server serializes +what the apps need: + +- `/health` gains `transports`, one entry per configured transport with its + state, endpoint, and traits. The `tailscale` and `route` fields stay while + older Desktop builds are in use. +- `transport.status`, `transport.setup`, and `transport.discover` replace + Desktop's own `tailscale status --json` reads and the `route.setup` call. +- Each transport's `optionsSchema` joins `settings.schema.json` under + `network.options.`, so the existing settings editor renders it. Cache + providers get the same optional `optionsSchema`. +- Desktop shows the generic checks list by default. The Tailscale screens keep + their custom content (app links, app or CLI install, Funnel warning) as an + override keyed on the transport id. +- The QR payload moves to `v: 2` with an `endpoints` list, each entry naming its + transport, plus the server key. The mobile app tries the endpoints in order + and stops relying on the `.ts.net` suffix to decide whether to trust a host. + Error and onboarding copy becomes per transport. + +## Credentials + +Machine credentials store `nodeId` and `dnsName`. They become +`{ transport, peerId, address }`, and records without a transport are read as +`tailscale`. Machine names in settings accept an optional `:` +prefix; no prefix means `tailscale`. + +## Phases + +1. Add `@stim-cli/transport` and move Tailscale into + `@stim-cli/transport-tailscale`, with the server reaching it only through the + contract. No behavior change; the existing server, setup, registry, and + hosted relay tests are the check. +2. Add `@stim-cli/transport-local` (loopback, and LAN with a self-signed + certificate pinned by the QR code), the `v: 2` QR payload, and the key-based + peer identity. +3. Serve `/health.transports`, `transport.status`, `transport.setup`, and + `transport.discover`, and move Desktop and the mobile app onto them. +4. Add `@stim-cli/transport-ngrok` and `@stim-cli/transport-cloudflared` for + pairing and paired connections. +5. Move Metro reach onto the transports, support remote machines over + transports without discovery, and carry hosted Metro over the WebSocket. From 8e772167cb3ca1f19481db4e93d4b5ce299e3cdf Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 13 Oct 2026 08:13:35 -0400 Subject: [PATCH 2/4] refactor: move Tailscale behind a transport contract Add @stim-cli/transport, the contract and loader for stim-server transports, and @stim-cli/transport-tailscale, which holds the Tailscale driver moved out of packages/server. stim-server loads the Tailscale transport through the loader and reaches peer identity and its extra listen addresses only through the contract. The /health fields, route.setup and paired machine pinning still read the Tailscale transport directly until the generic status and resolve capabilities land. No behavior change. Refs #3681. --- .github/workflows/release.yml | 20 ++- RELEASE.md | 6 +- apps/desktop/scripts/server-runtime.mjs | 2 +- docs/release-recovery.md | 2 + .../2026-10-13-pluggable-transports-design.md | 19 ++- package.json | 2 +- .../server/__tests__/loopback-only.test.ts | 20 ++- .../server/__tests__/machine-update.test.ts | 4 + packages/server/__tests__/server.test.ts | 3 +- .../server/__tests__/setup-endpoint.test.ts | 33 +++-- packages/server/__tests__/setup.test.ts | 2 +- packages/server/__tests__/shutdown.test.ts | 20 ++- packages/server/bin/stim-server.ts | 36 +++-- packages/server/package.json | 2 + packages/server/src/server.ts | 92 ++++++++---- packages/server/src/service-plist.ts | 32 +--- packages/server/src/service.ts | 2 +- packages/server/src/setup.ts | 5 +- packages/transport-tailscale/LICENSE | 21 +++ packages/transport-tailscale/README.md | 7 + .../__tests__/tailscale-monitor.test.ts | 0 packages/transport-tailscale/index.ts | 92 ++++++++++++ packages/transport-tailscale/package.json | 36 +++++ .../transport-tailscale/src/serve-plan.ts | 31 ++++ .../src/tailscale-monitor.ts | 0 .../src/tailscale.ts | 14 +- packages/transport-tailscale/tsconfig.json | 9 ++ .../transport-tailscale/tsdown.config.mts | 12 ++ packages/transport/LICENSE | 21 +++ packages/transport/README.md | 29 ++++ packages/transport/__tests__/load.test.ts | 52 +++++++ packages/transport/index.ts | 138 ++++++++++++++++++ packages/transport/package.json | 32 ++++ packages/transport/tsconfig.json | 10 ++ packages/transport/tsdown.config.mts | 12 ++ pnpm-lock.yaml | 17 +++ scripts/release-prep.mjs | 13 +- scripts/release-qa-matrix.data.mjs | 2 + scripts/type-assertion-baseline.json | 24 ++- scripts/type-assertion-ratchet.mjs | 2 + test/e2e/release-prep.e2e.js | 13 +- test/runtime-floor.mjs | 15 +- 42 files changed, 770 insertions(+), 134 deletions(-) create mode 100644 packages/transport-tailscale/LICENSE create mode 100644 packages/transport-tailscale/README.md rename packages/{server => transport-tailscale}/__tests__/tailscale-monitor.test.ts (100%) create mode 100644 packages/transport-tailscale/index.ts create mode 100644 packages/transport-tailscale/package.json create mode 100644 packages/transport-tailscale/src/serve-plan.ts rename packages/{server => transport-tailscale}/src/tailscale-monitor.ts (100%) rename packages/{server => transport-tailscale}/src/tailscale.ts (97%) create mode 100644 packages/transport-tailscale/tsconfig.json create mode 100644 packages/transport-tailscale/tsdown.config.mts create mode 100644 packages/transport/LICENSE create mode 100644 packages/transport/README.md create mode 100644 packages/transport/__tests__/load.test.ts create mode 100644 packages/transport/index.ts create mode 100644 packages/transport/package.json create mode 100644 packages/transport/tsconfig.json create mode 100644 packages/transport/tsdown.config.mts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74ae04d91..1cf6d18de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: outputs: dist_tag: ${{ steps.dist-tag.outputs.dist_tag }} env: - PACKAGE_DIRS: core cache cache-s3 metro expo-build-cache stim-cli ci server + PACKAGE_DIRS: core cache cache-s3 metro expo-build-cache stim-cli ci transport transport-tailscale server steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 @@ -194,6 +194,22 @@ jobs: else npm publish "$RUNNER_TEMP/tarballs/ci.tgz" --provenance --access public --tag "$DIST_TAG" fi + - name: Publish @stim-cli/transport + run: | + version=$(node -p "require('$RUNNER_TEMP/tarballs/transport.package.json').version") + if npm view "@stim-cli/transport@$version" version >/dev/null 2>&1; then + echo "@stim-cli/transport@$version is already published" + else + npm publish "$RUNNER_TEMP/tarballs/transport.tgz" --provenance --access public --tag "$DIST_TAG" + fi + - name: Publish @stim-cli/transport-tailscale + run: | + version=$(node -p "require('$RUNNER_TEMP/tarballs/transport-tailscale.package.json').version") + if npm view "@stim-cli/transport-tailscale@$version" version >/dev/null 2>&1; then + echo "@stim-cli/transport-tailscale@$version is already published" + else + npm publish "$RUNNER_TEMP/tarballs/transport-tailscale.tgz" --provenance --access public --tag "$DIST_TAG" + fi - name: Publish @stim-cli/server run: | version=$(node -p "require('$RUNNER_TEMP/tarballs/server.package.json').version") @@ -215,7 +231,7 @@ jobs: permissions: contents: read env: - PACKAGE_DIRS: core cache cache-s3 metro expo-build-cache stim-cli ci server + PACKAGE_DIRS: core cache cache-s3 metro expo-build-cache stim-cli ci transport transport-tailscale server DIST_TAG: ${{ needs.publish.outputs.dist_tag }} npm_config_prefer_online: 'true' steps: diff --git a/RELEASE.md b/RELEASE.md index a293a2460..fd9f19cc6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -20,6 +20,8 @@ packages/cache @stim-cli/cache cache provider contra packages/cache-s3 @stim-cli/cache-s3 optional S3-compatible cache provider packages/expo-build-cache @stim-cli/expo-build-cache Expo build cache provider packages/metro @stim-cli/metro shared Metro transform cache + log reporter +packages/transport @stim-cli/transport transport contract for stim-server +packages/transport-tailscale @stim-cli/transport-tailscale Tailscale transport for stim-server packages/server @stim-cli/server stim-server, read-only status for paired clients ``` @@ -82,7 +84,7 @@ Every package name must already exist on npm, because trusted publishing can only be configured for an existing package: ```bash -for p in @stim-cli/core @stim-cli/cache @stim-cli/cache-s3 @stim-cli/metro @stim-cli/expo-build-cache stim @stim-cli/ci @stim-cli/server; do +for p in @stim-cli/core @stim-cli/cache @stim-cli/cache-s3 @stim-cli/metro @stim-cli/expo-build-cache stim @stim-cli/ci @stim-cli/transport @stim-cli/transport-tailscale @stim-cli/server; do npm view "$p" name >/dev/null || echo "$p: first publication required" done ``` @@ -190,7 +192,7 @@ preparation, `git status --short` may show only the draft ```bash out=$(mktemp -d) - for p in core cache cache-s3 metro expo-build-cache stim-cli ci server; do + for p in core cache cache-s3 metro expo-build-cache stim-cli ci transport transport-tailscale server; do tgz=$(cd "packages/$p" && pnpm pack --pack-destination "$out" | tail -1) echo "== $p ($(tar -tzf "$tgz" | wc -l | tr -d ' ') files)" tar -tzf "$tgz" | grep -E 'README|LICENSE' diff --git a/apps/desktop/scripts/server-runtime.mjs b/apps/desktop/scripts/server-runtime.mjs index eaef9af78..5bfad62dd 100644 --- a/apps/desktop/scripts/server-runtime.mjs +++ b/apps/desktop/scripts/server-runtime.mjs @@ -22,7 +22,7 @@ const NODE_SHA256 = { arm64: '6239d4cf92d864487ec8cd3615038f7b67e7f58b77b21cd2f09ea9fbd68065fe', x64: '0ae5a24c24bb7d015cd816c5036b3f90f2945aa872fcf54e58da054753b3a299', }; -const PACKAGES = ['core', 'cache', 'metro', 'stim-cli', 'server']; +const PACKAGES = ['core', 'cache', 'metro', 'stim-cli', 'transport', 'transport-tailscale', 'server']; const usage = 'usage: node scripts/server-runtime.mjs [--universal]'; const args = process.argv.slice(2); diff --git a/docs/release-recovery.md b/docs/release-recovery.md index c59f36666..d9090f4cc 100644 --- a/docs/release-recovery.md +++ b/docs/release-recovery.md @@ -62,6 +62,8 @@ pnpm --filter @stim-cli/cache-s3 publish --access public --tag --otp pnpm --filter @stim-cli/metro publish --access public --tag --otp pnpm --filter @stim-cli/expo-build-cache publish --access public --tag --otp pnpm --filter stim publish --access public --tag --otp +pnpm --filter @stim-cli/transport publish --access public --tag --otp +pnpm --filter @stim-cli/transport-tailscale publish --access public --tag --otp pnpm --filter @stim-cli/server publish --access public --tag --otp ``` diff --git a/docs/specs/2026-10-13-pluggable-transports-design.md b/docs/specs/2026-10-13-pluggable-transports-design.md index 1ec288ab6..311c310d6 100644 --- a/docs/specs/2026-10-13-pluggable-transports-design.md +++ b/docs/specs/2026-10-13-pluggable-transports-design.md @@ -19,12 +19,12 @@ and Stim Desktop bundles them. They may become optional installs later. Stim uses Tailscale for four separate things. The contract keeps them separate, because the other backends provide only some of them. -| Role | Today | Contract capability | -|---|---|---| -| Exposure and TLS | `tailscale serve` on a tailnet-only HTTPS port | `expose`, `setup` | -| Peer identity | `tailscale whois` gives the node a device token is bound to | `identify` | -| Discovery and pinning | `tailscale status --json` lists peer machines; a name that moves to another node is refused | `discover`, `resolve` | -| Bind addresses | The server also listens on the Tailscale addresses; the hosted Metro gateway trusts WireGuard source addresses | `bindAddresses`, `traits.privateNetwork` | +| Role | Today | Contract capability | +| --------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| Exposure and TLS | `tailscale serve` on a tailnet-only HTTPS port | `expose`, `setup` | +| Peer identity | `tailscale whois` gives the node a device token is bound to | `identify` | +| Discovery and pinning | `tailscale status --json` lists peer machines; a name that moves to another node is refused | `discover`, `resolve` | +| Bind addresses | The server also listens on the Tailscale addresses; the hosted Metro gateway trusts WireGuard source addresses | `bindAddresses`, `traits.privateNetwork` | The pairing core does not depend on the transport and does not change: the QR payload, `hello` with a pairing token and then a device token, the token @@ -61,8 +61,11 @@ registry, and grant and revoke. export const apiVersion = 1; export const id: string; export const optionsSchema: JSONSchema; // x-stim annotations, rendered by Desktop -export function createTransport(input: { stimHome: string; env: NodeJS.ProcessEnv; options: Record }): - Transport | Promise; +export function createTransport(input: { + stimHome: string; + env: NodeJS.ProcessEnv; + options: Record; +}): Transport | Promise; interface Transport { id: string; diff --git a/package.json b/package.json index 9511a24f1..e2dd3df81 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ ], "type": "module", "scripts": { - "typecheck": "tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/cache/tsconfig.json && tsc --noEmit -p packages/cache-s3/tsconfig.json && tsc --noEmit -p packages/stim-cli/tsconfig.json && tsc --noEmit -p packages/metro/tsconfig.json && tsc --noEmit -p packages/expo-build-cache/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/ci/tsconfig.json && tsc --noEmit -p .github/actions/stim-ci/tsconfig.json", + "typecheck": "tsc --noEmit -p packages/core/tsconfig.json && tsc --noEmit -p packages/cache/tsconfig.json && tsc --noEmit -p packages/cache-s3/tsconfig.json && tsc --noEmit -p packages/stim-cli/tsconfig.json && tsc --noEmit -p packages/metro/tsconfig.json && tsc --noEmit -p packages/expo-build-cache/tsconfig.json && tsc --noEmit -p packages/server/tsconfig.json && tsc --noEmit -p packages/transport/tsconfig.json && tsc --noEmit -p packages/transport-tailscale/tsconfig.json && tsc --noEmit -p packages/ci/tsconfig.json && tsc --noEmit -p .github/actions/stim-ci/tsconfig.json", "lint": "oxlint . && node scripts/type-assertion-ratchet.mjs", "lint:fix": "oxlint --fix .", "format": "oxfmt .", diff --git a/packages/server/__tests__/loopback-only.test.ts b/packages/server/__tests__/loopback-only.test.ts index d628c46a7..fd8b52fe4 100644 --- a/packages/server/__tests__/loopback-only.test.ts +++ b/packages/server/__tests__/loopback-only.test.ts @@ -18,13 +18,19 @@ vi.mock('../src/environment.ts', () => ({ vi.mock('@stim-cli/core/toolchain', () => ({ discoverToolchain: (env: NodeJS.ProcessEnv) => ({ env: { ...env }, entries: [] }), })); -vi.mock('../src/tailscale.ts', () => ({ - findTailscale: () => null, - readTailscaleStatus: async () => ({ state: 'unavailable', reason: 'fixture' }), -})); -vi.mock('../src/tailscale-monitor.ts', () => ({ - watchTailscale: () => ({ stop: () => {}, onChange: () => {} }), -})); +vi.mock('@stim-cli/transport-tailscale', async (original) => { + const actual = await original(); + const snapshot = { binary: null, state: { state: 'unavailable' as const, reason: 'fixture' } }; + return { + ...actual, + createTransport: async ({ env }: { env: NodeJS.ProcessEnv }) => + actual.tailscaleTransport({ + env, + snapshot, + monitor: { current: () => snapshot, stop: () => {}, onChange: () => () => {} }, + }), + }; +}); async function serve(...flags: string[]): Promise> { const home = mkdtempSync(join(tmpdir(), 'stim-server-loopback-')); diff --git a/packages/server/__tests__/machine-update.test.ts b/packages/server/__tests__/machine-update.test.ts index d12ffa43d..88677bf0a 100644 --- a/packages/server/__tests__/machine-update.test.ts +++ b/packages/server/__tests__/machine-update.test.ts @@ -22,6 +22,8 @@ describe('client build packing', () => { '@stim-cli/core', '@stim-cli/metro', '@stim-cli/server', + '@stim-cli/transport', + '@stim-cli/transport-tailscale', 'stim', ]); expect(workspacePackages(join(SERVER, 'node_modules', '@stim-cli', 'server'))).toBeNull(); @@ -142,6 +144,8 @@ describe('asking a remote Mac to update', () => { 'stim-cli-core', 'stim-cli-metro', 'stim-cli-server', + 'stim-cli-transport', + 'stim-cli-transport-tailscale', ]); for (const { name, size } of offered) { const chunks = calls.filter((call) => call.method === 'server.update.chunk' && call.params.name === name); diff --git a/packages/server/__tests__/server.test.ts b/packages/server/__tests__/server.test.ts index fd0514150..a83be6c91 100644 --- a/packages/server/__tests__/server.test.ts +++ b/packages/server/__tests__/server.test.ts @@ -66,8 +66,7 @@ import { readDevices, revokeDevice, } from '../src/registry.ts'; -import { watchTailscale } from '../src/tailscale-monitor.ts'; -import type { TailscaleState } from '../src/tailscale.ts'; +import { watchTailscale, type TailscaleState } from '@stim-cli/transport-tailscale'; import { createRequestLog } from '../src/request-log.ts'; import { startServer, upgradeFromWebPage, type RunningServer, type ServerOptions } from '../src/server.ts'; import { workspaceName, workspaceStateDir } from '@stim-cli/core'; diff --git a/packages/server/__tests__/setup-endpoint.test.ts b/packages/server/__tests__/setup-endpoint.test.ts index 1655107c6..86641bab9 100644 --- a/packages/server/__tests__/setup-endpoint.test.ts +++ b/packages/server/__tests__/setup-endpoint.test.ts @@ -8,12 +8,9 @@ import { readBuildClients, readDeviceHostClients } from '../src/registry.ts'; import { startServer, type RunningServer } from '../src/server.ts'; import { writeSetupJournal } from '../src/setup-journal.ts'; import { probeDirectories } from '../src/startup.ts'; -import { whois } from '../src/tailscale.ts'; +import { tailscaleTransport, type TailnetIdentity } from '@stim-cli/transport-tailscale'; -vi.mock('../src/tailscale.ts', async (original) => ({ - ...(await original()), - whois: vi.fn(), -})); +const whois = vi.fn<(address: string) => Promise>(); vi.mock('../src/startup.ts', async (original) => { const actual = await original(); return { ...actual, probeDirectories: vi.fn(actual.probeDirectories) }; @@ -45,9 +42,7 @@ beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'stim-setup-http-')); process.env.STIM_HOME = home; server = null; - vi.mocked(whois) - .mockReset() - .mockImplementation(async (_binary, _env, address) => (address === peer ? identity : null)); + whois.mockReset().mockImplementation(async (address) => (address === peer ? identity : null)); }); afterEach(async () => { vi.restoreAllMocks(); @@ -64,8 +59,18 @@ async function start(wait = true): Promise { name: 'Test Mac', stimVersion: '1.2.3', serverVersion: '1.2.3', - tailscale: null, - tailscaleState: { state: 'not-running', backendState: 'Stopped' }, + transports: [ + { + ...tailscaleTransport({ + env: process.env, + snapshot: { binary: null, state: { state: 'not-running', backendState: 'Stopped' } }, + }), + async identify({ address }) { + const found = await whois(address); + return found && { transport: 'tailscale', id: found.nodeId, name: found.nodeName, user: found.user }; + }, + }, + ], env: process.env, record: false, history: false, @@ -129,7 +134,7 @@ test('serves only the bound node and exposes toolchain steps only after a build const base = await start(); const pending = journal(); writeSetupJournal(hash, pending); - vi.mocked(whois).mockResolvedValueOnce({ ...identity, nodeId: 'other' }); + whois.mockResolvedValueOnce({ ...identity, nodeId: 'other' }); expect(await refusal(await fetch(`${base}/setup/${hash}`, { headers: { 'x-forwarded-for': '100.64.0.3' } }))).toEqual( notFound, ); @@ -151,7 +156,7 @@ test('serves only the bound node and exposes toolchain steps only after a build test('coalesces a peer burst into one whois call and refreshes its identity after thirty seconds', async () => { const base = await start(); writeSetupJournal(hash, journal()); - vi.mocked(whois).mockImplementation(async () => { + whois.mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); return identity; }); @@ -169,7 +174,7 @@ test('coalesces a peer burst into one whois call and refreshes its identity afte test('counts node mismatches as failures and blocks repeated identity attempts without revealing journal data', async () => { const base = await start(); writeSetupJournal(hash, journal()); - vi.mocked(whois).mockResolvedValue({ ...identity, nodeId: 'other' }); + whois.mockResolvedValue({ ...identity, nodeId: 'other' }); for (let i = 0; i < 30; i++) { expect(await refusal(await fetch(`${base}/setup/${hash}`, { headers }))).toEqual(notFound); } @@ -183,7 +188,7 @@ test('counts node mismatches as failures and blocks repeated identity attempts w test('a transient whois failure does not lock out the bound node', async () => { const base = await start(); writeSetupJournal(hash, journal()); - vi.mocked(whois).mockResolvedValueOnce(null).mockResolvedValue(identity); + whois.mockResolvedValueOnce(null).mockResolvedValue(identity); expect((await fetch(`${base}/setup/${hash}`, { headers })).status).toBe(404); for (let i = 0; i < 40; i++) expect((await fetch(`${base}/setup/${hash}`, { headers })).status).toBe(200); expect(whois).toHaveBeenCalledTimes(2); diff --git a/packages/server/__tests__/setup.test.ts b/packages/server/__tests__/setup.test.ts index a0fa58cf3..73afb831b 100644 --- a/packages/server/__tests__/setup.test.ts +++ b/packages/server/__tests__/setup.test.ts @@ -17,7 +17,7 @@ import { setupVersionDecision, type SetupDeps, } from '../src/setup.ts'; -import { tailscaleInstall } from '../src/tailscale.ts'; +import { tailscaleInstall } from '@stim-cli/transport-tailscale'; import type { InstalledService } from '../src/service-plist.ts'; const now = Date.parse('2026-10-06T12:00:00Z'); diff --git a/packages/server/__tests__/shutdown.test.ts b/packages/server/__tests__/shutdown.test.ts index c343444c6..30784fd3a 100644 --- a/packages/server/__tests__/shutdown.test.ts +++ b/packages/server/__tests__/shutdown.test.ts @@ -16,13 +16,19 @@ vi.mock('../src/environment.ts', () => ({ vi.mock('@stim-cli/core/toolchain', () => ({ discoverToolchain: (env: NodeJS.ProcessEnv) => ({ env: { ...env }, entries: [] }), })); -vi.mock('../src/tailscale.ts', () => ({ - findTailscale: () => null, - readTailscaleStatus: async () => ({ state: 'unavailable', reason: 'fixture' }), -})); -vi.mock('../src/tailscale-monitor.ts', () => ({ - watchTailscale: () => ({ stop: fixture.monitorStop, onChange: () => {} }), -})); +vi.mock('@stim-cli/transport-tailscale', async (original) => { + const actual = await original(); + const snapshot = { binary: null, state: { state: 'unavailable' as const, reason: 'fixture' } }; + return { + ...actual, + createTransport: async ({ env }: { env: NodeJS.ProcessEnv }) => + actual.tailscaleTransport({ + env, + snapshot, + monitor: { current: () => snapshot, stop: fixture.monitorStop, onChange: () => () => {} }, + }), + }; +}); test.each([false, true])('SIGTERM exits after close (kept claims: %s)', async (failed) => { const home = mkdtempSync(join(tmpdir(), 'stim-server-shutdown-')); diff --git a/packages/server/bin/stim-server.ts b/packages/server/bin/stim-server.ts index 7b5ff4e18..eca93040e 100644 --- a/packages/server/bin/stim-server.ts +++ b/packages/server/bin/stim-server.ts @@ -40,16 +40,17 @@ import { import { hostFromExecutable, hostPermissionPanes } from '../src/stim-host.ts'; import { defaultSetupDeps, runSetup } from '../src/setup.ts'; import { startServer } from '../src/server.ts'; -import { watchTailscale } from '../src/tailscale-monitor.ts'; +import type { ServeRoute } from '@stim-cli/core/protocol'; +import { loadTransport } from '@stim-cli/transport'; import { findTailscale, serveCommand, serveRoute, tailnetEndpoint, readTailscaleStatus, - type ServeRoute, + isTailscaleTransport, type TailscaleState, -} from '../src/tailscale.ts'; +} from '@stim-cli/transport-tailscale'; const USAGE = `Usage: stim-server [--port ] [--loopback-only] [--env KEY=VALUE]... [--path-prepend ]... @@ -128,10 +129,17 @@ async function serve(port: number, extraEnv: string[], pathPrepend: string[], lo Object.assign(process.env, env); const launchdLabel = process.env.XPC_SERVICE_NAME; - const tailscaleBinary = findTailscale(env); - const tailscale = await readTailscaleStatus(tailscaleBinary, env); + const loaded = await loadTransport({ + reference: '@stim-cli/transport-tailscale', + load: () => import('@stim-cli/transport-tailscale'), + input: { env, options: {} }, + }); + if ('unavailable' in loaded) fail(`the Tailscale transport is unavailable: ${loaded.unavailable}`); + if (!isTailscaleTransport(loaded.transport)) + fail('@stim-cli/transport-tailscale did not create a Tailscale transport'); + const transport = loaded.transport; + const { state: tailscale } = transport.snapshot(); const stim = bundledStim(); - const monitor = watchTailscale({ env, initial: { binary: tailscaleBinary, state: tailscale } }); let server; try { server = await startServer({ @@ -144,9 +152,7 @@ async function serve(port: number, extraEnv: string[], pathPrepend: string[], lo stimVersion: stim.version, serverVersion: pkg.version, env, - tailscale: tailscaleBinary, - tailscaleState: tailscale, - tailscaleMonitor: monitor, + transports: [transport], service: { label: launchdLabel && !validateLabel(launchdLabel) ? launchdLabel : null, node: process.execPath, @@ -163,10 +169,10 @@ async function serve(port: number, extraEnv: string[], pathPrepend: string[], lo for (const { host, port: bound } of server.addresses) { console.log(`listening on ws://${host.includes(':') ? `[${host}]` : host}:${bound}`); } - const announce = async (binary: string | null, state: TailscaleState) => { + const announce = async (state: TailscaleState) => { let note = tailscaleNote(state); if (state.state === 'running' && state.dnsName) { - const route = await serveRoute(binary, env, port, state.ips); + const route = await transport.serveRoute(port); if (route.state !== 'funneled') console.log(`tailnet endpoint: ${tailnetEndpoint(state.dnsName, route.port)}`); note = routeNote(route, port); if (route.state === 'funneled') note = `${note} Pairing is refused until then.`; @@ -174,15 +180,15 @@ async function serve(port: number, extraEnv: string[], pathPrepend: string[], lo if (note) console.error(note); }; if (!loopbackOnly) - monitor.onChange((snapshot, previous) => { + transport.monitor?.onChange((snapshot, previous) => { const { state } = snapshot; if (state.state === 'running') console.log('Tailscale is running.'); else if (previous.state.state === 'running') console.error('Tailscale stopped.'); - if (state.state === 'running' || previous.state.state === 'running') void announce(snapshot.binary, state); + if (state.state === 'running' || previous.state.state === 'running') void announce(state); }); - if (!loopbackOnly) await announce(tailscaleBinary, tailscale); + if (!loopbackOnly) await announce(tailscale); const shutdown = () => { - monitor.stop(); + transport.close?.(); void server .close() .catch((error: unknown) => { diff --git a/packages/server/package.json b/packages/server/package.json index 84558c311..29a35328a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -33,6 +33,8 @@ "dependencies": { "@stim-cli/cache": "workspace:^", "@stim-cli/core": "workspace:^", + "@stim-cli/transport": "workspace:^", + "@stim-cli/transport-tailscale": "workspace:^", "stim": "workspace:*", "ws": "^8.21.3" }, diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index f7e5f2afe..7211943e9 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -144,15 +144,15 @@ import { ServerUpdates } from './server-update.ts'; import { MachineUpdates } from './machine-update.ts'; import { Pending, runStim, type CommandLimits } from './stim-command.ts'; import { readHostPermissions } from './stim-host.ts'; +import type { Transport, TransportIdentity } from '@stim-cli/transport'; import { - readRawTailscaleStatus, - serveRoute, - setupServeRoute, - whois, - type ServeRoute, + isTailscaleTransport, + tailscaleTransport, + type TailscaleMonitor, type TailscaleState, -} from './tailscale.ts'; -import type { TailscaleMonitor, TailscaleSnapshot } from './tailscale-monitor.ts'; + type TailscaleTransport, +} from '@stim-cli/transport-tailscale'; +import type { ServeRoute } from '@stim-cli/core/protocol'; import { DEFAULT_VIDEO_LIMITS, videoPacket, VideoGate, type AccessUnit } from './video.ts'; import { DeviceViewers } from './viewers.ts'; @@ -195,8 +195,14 @@ export interface ServerOptions { stimVersion: string; serverVersion: string; env: NodeJS.ProcessEnv; - tailscale: string | null; - tailscaleState: TailscaleState; + /** + * The transports that carry phones and other machines to this server. Without them, the server uses a Tailscale + * transport built from `tailscale`, `tailscaleState` and `tailscaleMonitor`. The server listens on each + * transport's bind addresses while it reports them. + */ + transports?: Transport[]; + tailscale?: string | null; + tailscaleState?: TailscaleState; /** * Keeps the Tailscale state current after start. Without it, `tailscale` and `tailscaleState` stay as * given. While Tailscale runs the server listens on its addresses, and it closes them when Tailscale stops. @@ -411,6 +417,25 @@ function peerAddress(request: IncomingMessage): string | null { return first || null; } +function registryIdentity(identity: TransportIdentity | null): PeerIdentity | null { + if (identity?.transport !== 'tailscale') return null; + return { kind: 'tailnet', nodeId: identity.id, nodeName: identity.name, user: identity.user }; +} + +const NO_TAILSCALE: TailscaleState = { state: 'unavailable', reason: 'the tailscale command was not found' }; + +function serverTransports(options: ServerOptions): { transports: Transport[]; tailscale: TailscaleTransport } { + const given = options.transports?.find(isTailscaleTransport); + const tailscale = + given ?? + tailscaleTransport({ + env: options.env, + snapshot: { binary: options.tailscale ?? null, state: options.tailscaleState ?? NO_TAILSCALE }, + monitor: options.tailscaleMonitor ?? null, + }); + return { transports: options.transports ?? [tailscale], tailscale }; +} + const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']); /** @@ -590,6 +615,14 @@ function isLink(path: string): boolean { } export async function startServer(options: ServerOptions): Promise { + const { transports, tailscale } = serverTransports(options); + async function identifyPeer(request: IncomingMessage, address: string): Promise { + for (const transport of transports) { + const identity = registryIdentity((await transport.identify?.({ address, request })) ?? null); + if (identity) return identity; + } + return null; + } let homeId: string | undefined; let hostProbe: Promise | undefined; let hostExpires = 0; @@ -817,7 +850,7 @@ export async function startServer(options: ServerOptions): Promise null), + identity: identifyPeer(request, peer).catch(() => null), until: Date.now() + 30_000, }; setupNodes.set(peer, known); @@ -911,7 +944,7 @@ export async function startServer(options: ServerOptions): Promise readRawTailscaleStatus(tailscaleNow().binary, options.env), + status: () => tailscale.rawStatus(), endpoint: options.hostedRelay?.endpoint, }); const updates = new ServerUpdates({ @@ -966,7 +999,7 @@ export async function startServer(options: ServerOptions): Promise(); const hostConnections = new HostConnections( { - ...(options.hostedRelay ?? { status: () => readRawTailscaleStatus(tailscaleNow().binary, options.env) }), + ...(options.hostedRelay ?? { status: () => tailscale.rawStatus() }), log: requestLog.host, }, options.serverVersion, @@ -1028,7 +1061,7 @@ export async function startServer(options: ServerOptions): Promise | null = null; - function connection(socket: WebSocket, peer: string | null, localControl: boolean): void { + function connection(socket: WebSocket, request: IncomingMessage, peer: string | null, localControl: boolean): void { const limitKey = peer ?? 'local'; const subscriptions = new Map void>(); const keyframes = new Map void>(); @@ -1070,7 +1103,7 @@ export async function startServer(options: ServerOptions): Promise { if (peer === null) return { kind: 'local' }; - return isIP(peer) ? whois(tailscaleNow().binary, options.env, peer) : null; + return identifyPeer(request, peer); } async function hello(id: RequestId, params: unknown): Promise { @@ -2539,8 +2572,7 @@ export async function startServer(options: ServerOptions): Promise { + settingUpRoute ??= tailscale.setupRoute(addresses[0]!.port).finally(() => { settingUpRoute = null; }); try { @@ -2909,11 +2941,9 @@ export async function startServer(options: ServerOptions): Promise request.headers[header] === undefined, ); - wss.handleUpgrade(request, socket, head, (ws) => connection(ws, peer, localControl)); + wss.handleUpgrade(request, socket, head, (ws) => connection(ws, request, peer, localControl)); } - const tailscaleNow = (): TailscaleSnapshot => - options.tailscaleMonitor?.current() ?? { binary: options.tailscale, state: options.tailscaleState }; const health = { server: 'stim-server', name: options.name, @@ -2924,17 +2954,17 @@ export async function startServer(options: ServerOptions): Promise { - const { binary, state: tailscale } = tailscaleNow(); + const { state } = tailscale.snapshot(); const route = - tailscale.state === 'running' && tailscale.dnsName - ? await serveRoute(binary, options.env, addresses[0]!.port, tailscale.ips, HEALTH_ROUTE_TIMEOUT_MS) + state.state === 'running' && state.dnsName + ? await tailscale.serveRoute(addresses[0]!.port, HEALTH_ROUTE_TIMEOUT_MS) : undefined; const body: ServerHealth = { ...health, startup, ...(options.host ? { host: await hostHealth() } : {}), busy: { builds: builds.running(), hostedSessions: hostedDevices.active() }, - tailscale: healthTailscale(tailscale), + tailscale: healthTailscale(state), route, nativeViewerOpened, ...(options.loopbackOnly ? { loopbackOnly: true as const } : {}), @@ -3054,11 +3084,11 @@ export async function startServer(options: ServerOptions): Promise undefined); throw error; } - const monitor = options.tailscaleMonitor; + const watched = transports.filter((transport) => transport.watch); const listenFailures = new Set(); - const reconcile = async (snapshot: TailscaleSnapshot) => { - const tailnet = snapshot.state.state === 'running' && !options.loopbackOnly ? snapshot.state.ips : []; - const wanted = new Set([...options.hosts, ...tailnet]); + const reconcile = async () => { + const extra = options.loopbackOnly ? [] : watched.flatMap((transport) => transport.bindAddresses?.() ?? []); + const wanted = new Set([...options.hosts, ...extra]); for (const [host, listener] of servers) { if (wanted.has(host)) continue; servers.delete(host); @@ -3091,11 +3121,11 @@ export async function startServer(options: ServerOptions): Promise { if (retry) clearTimeout(retry); retry = null; - if (monitor) reconciling = reconciling.then(() => reconcile(monitor.current())); + if (watched.length) reconciling = reconciling.then(reconcile); }; queueReconcile(); await reconciling; - const stopWatching = monitor?.onChange(queueReconcile); + const stopWatching = watched.flatMap((transport) => (transport.watch ? [transport.watch(queueReconcile)] : [])); let settle!: (state: StartupState) => void; const ready = new Promise((resolve) => { settle = resolve; @@ -3138,7 +3168,7 @@ export async function startServer(options: ServerOptions): Promise { - stopWatching?.(); + for (const stop of stopWatching) stop(); const closed = close(); if (retry) clearTimeout(retry); await Promise.all([reconciling, closed]); diff --git a/packages/server/src/service-plist.ts b/packages/server/src/service-plist.ts index 9b9fd13b0..6d68635da 100644 --- a/packages/server/src/service-plist.ts +++ b/packages/server/src/service-plist.ts @@ -1,6 +1,6 @@ import { delimiter, isAbsolute, join, relative } from 'node:path'; import { isJsonObject } from '@stim-cli/core/state'; -import type { ServeRoute } from './tailscale.ts'; +import type { ServeRecord } from '@stim-cli/transport-tailscale'; export class ServiceError extends Error {} @@ -12,11 +12,7 @@ const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; const JOB_KEYS = ['STIM_HOME', 'SHELL']; -export interface ServeRecord { - port: number; - /** Whether `service install` created the `tailscale serve` route, so `uninstall` removes it. */ - created: boolean; -} +export { planServe, type ServeRecord } from '@stim-cli/transport-tailscale'; export interface ServiceSpec { label: string; @@ -298,30 +294,6 @@ export function parseLaunchctlPrint(output: string): LaunchdJob { }; } -/** - * What `install --serve` does about the node's `tailscale serve` config: refuse a Funnel route or an unreadable - * config, keep a route that already reaches the port (remembering whether an earlier install created it), or - * create one. - */ -export function planServe( - route: ServeRoute, - target: number, - previous: ServeRecord | null, -): { record: ServeRecord; create: boolean } | { refusal: string } { - if (route.state === 'funneled') { - return { - refusal: `refusing to serve. Tailscale Funnel is on for port ${route.ports.join(', ')}, which proxies to port ${target}, so it is reachable from the public internet. Remove that handler (see \`tailscale serve status\`), then run install again.`, - }; - } - if (route.state === 'unknown') { - return { refusal: `could not read \`tailscale serve status --json\` (${route.reason}); not changing the route.` }; - } - if (route.state === 'routed') { - return { record: { port: route.port, created: previous?.port === route.port && previous.created }, create: false }; - } - return { record: { port: route.port, created: true }, create: true }; -} - const parseVersion = (v: string) => /^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+.*)?$/.exec(v); export function compareVersions(a: string, b: string): number { diff --git a/packages/server/src/service.ts b/packages/server/src/service.ts index 7f2287965..7fa65c9f3 100644 --- a/packages/server/src/service.ts +++ b/packages/server/src/service.ts @@ -38,7 +38,7 @@ import { lastLines, } from './service-plist.ts'; import { hostPermissionPanes, installHostApp, requestHostPermissions, type HostApp } from './stim-host.ts'; -import { findTailscale, readTailscaleStatus, serveCommand, serveRoute } from './tailscale.ts'; +import { findTailscale, readTailscaleStatus, serveCommand, serveRoute } from '@stim-cli/transport-tailscale'; import type { StartupState } from './startup.ts'; const LAUNCHCTL_TIMEOUT_MS = 15_000; diff --git a/packages/server/src/setup.ts b/packages/server/src/setup.ts index 81e1f95f9..12d8aa5b0 100644 --- a/packages/server/src/setup.ts +++ b/packages/server/src/setup.ts @@ -5,7 +5,7 @@ import { createInterface, type Interface } from 'node:readline/promises'; import { parseArgs } from 'node:util'; import { claimRemoveCommand, releaseClaim, tryAcquireClaim } from '@stim-cli/core/ownership-claim'; import { isJsonObject, stimBuildDigest, type SetupCapability, type SetupJournal } from '@stim-cli/core/state'; -import type { BuildToolchain } from '@stim-cli/core/protocol'; +import type { BuildToolchain, ServeRoute } from '@stim-cli/core/protocol'; import { BuildHost } from './build.ts'; import { discoverToolchain } from '@stim-cli/core/toolchain'; import { bundledStim } from './environment.ts'; @@ -45,9 +45,8 @@ import { readRawTailscaleStatus, serveRoute, tailscaleInstall, - type ServeRoute, type TailscaleInstall, -} from './tailscale.ts'; +} from '@stim-cli/transport-tailscale'; const SETUP_MIN_VERSION = '1.16.0'; diff --git a/packages/transport-tailscale/LICENSE b/packages/transport-tailscale/LICENSE new file mode 100644 index 000000000..021991ea6 --- /dev/null +++ b/packages/transport-tailscale/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Janic Duplessis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/transport-tailscale/README.md b/packages/transport-tailscale/README.md new file mode 100644 index 000000000..207d65fdb --- /dev/null +++ b/packages/transport-tailscale/README.md @@ -0,0 +1,7 @@ +# @stim-cli/transport-tailscale + +The Tailscale transport for stim-server. It serves stim-server on a tailnet-only `tailscale serve` HTTPS route, never +on Funnel, identifies each peer with `tailscale whois`, and listens on the Mac's Tailscale addresses while Tailscale +runs. It implements the [`@stim-cli/transport`](../transport/README.md) contract. + +stim-server depends on it and loads it at start; it needs no configuration. diff --git a/packages/server/__tests__/tailscale-monitor.test.ts b/packages/transport-tailscale/__tests__/tailscale-monitor.test.ts similarity index 100% rename from packages/server/__tests__/tailscale-monitor.test.ts rename to packages/transport-tailscale/__tests__/tailscale-monitor.test.ts diff --git a/packages/transport-tailscale/index.ts b/packages/transport-tailscale/index.ts new file mode 100644 index 000000000..9f5595235 --- /dev/null +++ b/packages/transport-tailscale/index.ts @@ -0,0 +1,92 @@ +import type { ServeRoute } from '@stim-cli/core/protocol'; +import { isIP } from 'node:net'; +import { TRANSPORT_API_VERSION, type Transport, type TransportInput } from '@stim-cli/transport'; +import { + findTailscale, + readRawTailscaleStatus, + readTailscaleStatus, + serveRoute, + setupServeRoute, + whois, +} from './src/tailscale.ts'; +import { watchTailscale, type TailscaleMonitor, type TailscaleSnapshot } from './src/tailscale-monitor.ts'; + +export { planServe, type ServeRecord } from './src/serve-plan.ts'; +export { + findTailscale, + readRawTailscaleStatus, + readTailscaleStatus, + serveCommand, + serveRoute, + tailnetEndpoint, + tailscaleInstall, + type TailnetIdentity, + type TailscaleInstall, + type TailscaleState, +} from './src/tailscale.ts'; +export { watchTailscale, type TailscaleMonitor, type TailscaleSnapshot } from './src/tailscale-monitor.ts'; + +export const apiVersion: typeof TRANSPORT_API_VERSION = TRANSPORT_API_VERSION; +export const id = 'tailscale'; + +/** + * The Tailscale transport, plus what stim-server still reads from Tailscale directly: the snapshot behind the + * `tailscale` and `route` fields of `/health`, the raw status that pins paired machines, and `route.setup`. + */ +export interface TailscaleTransport extends Transport { + id: typeof id; + snapshot(): TailscaleSnapshot; + monitor: TailscaleMonitor | null; + rawStatus(): Promise; + serveRoute(target: number, timeoutMs?: number): Promise; + setupRoute(target: number): Promise; +} + +export function isTailscaleTransport(transport: Transport): transport is TailscaleTransport { + return transport.id === id && 'snapshot' in transport && typeof transport.snapshot === 'function'; +} + +export function tailscaleTransport({ + env, + snapshot, + monitor = null, +}: { + env: NodeJS.ProcessEnv; + snapshot: TailscaleSnapshot; + monitor?: TailscaleMonitor | null; +}): TailscaleTransport { + const now = () => monitor?.current() ?? snapshot; + return { + id, + traits: { privateNetwork: true, tls: 'end-to-end', peerIdentity: true }, + monitor, + snapshot: now, + bindAddresses() { + const { state } = now(); + return state.state === 'running' ? state.ips : []; + }, + watch: monitor ? (listener) => monitor.onChange(() => listener()) : undefined, + async identify({ address }) { + if (!isIP(address)) return null; + const identity = await whois(now().binary, env, address); + return identity && { transport: id, id: identity.nodeId, name: identity.nodeName, user: identity.user }; + }, + rawStatus: () => readRawTailscaleStatus(now().binary, env), + serveRoute(target, timeoutMs) { + const { binary, state } = now(); + return serveRoute(binary, env, target, state.state === 'running' ? state.ips : [], timeoutMs); + }, + setupRoute(target) { + const { binary, state } = now(); + return setupServeRoute(binary, env, target, state); + }, + close: () => monitor?.stop(), + }; +} + +export async function createTransport({ env }: TransportInput): Promise { + const binary = findTailscale(env); + const state = await readTailscaleStatus(binary, env); + const monitor = watchTailscale({ env, initial: { binary, state } }); + return tailscaleTransport({ env, snapshot: { binary, state }, monitor }); +} diff --git a/packages/transport-tailscale/package.json b/packages/transport-tailscale/package.json new file mode 100644 index 000000000..f2a6a2071 --- /dev/null +++ b/packages/transport-tailscale/package.json @@ -0,0 +1,36 @@ +{ + "name": "@stim-cli/transport-tailscale", + "version": "1.20.2", + "description": "Tailscale transport for stim-server: tailnet-only serve route, whois identity and peer pinning.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/appandflow/stim.git", + "directory": "packages/transport-tailscale" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "type": "module", + "main": "dist/index.mjs", + "types": "dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "module-sync": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@stim-cli/core": "workspace:^", + "@stim-cli/transport": "workspace:^" + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/packages/transport-tailscale/src/serve-plan.ts b/packages/transport-tailscale/src/serve-plan.ts new file mode 100644 index 000000000..833921659 --- /dev/null +++ b/packages/transport-tailscale/src/serve-plan.ts @@ -0,0 +1,31 @@ +import type { ServeRoute } from '@stim-cli/core/protocol'; + +export interface ServeRecord { + port: number; + /** Whether `service install` created the `tailscale serve` route, so `uninstall` removes it. */ + created: boolean; +} + +/** + * What `install --serve` does about the node's `tailscale serve` config: refuse a Funnel route or an unreadable + * config, keep a route that already reaches the port (remembering whether an earlier install created it), or + * create one. + */ +export function planServe( + route: ServeRoute, + target: number, + previous: ServeRecord | null, +): { record: ServeRecord; create: boolean } | { refusal: string } { + if (route.state === 'funneled') { + return { + refusal: `refusing to serve. Tailscale Funnel is on for port ${route.ports.join(', ')}, which proxies to port ${target}, so it is reachable from the public internet. Remove that handler (see \`tailscale serve status\`), then run install again.`, + }; + } + if (route.state === 'unknown') { + return { refusal: `could not read \`tailscale serve status --json\` (${route.reason}); not changing the route.` }; + } + if (route.state === 'routed') { + return { record: { port: route.port, created: previous?.port === route.port && previous.created }, create: false }; + } + return { record: { port: route.port, created: true }, create: true }; +} diff --git a/packages/server/src/tailscale-monitor.ts b/packages/transport-tailscale/src/tailscale-monitor.ts similarity index 100% rename from packages/server/src/tailscale-monitor.ts rename to packages/transport-tailscale/src/tailscale-monitor.ts diff --git a/packages/server/src/tailscale.ts b/packages/transport-tailscale/src/tailscale.ts similarity index 97% rename from packages/server/src/tailscale.ts rename to packages/transport-tailscale/src/tailscale.ts index 88e92566c..37200bc8a 100644 --- a/packages/server/src/tailscale.ts +++ b/packages/transport-tailscale/src/tailscale.ts @@ -3,12 +3,18 @@ import { execFile } from 'node:child_process'; import { accessSync, constants } from 'node:fs'; import { delimiter, join } from 'node:path'; import { isJsonObject } from '@stim-cli/core/state'; -import type { PeerIdentity } from './registry.ts'; -import { planServe } from './service-plist.ts'; +import { planServe } from './serve-plan.ts'; const MAC_APP_BINARY = '/Applications/Tailscale.app/Contents/MacOS/Tailscale'; const TIMEOUT_MS = 3000; +export interface TailnetIdentity { + kind: 'tailnet'; + nodeId: string; + nodeName: string; + user: string; +} + /** The tailnet-only HTTPS port the setup command serves stim-server on. */ const SERVE_PORT = 7443; @@ -274,7 +280,7 @@ export async function setupServeRoute( } /** Reads `tailscale whois --json`: the peer's node (`Node.StableID`, `Node.Name`) and user (`UserProfile.LoginName`). */ -function parseWhois(value: unknown): PeerIdentity | null { +function parseWhois(value: unknown): TailnetIdentity | null { if (!isJsonObject(value) || !isJsonObject(value.Node)) return null; const { StableID, Name } = value.Node; const login = isJsonObject(value.UserProfile) ? value.UserProfile.LoginName : undefined; @@ -287,7 +293,7 @@ function parseWhois(value: unknown): PeerIdentity | null { }; } -export function whois(binary: string | null, env: NodeJS.ProcessEnv, ip: string): Promise { +export function whois(binary: string | null, env: NodeJS.ProcessEnv, ip: string): Promise { if (!binary) return Promise.resolve(null); return new Promise((resolve) => { execFile(binary, ['whois', '--json', ip], { env, timeout: TIMEOUT_MS, encoding: 'utf8' }, (error, stdout) => { diff --git a/packages/transport-tailscale/tsconfig.json b/packages/transport-tailscale/tsconfig.json new file mode 100644 index 000000000..c1a3921bf --- /dev/null +++ b/packages/transport-tailscale/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "preserve", + "moduleResolution": "bundler", + "types": ["node", "vitest/globals"] + }, + "include": ["*.ts", "src/**/*.ts", "__tests__/**/*.ts"] +} diff --git a/packages/transport-tailscale/tsdown.config.mts b/packages/transport-tailscale/tsdown.config.mts new file mode 100644 index 000000000..081ccf372 --- /dev/null +++ b/packages/transport-tailscale/tsdown.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { index: 'index.ts' }, + format: 'esm', + dts: true, + outDir: 'dist', + target: 'node22.12', + platform: 'node', + tsconfig: 'tsconfig.json', + fixedExtension: true, +}); diff --git a/packages/transport/LICENSE b/packages/transport/LICENSE new file mode 100644 index 000000000..021991ea6 --- /dev/null +++ b/packages/transport/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Janic Duplessis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/transport/README.md b/packages/transport/README.md new file mode 100644 index 000000000..e66533ed3 --- /dev/null +++ b/packages/transport/README.md @@ -0,0 +1,29 @@ +# @stim-cli/transport + +The contract between stim-server and the transports that carry phones and other machines to it. A transport +package exports `apiVersion`, `id` and `createTransport()`, and stim-server loads it with `loadTransport()`: + +```ts +import { loadTransport } from '@stim-cli/transport'; + +const loaded = await loadTransport({ + reference: '@stim-cli/transport-tailscale', + load: () => import('@stim-cli/transport-tailscale'), + input: { env: process.env, options: {} }, +}); +if ('unavailable' in loaded) console.error(loaded.unavailable); +``` + +`loadTransport()` never throws. A missing package, a different `apiVersion`, a failing `createTransport()` or a +load that takes longer than 10 seconds comes back as `unavailable` with the reason. + +A transport declares its `traits` and implements the capabilities it has: + +- `bindAddresses()`: the addresses stim-server listens on, besides loopback, for the clients it carries. +- `watch(listener)`: called when those addresses or the transport's state may have changed. +- `identify({ address, request })`: the machine a connection comes from. stim-server binds a device token to it at + pairing and refuses the token from any other machine. +- `close()`: stops any background work. + +The design and the capabilities still to come are in +[docs/specs/2026-10-13-pluggable-transports-design.md](../../docs/specs/2026-10-13-pluggable-transports-design.md). diff --git a/packages/transport/__tests__/load.test.ts b/packages/transport/__tests__/load.test.ts new file mode 100644 index 000000000..d518b898e --- /dev/null +++ b/packages/transport/__tests__/load.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from 'vitest'; +import { loadTransport, TRANSPORT_API_VERSION, type Transport } from '../index.ts'; + +const input = { env: {}, options: {} }; +const transport: Transport = { id: 'fake', traits: { privateNetwork: false, tls: 'none', peerIdentity: false } }; +const load = (namespace: unknown) => loadTransport({ reference: 'fake', load: async () => namespace, input }); + +test('creates the transport of a module with the current apiVersion, named or as its default export', async () => { + const module = { apiVersion: TRANSPORT_API_VERSION, id: 'fake', createTransport: () => transport }; + expect(await load(module)).toEqual({ transport }); + expect(await load({ default: module })).toEqual({ transport }); +}); + +test.each([ + [ + 'a module that does not load', + new Error('Cannot find module fake\nRequire stack: x'), + /fake is not installed: Cannot find module fake$/, + ], + ['another apiVersion', { apiVersion: 2, createTransport: () => transport }, /expected apiVersion 1, found 2/], + ['no factory', { apiVersion: TRANSPORT_API_VERSION }, /does not export createTransport/], + [ + 'a failing factory', + { + apiVersion: TRANSPORT_API_VERSION, + createTransport: () => { + throw new Error('no binary'); + }, + }, + /createTransport\(\) failed: no binary/, + ], + [ + 'a factory that returns no transport', + { apiVersion: TRANSPORT_API_VERSION, createTransport: () => ({}) }, + /did not return a transport/, + ], +])('reports %s as unavailable instead of throwing', async (_case, namespace, reason) => { + const result = await loadTransport({ + reference: 'fake', + load: async () => { + if (namespace instanceof Error) throw namespace; + return namespace; + }, + input, + }); + expect(result).toEqual({ unavailable: expect.stringMatching(reason) }); +}); + +test('reports a load that never settles as unavailable after the timeout', async () => { + const result = await loadTransport({ reference: 'fake', load: () => new Promise(() => {}), input, timeoutMs: 20 }); + expect(result).toEqual({ unavailable: 'fake did not load within 20ms' }); +}); diff --git a/packages/transport/index.ts b/packages/transport/index.ts new file mode 100644 index 000000000..c3106dfb4 --- /dev/null +++ b/packages/transport/index.ts @@ -0,0 +1,138 @@ +import type { IncomingMessage } from 'node:http'; + +export const TRANSPORT_API_VERSION = 1 as const; + +const LOAD_TIMEOUT_MS = 10_000; + +export interface TransportTraits { + /** Traffic stays on a private overlay whose source addresses identify the sending machine. */ + privateNetwork: boolean; + /** Where TLS ends: on this machine, at a provider's edge, or nowhere. */ + tls: 'end-to-end' | 'edge' | 'none'; + /** `identify` names the machine a connection comes from, so a device token can be bound to it. */ + peerIdentity: boolean; +} + +/** The machine a connection comes from, as the transport identifies it. `id` stays the same across renames. */ +export interface TransportIdentity { + transport: string; + id: string; + name: string; + user: string; +} + +export interface TransportPeer { + /** The connection's source address as stim-server sees it, after any proxy the transport runs. */ + address: string; + request: IncomingMessage; +} + +export interface Transport { + id: string; + traits: TransportTraits; + /** The addresses stim-server listens on, besides loopback, for clients this transport carries. */ + bindAddresses?(): string[]; + /** Calls `listener` whenever `bindAddresses` or the transport's state may have changed. */ + watch?(listener: () => void): () => void; + identify?(peer: TransportPeer): Promise; + close?(): void; +} + +export interface TransportInput { + env: NodeJS.ProcessEnv; + options: Record; +} + +export interface TransportModule { + apiVersion: typeof TRANSPORT_API_VERSION; + id: string; + createTransport(input: TransportInput): Transport | Promise; +} + +export type LoadTransportResult = { transport: Transport } | { unavailable: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function reason(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? ''; +} + +function hasFactory( + value: Record, +): value is Record & Pick { + return typeof value.createTransport === 'function'; +} + +function isTransport(value: unknown): value is Transport { + return isRecord(value) && typeof value.id === 'string' && isRecord(value.traits); +} + +function moduleCandidate(namespace: unknown): unknown { + if (!isRecord(namespace)) return namespace; + return 'apiVersion' in namespace ? namespace : namespace.default; +} + +async function importTransport( + reference: string, + load: () => Promise, + input: TransportInput, +): Promise { + let namespace: unknown; + try { + namespace = await load(); + } catch (error) { + return { unavailable: `${reference} is not installed: ${reason(error)}` }; + } + const candidate = moduleCandidate(namespace); + if (!isRecord(candidate) || candidate.apiVersion !== TRANSPORT_API_VERSION) { + const found = isRecord(candidate) ? candidate.apiVersion : candidate; + return { + unavailable: `${reference}: expected apiVersion ${TRANSPORT_API_VERSION}, found ${JSON.stringify(found)}`, + }; + } + if (!hasFactory(candidate)) { + return { unavailable: `${reference} does not export createTransport()` }; + } + let transport: unknown; + try { + transport = await candidate.createTransport(input); + } catch (error) { + return { unavailable: `${reference}: createTransport() failed: ${reason(error)}` }; + } + if (!isTransport(transport)) { + return { unavailable: `${reference}: createTransport() did not return a transport` }; + } + return { transport }; +} + +/** + * Imports the transport module `reference` with `load` and creates its transport. Never throws: a missing module, a + * wrong `apiVersion`, a failing factory, or a load that takes longer than `timeoutMs` comes back as `unavailable` + * with the reason. + */ +export async function loadTransport({ + reference, + load, + input, + timeoutMs = LOAD_TIMEOUT_MS, +}: { + reference: string; + load: () => Promise; + input: TransportInput; + timeoutMs?: number; +}): Promise { + let timer: NodeJS.Timeout | undefined; + const expired = new Promise((resolve) => { + timer = setTimeout(() => resolve({ unavailable: `${reference} did not load within ${timeoutMs}ms` }), timeoutMs); + }); + try { + return await Promise.race([ + importTransport(reference, load, input).catch((error: unknown) => ({ unavailable: reason(error) })), + expired, + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/packages/transport/package.json b/packages/transport/package.json new file mode 100644 index 000000000..9e2dab8e3 --- /dev/null +++ b/packages/transport/package.json @@ -0,0 +1,32 @@ +{ + "name": "@stim-cli/transport", + "version": "1.20.2", + "description": "Transport contract for stim-server: how phones and other machines reach it.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/appandflow/stim.git", + "directory": "packages/transport" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "type": "module", + "main": "dist/index.mjs", + "types": "dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "module-sync": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown" + }, + "engines": { + "node": ">=22.12.0" + } +} diff --git a/packages/transport/tsconfig.json b/packages/transport/tsconfig.json new file mode 100644 index 000000000..a36ef36a9 --- /dev/null +++ b/packages/transport/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "exactOptionalPropertyTypes": true, + "module": "preserve", + "moduleResolution": "bundler", + "types": ["node", "vitest/globals"] + }, + "include": ["*.ts", "__tests__/**/*.ts"] +} diff --git a/packages/transport/tsdown.config.mts b/packages/transport/tsdown.config.mts new file mode 100644 index 000000000..081ccf372 --- /dev/null +++ b/packages/transport/tsdown.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { index: 'index.ts' }, + format: 'esm', + dts: true, + outDir: 'dist', + target: 'node22.12', + platform: 'node', + tsconfig: 'tsconfig.json', + fixedExtension: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9bbcc1ee..efe8148cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -431,6 +431,12 @@ importers: '@stim-cli/core': specifier: workspace:^ version: link:../core + '@stim-cli/transport': + specifier: workspace:^ + version: link:../transport + '@stim-cli/transport-tailscale': + specifier: workspace:^ + version: link:../transport-tailscale stim: specifier: workspace:* version: link:../stim-cli @@ -491,6 +497,17 @@ importers: specifier: 0.1.1 version: 0.1.1 + packages/transport: {} + + packages/transport-tailscale: + dependencies: + '@stim-cli/core': + specifier: workspace:^ + version: link:../core + '@stim-cli/transport': + specifier: workspace:^ + version: link:../transport + website: dependencies: '@docusaurus/core': diff --git a/scripts/release-prep.mjs b/scripts/release-prep.mjs index ac068d34a..9fcd1d728 100644 --- a/scripts/release-prep.mjs +++ b/scripts/release-prep.mjs @@ -3,7 +3,18 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const repositoryRoot = process.env.RELEASE_PREP_ROOT ?? join(import.meta.dirname, '..'); -const packageDirs = ['core', 'cache', 'cache-s3', 'metro', 'expo-build-cache', 'stim-cli', 'ci', 'server']; +const packageDirs = [ + 'core', + 'cache', + 'cache-s3', + 'metro', + 'expo-build-cache', + 'stim-cli', + 'ci', + 'transport', + 'transport-tailscale', + 'server', +]; const versionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][\dA-Za-z-]*))*)?$/; const acceptedRanges = new Set(['workspace:^', 'workspace:~', 'workspace:*']); diff --git a/scripts/release-qa-matrix.data.mjs b/scripts/release-qa-matrix.data.mjs index d2ce8d5b8..e4cc8be8d 100644 --- a/scripts/release-qa-matrix.data.mjs +++ b/scripts/release-qa-matrix.data.mjs @@ -73,6 +73,8 @@ export const pathRules = [ { path: 'packages/metro', rows: ['caches', 'logs'] }, { path: 'packages/expo-build-cache', rows: ['caches'] }, { path: 'packages/server', rows: ['loop'] }, + { path: 'packages/transport', rows: ['loop'] }, + { path: 'packages/transport-tailscale', rows: ['loop'] }, { path: 'packages/server/README.md', exempt: 'documentation' }, { path: 'packages/server/__tests__', exempt: 'unit tests, run by the section 2 preflight' }, { path: 'packages/ci', rows: ['loop'] }, diff --git a/scripts/type-assertion-baseline.json b/scripts/type-assertion-baseline.json index 46e9289ce..28f1390fb 100644 --- a/scripts/type-assertion-baseline.json +++ b/scripts/type-assertion-baseline.json @@ -91,8 +91,8 @@ }, "packages/server": { "src": { - "as": 311, - "nonNull": 137 + "as": 310, + "nonNull": 134 }, "test": { "as": 275, @@ -108,5 +108,25 @@ "as": 543, "nonNull": 793 } + }, + "packages/transport": { + "src": { + "as": 0, + "nonNull": 0 + }, + "test": { + "as": 0, + "nonNull": 0 + } + }, + "packages/transport-tailscale": { + "src": { + "as": 1, + "nonNull": 3 + }, + "test": { + "as": 0, + "nonNull": 0 + } } } diff --git a/scripts/type-assertion-ratchet.mjs b/scripts/type-assertion-ratchet.mjs index 45cb30bdc..9c5bcb5a9 100644 --- a/scripts/type-assertion-ratchet.mjs +++ b/scripts/type-assertion-ratchet.mjs @@ -15,6 +15,8 @@ const defaultPackages = [ 'packages/metro', 'packages/server', 'packages/stim-cli', + 'packages/transport', + 'packages/transport-tailscale', ]; const named = process.argv.slice(2).filter((arg) => !arg.startsWith('--')); const packages = named.length > 0 ? named : defaultPackages; diff --git a/test/e2e/release-prep.e2e.js b/test/e2e/release-prep.e2e.js index 619c15086..b2554583e 100644 --- a/test/e2e/release-prep.e2e.js +++ b/test/e2e/release-prep.e2e.js @@ -9,7 +9,18 @@ import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = join(HERE, '..', '..'); const SCRIPT = join(REPO, 'scripts', 'release-prep.mjs'); -const PACKAGE_DIRS = ['core', 'cache', 'cache-s3', 'metro', 'expo-build-cache', 'stim-cli', 'ci', 'server']; +const PACKAGE_DIRS = [ + 'core', + 'cache', + 'cache-s3', + 'metro', + 'expo-build-cache', + 'stim-cli', + 'ci', + 'transport', + 'transport-tailscale', + 'server', +]; const ACCEPTED_RANGES = new Set(['workspace:^', 'workspace:~', 'workspace:*']); const manifestIn = (root, dir) => join(root, 'packages', dir, 'package.json'); diff --git a/test/runtime-floor.mjs b/test/runtime-floor.mjs index eb32738a6..003857097 100644 --- a/test/runtime-floor.mjs +++ b/test/runtime-floor.mjs @@ -17,7 +17,18 @@ import { pathToFileURL } from 'node:url'; const repositoryRoot = join(import.meta.dirname, '..'); const require = createRequire(join(repositoryRoot, 'packages', 'stim-cli', 'package.json')); -const packageDirs = ['stim-cli', 'core', 'cache', 'cache-s3', 'metro', 'expo-build-cache', 'ci', 'server']; +const packageDirs = [ + 'stim-cli', + 'core', + 'cache', + 'cache-s3', + 'metro', + 'expo-build-cache', + 'ci', + 'transport', + 'transport-tailscale', + 'server', +]; for (const directory of packageDirs) { const root = join(repositoryRoot, 'packages', directory); @@ -44,6 +55,8 @@ const entrypoints = [ ['@stim-cli/cache-s3', 'createS3FileCache', 'cache-s3'], ['@stim-cli/expo-build-cache', 'cacheRoot'], ['@stim-cli/metro', 'sharedCacheStores'], + ['@stim-cli/transport', 'loadTransport', 'server'], + ['@stim-cli/transport-tailscale', 'createTransport', 'server'], ['stim/cache-manifest', 'readManifest'], ]; From 66c15c0a1a313cd4a54de3c00cab3638a9a0676b Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 13 Oct 2026 08:26:49 -0400 Subject: [PATCH 3/4] fix: harden the transport loader and server binding Listen on the bind addresses of every transport, not only of those that report changes, and give the Tailscale transport bind addresses only when it has a monitor, as before. The loader refuses a capability that is not a function and closes a transport that a factory creates after the load timed out. stim-server binds an identity to the transport that answered. A service install from package files is keyed by those files too, so an update that changes only a dependency package installs it. Align the design with the phase 1 contract and list the new packages in the READMEs. Refs #3681. --- README.md | 4 +++ .../2026-10-13-pluggable-transports-design.md | 21 ++++++++++------ packages/server/README.md | 8 +++--- .../__tests__/install-from.compat.test.ts | 11 ++++++-- packages/server/__tests__/server.test.ts | 18 +++++++++++++ .../server/__tests__/setup-endpoint.test.ts | 2 +- packages/server/src/server.ts | 14 +++++------ packages/server/src/service.ts | 16 +++++++++++- packages/transport-tailscale/index.ts | 14 ++++++----- packages/transport/__tests__/load.test.ts | 25 +++++++++++++++++++ packages/transport/index.ts | 23 +++++++++++------ 11 files changed, 121 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 9eddb536a..203964538 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ stim guide settings - [`@stim-cli/core`](./packages/core) contains shared internal cache contracts. - [`@stim-cli/server`](./packages/server) serves Stim status to paired read-only clients, such as the phone app, over Tailscale. +- [`@stim-cli/transport`](./packages/transport) is the contract for the + transports that carry clients to stim-server, and + [`@stim-cli/transport-tailscale`](./packages/transport-tailscale) is its + Tailscale transport. ## Development diff --git a/docs/specs/2026-10-13-pluggable-transports-design.md b/docs/specs/2026-10-13-pluggable-transports-design.md index 311c310d6..167dc9b61 100644 --- a/docs/specs/2026-10-13-pluggable-transports-design.md +++ b/docs/specs/2026-10-13-pluggable-transports-design.md @@ -60,9 +60,8 @@ registry, and grant and revoke. ```ts export const apiVersion = 1; export const id: string; -export const optionsSchema: JSONSchema; // x-stim annotations, rendered by Desktop +export const optionsSchema: JSONSchema; // phase 3: x-stim annotations, rendered by Desktop export function createTransport(input: { - stimHome: string; env: NodeJS.ProcessEnv; options: Record; }): Transport | Promise; @@ -70,18 +69,26 @@ export function createTransport(input: { interface Transport { id: string; traits: { privateNetwork: boolean; tls: 'end-to-end' | 'edge' | 'none'; peerIdentity: boolean }; - status(): Promise; + bindAddresses?(): string[]; watch?(listener: () => void): () => void; - expose?(input: { port: number; purpose: 'server' | 'metro' }): Promise; + identify?(peer: { address: string; request: IncomingMessage }): Promise; + close?(): void; + // Later phases: + status?(): Promise; setup?(input: { port: number; action: string }): Promise; - identify?(request: IncomingMessage): Promise; - bindAddresses?(): string[]; + expose?(input: { port: number; purpose: 'server' | 'metro' }): Promise; discover?(): Promise; resolve?(target: { machine: string; peerId: string }): Promise; - close?(): void; } ``` +Phase 1 ships the members above the comment. The others are added as optional +members while `apiVersion` stays 1, the way `@stim-cli/cache` added `files`. +In phase 1, `address` is still the `X-Forwarded-For` peer that `tailscale serve` +sets, and stim-server binds an identity to the transport that returned it. Phase 2 +gives each listener a single transport, so only the transport that owns the +connection is asked. + `TransportStatus` is a list of checks, each with a state (`waiting`, `working`, `ok`, `problem`), a title, an optional detail, and an optional remedy (a command to copy, a URL to open, or a `setup` action). Desktop already diff --git a/packages/server/README.md b/packages/server/README.md index 26bc7ba83..1577014de 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1135,14 +1135,16 @@ the job to it. `--release ` takes an exact version from the public npm registry (`https://registry.npmjs.org/`, never a registry `.npmrc` names), and npm checks each package against the registry's integrity. `--from ` installs the `.tgz` packages in that directory, such as `pnpm pack` output of -the `@stim-cli/core`, `@stim-cli/cache`, `@stim-cli/metro`, `stim` and -`@stim-cli/server` packages of a checkout, and records each file's sha256. +the `@stim-cli/core`, `@stim-cli/cache`, `@stim-cli/metro`, `stim`, +`@stim-cli/transport`, `@stim-cli/transport-tailscale` and `@stim-cli/server` +packages of a checkout, and records each file's sha256. Either way, `npm audit signatures` must verify the registry signature of every package that came from the registry, and the provenance attestation of each one that has it, or the update stops before it touches the job. Install scripts never run. Each install goes to `~/Library/Application Support/Stim/services/