diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index ceb3f293550bf..90f583425209d 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -372,6 +372,7 @@ const migrateSavedEnvironmentRecords = Effect.fn( credentials, remoteDpopTokens: [], disabledEnvironmentIds: [], + dismissedEnvironmentIds: [], }; }); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index b973f8177c9d0..cb01fdccfa610 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -39,10 +39,13 @@ const layerServerExposure = Layer.succeed(DesktopServerExposure.DesktopServerExp httpBaseUrl: new URL("http://127.0.0.1:4888"), tailscaleServeEnabled: true, tailscaleServePort: 8443, + tailscaleSignInEnabled: false, + tailscaleOnly: false, }), configureFromSettings: () => Effect.die("unexpected configureFromSettings"), setMode: () => Effect.die("unexpected setMode"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + setTailscaleSignInEnabled: () => Effect.die("unexpected setTailscaleSignInEnabled"), getAdvertisedEndpoints: Effect.succeed([]), } satisfies DesktopServerExposure.DesktopServerExposure["Service"]); @@ -245,6 +248,9 @@ describe("DesktopBackendConfiguration", () => { assert.isUndefined(first.env.T3CODE_PORT); assert.isUndefined(first.env.T3CODE_MODE); assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST); + // Removed from what the backend inherits, so the desktop's own switch decides. + assert.property(first.env, "T3CODE_TAILSCALE_SIGN_IN"); + assert.isUndefined(first.env.T3CODE_TAILSCALE_SIGN_IN); assert.equal(first.bootstrap.mode, "desktop"); assert.equal(first.bootstrap.noBrowser, true); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 9c8186711f6ec..bb0159ef6e672 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -78,6 +78,8 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_DESKTOP_HTTPS_ENDPOINTS", "T3CODE_TAILSCALE_SERVE", "T3CODE_TAILSCALE_SERVE_PORT", + "T3CODE_TAILSCALE_SIGN_IN", + "T3CODE_TAILSCALE_OWNER", ] as const; // Windows forwards these variables into WSL only through WSLENV. @@ -507,6 +509,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv shellEnvironmentPrepared: input.shellEnvironmentPrepared, tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, tailscaleServePort: backendExposure.tailscaleServePort, + ...(backendExposure.tailscaleSignInEnabled ? { tailscaleSignInEnabled: true } : {}), + ...(backendExposure.tailscaleOnly ? { tailscaleOnly: true } : {}), desktopTelemetryFd: 4, desktopTelemetryControlFd: 5, desktopBrowserFd: 6, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 23732860e05cd..1cf1bb495f00d 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -10,6 +10,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/http"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -17,6 +18,7 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts"; import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; +import * as DesktopTailscaleStatus from "./DesktopTailscaleStatus.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; const encoder = new TextEncoder(); @@ -93,6 +95,7 @@ function layer(input: { readonly spawnerLayer?: Layer.Layer; readonly desktopSettingsLayer?: Layer.Layer; readonly managedPolicyLayer?: Layer.Layer; + readonly httpClientLayer?: Layer.Layer; }) { const env = { T3CODE_HOME: input.baseDir, ...input.env }; const layerEnvironment = layerEnvironmentFor(input.baseDir, env); @@ -101,10 +104,11 @@ function layer(input: { }); return DesktopServerExposure.layer.pipe( + Layer.provideMerge(DesktopTailscaleStatus.layer), Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer), Layer.provideMerge(input.managedPolicyLayer ?? DesktopManagedPolicy.layerTest()), Layer.provideMerge(NodeFileSystem.layer), - Layer.provideMerge(NodeHttpClient.layerUndici), + Layer.provideMerge(input.httpClientLayer ?? NodeHttpClient.layerUndici), Layer.provideMerge(input.spawnerLayer ?? layerMockSpawner()), Layer.provideMerge(layerNetwork), Layer.provideMerge(DesktopConfig.layerTest(env)), @@ -127,6 +131,7 @@ const withHarness = ( spawnerLayer?: Layer.Layer, desktopSettingsLayer?: Layer.Layer, managedPolicyLayer?: Layer.Layer, + httpClientLayer?: Layer.Layer, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -142,6 +147,7 @@ const withHarness = ( ...(spawnerLayer ? { spawnerLayer } : {}), ...(desktopSettingsLayer ? { desktopSettingsLayer } : {}), ...(managedPolicyLayer ? { managedPolicyLayer } : {}), + ...(httpClientLayer ? { httpClientLayer } : {}), }), ), ); @@ -197,39 +203,46 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("starts local-only, Serve off, when a managed policy keeps clients local", () => - withHarness( - lanNetworkInterfaces, - Effect.gen(function* () { - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const settings = yield* DesktopAppSettings.DesktopAppSettings; - yield* settings.setServerExposureMode("network-accessible"); - yield* settings.setTailscaleServe({ enabled: true, port: Option.none() }); - - const state = yield* serverExposure.configureFromSettings({ port: 4173 }); - assert.equal(state.mode, "local-only"); - const backendConfig = yield* serverExposure.backendConfig; - assert.equal(backendConfig.bindHost, "127.0.0.1"); - assert.isFalse(backendConfig.tailscaleServeEnabled); - // Choosing them again while the policy applies does not widen the backend either. - yield* serverExposure.setMode("network-accessible"); - yield* serverExposure.setTailscaleServeEnabled({ enabled: true }); - assert.equal((yield* serverExposure.backendConfig).bindHost, "127.0.0.1"); - assert.isFalse((yield* serverExposure.backendConfig).tailscaleServeEnabled); - // The saved choice returns once the policy allows it. - const saved = yield* settings.get; - assert.equal(saved.serverExposureMode, "network-accessible"); - assert.isTrue(saved.tailscaleServeEnabled); - }), - {}, - undefined, - undefined, - DesktopManagedPolicy.layerTest({ - schemaVersion: 1, - organization: "Example Corp", - limits: { clients: ["desktop"] }, - }), - ), + it.effect( + "starts local-only, Serve and sign-in off, when a managed policy keeps clients local", + () => + withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + yield* settings.setTailscaleServe({ enabled: true, port: Option.none() }); + yield* settings.setTailscaleSignIn(true); + + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + assert.equal(state.mode, "local-only"); + const backendConfig = yield* serverExposure.backendConfig; + assert.equal(backendConfig.bindHost, "127.0.0.1"); + assert.isFalse(backendConfig.tailscaleServeEnabled); + assert.isFalse(backendConfig.tailscaleSignInEnabled); + // Choosing them again while the policy applies does not widen the backend either. + yield* serverExposure.setMode("network-accessible"); + yield* serverExposure.setTailscaleServeEnabled({ enabled: true }); + yield* serverExposure.setTailscaleSignInEnabled(true); + assert.equal((yield* serverExposure.backendConfig).bindHost, "127.0.0.1"); + assert.isFalse((yield* serverExposure.backendConfig).tailscaleServeEnabled); + assert.isFalse((yield* serverExposure.backendConfig).tailscaleSignInEnabled); + // The saved choice returns once the policy allows it. + const saved = yield* settings.get; + assert.equal(saved.serverExposureMode, "network-accessible"); + assert.isTrue(saved.tailscaleServeEnabled); + assert.isTrue(saved.tailscaleSignInEnabled); + }), + {}, + undefined, + undefined, + DesktopManagedPolicy.layerTest({ + schemaVersion: 1, + organization: "Example Corp", + limits: { clients: ["desktop"] }, + }), + ), ); it.effect("persists network-accessible mode and updates backend binding state", () => @@ -250,6 +263,8 @@ describe("DesktopServerExposure", () => { advertisedHost: "192.168.1.20", tailscaleServeEnabled: false, tailscaleServePort: 443, + tailscaleSignInEnabled: false, + port: 4173, }); const backendConfig = yield* serverExposure.backendConfig; @@ -338,6 +353,47 @@ describe("DesktopServerExposure", () => { ), ); + it.effect( + "listens on the tailnet only while Tailscale sign-in is on and Network access is off", + () => + withHarness( + { ...tailnetNetworkInterfaces, ...lanNetworkInterfaces }, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* (yield* DesktopAppSettings.DesktopAppSettings).load; + yield* serverExposure.configureFromSettings({ port: 3773 }); + + const signIn = yield* serverExposure.setTailscaleSignInEnabled(true); + assert.equal(signIn.requiresRelaunch, true); + assert.equal(signIn.state.tailscaleSignInEnabled, true); + assert.equal(signIn.state.port, 3773); + assert.deepInclude(yield* serverExposure.backendConfig, { + bindHost: "0.0.0.0", + tailscaleSignInEnabled: true, + tailscaleOnly: true, + }); + assert.deepEqual( + (yield* serverExposure.getAdvertisedEndpoints).map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:3773/", "http://100.90.1.2:3773/"], + ); + + yield* serverExposure.setMode("network-accessible"); + assert.deepInclude(yield* serverExposure.backendConfig, { + bindHost: "0.0.0.0", + tailscaleOnly: false, + }); + + yield* serverExposure.setMode("local-only"); + yield* serverExposure.setTailscaleSignInEnabled(false); + assert.deepInclude(yield* serverExposure.backendConfig, { + bindHost: "127.0.0.1", + tailscaleSignInEnabled: false, + tailscaleOnly: false, + }); + }), + ), + ); + it.effect("keeps a Tailscale Serve change made while a mode change is saving", () => Effect.gen(function* () { const modeWriteStarted = yield* Deferred.make(); @@ -489,6 +545,54 @@ describe("DesktopServerExposure", () => { }, ); + it.effect("reads this backend's environment ID once it answers, then keeps it", () => { + let localReads = 0; + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + const local = request.url.startsWith("http://127.0.0.1:4173/"); + if (local) localReads += 1; + // The backend is still starting on the first read. + const body = + local && localReads === 1 + ? new Response("starting", { status: 503 }) + : Response.json({ environmentId: "this-desktop" }); + return HttpClientResponse.fromWeb(request, body); + }), + ), + ); + const serveStatus = () => + withHarness( + tailnetNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setTailscaleServeEnabled({ enabled: true }); + const statuses: Array = []; + for (let call = 0; call < 3; call += 1) { + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + statuses.push( + endpoints.find((endpoint) => endpoint.httpBaseUrl.startsWith("https://"))?.status ?? + "missing", + ); + } + return statuses; + }), + {}, + layerMockSpawner( + `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`, + ), + undefined, + undefined, + httpClientLayer, + ); + return Effect.gen(function* () { + assert.deepEqual(yield* serveStatus(), ["unavailable", "available", "available"]); + assert.equal(localReads, 2); + }); + }); + it.effect("preserves explicit Tailscale exposure overrides", () => withHarness( lanNetworkInterfaces, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 76c2392c0bd66..ed102ffddeedd 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -9,11 +9,12 @@ import { type DesktopServerExposureMode, type DesktopServerExposureState, } from "@t3tools/contracts"; -import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale"; +import { isTailscaleIpv4Address, readT3EnvironmentId } from "@t3tools/tailscale"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -26,10 +27,9 @@ import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; +import * as DesktopTailscaleStatus from "./DesktopTailscaleStatus.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; -const TAILSCALE_STATUS_CACHE_TTL = Duration.seconds(60); - const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; @@ -245,6 +245,18 @@ export class DesktopTailscaleServePersistenceError extends Schema.TaggedError()( + "DesktopTailscaleSignInPersistenceError", + { + enabled: Schema.Boolean, + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { + return `Failed to persist desktop Tailscale sign-in (enabled: ${this.enabled}).`; + } +} + export const DesktopServerExposureSetModeError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, DesktopServerExposureModePersistenceError, @@ -255,6 +267,7 @@ export const DesktopServerExposureError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, DesktopServerExposureModePersistenceError, DesktopTailscaleServePersistenceError, + DesktopTailscaleSignInPersistenceError, ]); export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; @@ -264,6 +277,9 @@ export interface DesktopServerExposureBackendConfig { readonly httpBaseUrl: URL; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + readonly tailscaleSignInEnabled: boolean; + /** Sign-in on with Network access off: listen on every interface, accept only tailnet peers. */ + readonly tailscaleOnly: boolean; } export interface DesktopServerExposureChange { @@ -286,6 +302,9 @@ export class DesktopServerExposure extends Context.Service< readonly enabled: boolean; readonly port?: number; }) => Effect.Effect; + readonly setTailscaleSignInEnabled: ( + enabled: boolean, + ) => Effect.Effect; readonly getAdvertisedEndpoints: Effect.Effect; } >()("@t3tools/desktop/backend/DesktopServerExposure") {} @@ -302,6 +321,8 @@ interface RuntimeState { readonly advertisedHost: Option.Option; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + readonly tailscaleSignInEnabled: boolean; + readonly tailscaleOnly: boolean; } interface ResolvedRuntimeState { @@ -327,6 +348,8 @@ const toContractState = (state: RuntimeState): DesktopServerExposureState => ({ advertisedHost: Option.getOrNull(state.advertisedHost), tailscaleServeEnabled: state.tailscaleServeEnabled, tailscaleServePort: state.tailscaleServePort, + tailscaleSignInEnabled: state.tailscaleSignInEnabled, + port: state.port, }); const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfig => ({ @@ -335,6 +358,8 @@ const toBackendConfig = (state: RuntimeState): DesktopServerExposureBackendConfi httpBaseUrl: state.httpBaseUrl, tailscaleServeEnabled: state.tailscaleServeEnabled, tailscaleServePort: state.tailscaleServePort, + tailscaleSignInEnabled: state.tailscaleSignInEnabled, + tailscaleOnly: state.tailscaleOnly, }); const toResolvedExposure = (state: RuntimeState): ResolvedDesktopServerExposure => ({ @@ -352,11 +377,15 @@ function runtimeStateFromResolvedExposure(input: { readonly exposure: ResolvedDesktopServerExposure; readonly port: number; }): RuntimeState { + // Other tailnet devices sign in at the tailnet address, so a local-only backend + // listens on every interface and the server accepts only loopback and tailnet peers. + const tailscaleOnly = + input.settings.tailscaleSignInEnabled && input.exposure.mode === "local-only"; return { requestedMode: input.requestedMode, mode: input.exposure.mode, port: input.port, - bindHost: input.exposure.bindHost, + bindHost: tailscaleOnly ? DESKTOP_LAN_BIND_HOST : input.exposure.bindHost, localHttpUrl: input.exposure.localHttpUrl, localWsUrl: input.exposure.localWsUrl, httpBaseUrl: new URL(input.exposure.localHttpUrl), @@ -364,6 +393,8 @@ function runtimeStateFromResolvedExposure(input: { advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost), tailscaleServeEnabled: input.settings.tailscaleServeEnabled, tailscaleServePort: input.settings.tailscaleServePort, + tailscaleSignInEnabled: input.settings.tailscaleSignInEnabled, + tailscaleOnly, }; } @@ -434,29 +465,11 @@ const withCurrentAdvertisedHost = ( }; }; -const tailscaleIpv4AddressKey = ( - networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, -): string => - [ - ...new Set( - Object.values(networkInterfaces) - .flatMap((addresses) => addresses ?? []) - .filter( - (address) => - !address.internal && - address.family === "IPv4" && - isTailscaleIpv4Address(address.address), - ) - .map((address) => address.address), - ), - ] - .toSorted() - .join(","); - const requiresBackendRelaunch = (previous: RuntimeState, next: RuntimeState): boolean => previous.port !== next.port || previous.bindHost !== next.bindHost || - previous.localHttpUrl !== next.localHttpUrl; + previous.localHttpUrl !== next.localHttpUrl || + previous.tailscaleOnly !== next.tailscaleOnly; /** @public */ export const make = Effect.gen(function* () { @@ -464,28 +477,30 @@ export const make = Effect.gen(function* () { const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; + const tailscaleStatus = yield* DesktopTailscaleStatus.DesktopTailscaleStatus; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const stateRef = yield* Ref.make(initialRuntimeState()); // Serialize read-persist-write changes so stale runtime state cannot overwrite another update. const changePermit = yield* Semaphore.make(1); - // Key status by interface addresses so network changes bypass stale DNS and - // identity. Cache unchanged addresses to avoid repeated macOS TCC prompts. - const tailscaleStatusCache = yield* Cache.make({ - capacity: 1, - timeToLive: TAILSCALE_STATUS_CACHE_TTL, - lookup: (_addresses: string) => - readTailscaleStatus.pipe( - Effect.orElseSucceed(() => null), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + // A backend keeps its environment ID for its run. Nothing answering yet is not kept. + const localEnvironmentIds = yield* Cache.makeWith( + (localHttpUrl: string) => + readT3EnvironmentId({ baseUrl: localHttpUrl }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), ), - }); + { + capacity: 1, + timeToLive: (exit) => + Exit.isSuccess(exit) && Option.isSome(exit.value) ? Duration.infinity : Duration.zero, + }, + ); const readNetworkInterfaces = networkInterfaces.read; - // The server refuses to bind wider than the policy's clients allow, so network access or Serve - // saved before the policy would keep the backend from starting. The backend runs without them; - // the saved choice stays for when the policy allows it. + // The server refuses to bind wider than the policy's clients allow, so network access, Serve + // or Tailscale sign-in saved before the policy would keep the backend from starting. The + // backend runs without them; the saved choice stays for when the policy allows it. const remoteAllowed = yield* (yield* DesktopManagedPolicy.DesktopManagedPolicy).read.pipe( Effect.map((found) => found?.document.limits?.clients?.includes("remote") ?? true), Effect.orElseSucceed(() => false), @@ -493,7 +508,12 @@ export const make = Effect.gen(function* () { const withinPolicy = (settings: DesktopAppSettings.DesktopSettings) => remoteAllowed ? settings - : { ...settings, serverExposureMode: "local-only" as const, tailscaleServeEnabled: false }; + : { + ...settings, + serverExposureMode: "local-only" as const, + tailscaleServeEnabled: false, + tailscaleSignInEnabled: false, + }; const readCurrentState = (currentNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces) => Ref.get(stateRef).pipe( @@ -601,6 +621,33 @@ export const make = Effect.gen(function* () { changePermit.withPermit, ); + const setTailscaleSignInEnabled = Effect.fn("desktop.serverExposure.setTailscaleSignInEnabled")( + function* (enabled: boolean) { + yield* Effect.annotateCurrentSpan({ enabled }); + const previous = yield* Ref.get(stateRef); + const result = yield* desktopSettings + .setTailscaleSignIn(enabled) + .pipe( + Effect.mapError( + (cause) => new DesktopTailscaleSignInPersistenceError({ enabled, cause }), + ), + ); + const resolved = resolveRuntimeState({ + requestedMode: withinPolicy(result.settings).serverExposureMode, + settings: withinPolicy(result.settings), + port: previous.port, + networkInterfaces: yield* readNetworkInterfaces, + advertisedHostOverride: config.desktopLanHostOverride, + }); + yield* Ref.set(stateRef, resolved.state); + return { + state: toContractState(resolved.state), + requiresRelaunch: result.changed, + }; + }, + changePermit.withPermit, + ); + const getAdvertisedEndpoints = Effect.gen(function* () { const currentNetworkInterfaces = yield* readNetworkInterfaces; const state = yield* readCurrentState(currentNetworkInterfaces); @@ -611,7 +658,11 @@ export const make = Effect.gen(function* () { }); // Spawning Mac App Store Tailscale can prompt for TCC access even when exposure is disabled. - if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) { + if ( + state.mode !== "network-accessible" && + !state.tailscaleOnly && + !state.tailscaleServeEnabled + ) { return coreEndpoints; } @@ -619,11 +670,10 @@ export const make = Effect.gen(function* () { port: state.port, serveEnabled: state.tailscaleServeEnabled, servePort: state.tailscaleServePort, + localHttpUrl: state.localHttpUrl, + localEnvironmentId: Cache.get(localEnvironmentIds, state.localHttpUrl), networkInterfaces: currentNetworkInterfaces, - readStatus: Cache.get( - tailscaleStatusCache, - tailscaleIpv4AddressKey(currentNetworkInterfaces), - ), + readStatus: tailscaleStatus.status, }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.provideService(HttpClient.HttpClient, httpClient), @@ -637,6 +687,7 @@ export const make = Effect.gen(function* () { configureFromSettings, setMode, setTailscaleServeEnabled, + setTailscaleSignInEnabled, getAdvertisedEndpoints, }); }); diff --git a/apps/desktop/src/backend/DesktopTailscaleStatus.test.ts b/apps/desktop/src/backend/DesktopTailscaleStatus.test.ts new file mode 100644 index 0000000000000..846cd12684976 --- /dev/null +++ b/apps/desktop/src/backend/DesktopTailscaleStatus.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { TailscalePeer, TailscaleStatus } from "@t3tools/tailscale"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; +import * as DesktopTailscaleStatus from "./DesktopTailscaleStatus.ts"; +import { tailnetProbeCandidates } from "./DesktopTailscaleStatus.ts"; + +const peer = (name: string, overrides: Partial): TailscalePeer => ({ + stableNodeId: name, + hostName: name, + magicDnsName: `${name}.tail1234.ts.net`, + os: "linux", + tailnetIpv4Addresses: [`100.88.1.${name.length}`], + tailnetIpv6Addresses: [], + userId: "owner", + tags: [], + sharedIn: false, + online: true, + lastSeen: null, + keyExpired: false, + active: false, + curAddr: null, + relay: null, + peerRelay: null, + ...overrides, +}); + +describe("tailnetProbeCandidates", () => { + it("probes online peers this user owns or that are tagged, never shared-in ones", () => { + const status = { + backendState: "Running", + selfUserId: "owner", + peers: [ + peer("studio", {}), + peer("server", { userId: null, tags: ["tag:server"] }), + peer("asleep", { online: false }), + peer("friend", { userId: "friend" }), + peer("shared", { sharedIn: true }), + ], + } as unknown as TailscaleStatus; + + expect(tailnetProbeCandidates(status).map(({ peer }) => peer.hostName)).toEqual([ + "studio", + "server", + ]); + expect(tailnetProbeCandidates({ ...status, backendState: "NeedsLogin" })).toEqual([]); + }); +}); + +describe("DesktopTailscaleStatus.discoverEnvironments", () => { + it.effect("runs no CLI off the tailnet and reads status once a minute on it", () => { + const interfaces: Record< + string, + readonly DesktopNetworkInterfaces.DesktopNetworkInterfaceInfo[] | undefined + > = { en0: [{ address: "192.168.1.20", family: "IPv4", internal: false }] }; + let statusReads = 0; + const statusJson = JSON.stringify({ + BackendState: "Running", + Self: { UserID: 7, TailscaleIPs: ["100.90.1.2"] }, + Peer: { a: { ID: "nStudio", UserID: 7, TailscaleIPs: ["100.88.1.2"], Online: true } }, + }); + const layer = DesktopTailscaleStatus.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(DesktopNetworkInterfaces.DesktopNetworkInterfaces, { + read: Effect.succeed(interfaces), + }), + DesktopAppSettings.layerTest(), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + statusReads += 1; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(statusJson)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ environmentId: "studio" })), + ), + ), + ), + ), + ), + ); + + return Effect.gen(function* () { + const tailscale = yield* DesktopTailscaleStatus.DesktopTailscaleStatus; + expect(yield* tailscale.discoverEnvironments).toEqual([]); + expect(statusReads).toBe(0); + + interfaces.tailscale0 = [{ address: "100.90.1.2", family: "IPv4", internal: false }]; + expect((yield* tailscale.discoverEnvironments).map((found) => found.tailnetIpv4)).toEqual([ + "100.88.1.2", + ]); + yield* tailscale.discoverEnvironments; + expect(statusReads).toBe(1); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/backend/DesktopTailscaleStatus.ts b/apps/desktop/src/backend/DesktopTailscaleStatus.ts new file mode 100644 index 0000000000000..5fde1d9c7ab91 --- /dev/null +++ b/apps/desktop/src/backend/DesktopTailscaleStatus.ts @@ -0,0 +1,181 @@ +import { + type DesktopTailscaleStatus as DesktopTailscaleStatusResult, + EnvironmentId, + type TailnetPeerEnvironment, + tailscaleStateFromBackend, +} from "@t3tools/contracts"; +import { + isTailscaleIpv4Address, + readT3EnvironmentId, + readTailscaleStatus, + type TailscaleCliUse, + type TailscaleStatus, + tailscaleStateOfReadError, +} from "@t3tools/tailscale"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/http/HttpClient"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts"; + +const CACHE_TTL = Duration.seconds(60); +/** Where `t3 up` boxes and desktops with Tailscale sign-in listen. */ +const PROBE_PORT = 3773; +const PROBE_TIMEOUT = Duration.millis(1_500); +const PROBE_CONCURRENCY = 4; + +const isEnvironmentId = Schema.is(EnvironmentId); + +/** This device's 100.64/10 addresses, sorted and joined. Empty while it is on no tailnet. */ +const tailscaleIpv4AddressKey = ( + networkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces, +): string => + [ + ...new Set( + Object.values(networkInterfaces) + .flatMap((addresses) => addresses ?? []) + .filter( + (address) => + !address.internal && + address.family === "IPv4" && + isTailscaleIpv4Address(address.address), + ) + .map((address) => address.address), + ), + ] + .toSorted() + .join(","); + +/** + * Online peers this user owns or that are tagged. Nodes shared in from other + * tailnets belong to someone else. + */ +export const tailnetProbeCandidates = (status: TailscaleStatus) => + status.backendState !== "Running" + ? [] + : status.peers.flatMap((peer) => { + const address = peer.tailnetIpv4Addresses[0]; + const owned = + peer.tags.length > 0 || (status.selfUserId !== null && peer.userId === status.selfUserId); + return peer.online && !peer.sharedIn && owned && address !== undefined + ? [{ peer, address }] + : []; + }); + +export class DesktopTailscaleStatus extends Context.Service< + DesktopTailscaleStatus, + { + /** This device's Tailscale, for connection diagnosis. Read only on request, never polled. */ + readonly read: Effect.Effect; + /** + * `tailscale status` for reads nobody asked for, or null when it fails. + * Skips the macOS app bundle unless Serve or sign-in is on. + */ + readonly status: Effect.Effect; + /** + * T3 Code environments among this device's tailnet peers. Probed here, not + * in the renderer, so ports that are not T3 Code never see a browser fetch. + */ + readonly discoverEnvironments: Effect.Effect>; + } +>()("@t3tools/desktop/backend/DesktopTailscaleStatus") {} + +/** @public */ +export const make = Effect.gen(function* () { + const networkInterfaces = yield* DesktopNetworkInterfaces.DesktopNetworkInterfaces; + const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + // Keyed by this device's tailnet addresses, so logging in or out reads again + // while repeated checks skip macOS TCC prompts. + const makeStatusCache = (use: TailscaleCliUse) => + Cache.make({ + capacity: 1, + timeToLive: CACHE_TTL, + lookup: (_addresses: string) => + Effect.all({ + read: Effect.result(readTailscaleStatus(use)), + checkedAt: Effect.map(DateTime.now, DateTime.formatIso), + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), + }); + const statusCaches = { + passive: yield* makeStatusCache("passive"), + active: yield* makeStatusCache("active"), + }; + const readCached = (use: TailscaleCliUse, addresses: string) => + Cache.get(statusCaches[use], addresses); + const passiveUse = Effect.map(desktopSettings.get, (settings): TailscaleCliUse => + settings.tailscaleServeEnabled || settings.tailscaleSignInEnabled ? "active" : "passive", + ); + const addressKey = Effect.map(networkInterfaces.read, tailscaleIpv4AddressKey); + + const status = Effect.gen(function* () { + const { read } = yield* readCached(yield* passiveUse, yield* addressKey); + return Result.getOrNull(read); + }); + + const read = Effect.gen(function* () { + const { read, checkedAt } = yield* readCached("active", yield* addressKey); + if (Result.isFailure(read)) { + return { state: tailscaleStateOfReadError(read.failure), checkedAt, peers: [] }; + } + return { + state: tailscaleStateFromBackend(read.success.backendState), + checkedAt, + peers: read.success.peers.map((peer) => ({ + tailnetAddresses: peer.tailnetIpv4Addresses, + magicDnsName: peer.magicDnsName, + online: peer.online, + lastSeen: peer.lastSeen, + active: peer.active, + curAddr: peer.curAddr, + relay: peer.relay, + peerRelay: peer.peerRelay, + })), + }; + }).pipe(Effect.withSpan("desktop.tailscaleStatus.read")); + + const discoverEnvironments = Effect.gen(function* () { + // With no tailnet address no peer is reachable, so the CLI does not run. + if ((yield* addressKey) === "") return []; + const current = yield* status; + if (current === null) return []; + const found = yield* Effect.forEach( + tailnetProbeCandidates(current), + ({ peer, address }) => + readT3EnvironmentId({ + baseUrl: `http://${address}:${PROBE_PORT}`, + timeout: PROBE_TIMEOUT, + }).pipe( + Effect.map((environmentId) => + Option.filter(environmentId, isEnvironmentId).pipe( + Option.map((id): TailnetPeerEnvironment => ({ + name: peer.magicDnsName?.split(".")[0] || peer.hostName || address, + magicDnsName: peer.magicDnsName, + tailnetIpv4: address, + environmentId: id, + })), + ), + ), + ), + { concurrency: PROBE_CONCURRENCY }, + ); + return found.flatMap(Option.toArray); + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("desktop.tailscaleStatus.discoverEnvironments"), + ); + + return DesktopTailscaleStatus.of({ read, status, discoverEnvironments }); +}); + +export const layer = Layer.effect(DesktopTailscaleStatus, make); diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts index 054655917cecc..bc38c38cdf618 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { HttpClient } from "effect/http"; +import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; @@ -22,11 +22,55 @@ const protonAndTailscaleInterfaces = { tailscale0: [{ address: "100.74.126.34", family: "IPv4", internal: false }], }; +/** The local backend reports `this-desktop`; the tailnet URL reports `servedEnvironmentId`. */ +const layerServeAnswers = (servedEnvironmentId: string) => + Layer.mergeAll( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + const environmentId = request.url.startsWith("http://127.0.0.1:3773/") + ? "this-desktop" + : servedEnvironmentId; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(JSON.stringify({ environmentId }))), + ); + }), + ), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected tailscale status process")), + ), + ); + +const resolveServeEndpointStatus = (servedEnvironmentId: string) => + resolveTailscaleAdvertisedEndpoints({ + port: 3773, + serveEnabled: true, + localHttpUrl: "http://127.0.0.1:3773", + networkInterfaces: {}, + statusJson: `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":[]}}`, + }).pipe( + Effect.map((endpoints) => endpoints.map((endpoint) => [endpoint.httpBaseUrl, endpoint.status])), + Effect.provide(layerServeAnswers(servedEnvironmentId)), + ); + describe("tailscale endpoint provider", () => { + it.effect("lists Tailscale HTTPS as available only while it reaches this desktop's server", () => + Effect.gen(function* () { + assert.deepEqual(yield* resolveServeEndpointStatus("this-desktop"), [ + ["https://desktop.tail.ts.net/", "available"], + ]); + assert.deepEqual(yield* resolveServeEndpointStatus("other-desktop"), [ + ["https://desktop.tail.ts.net/", "unavailable"], + ]); + }), + ); + it.effect("does not advertise Proton's CGNAT address as a Tailscale pairing endpoint", () => Effect.gen(function* () { const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: 3773, + localHttpUrl: "http://127.0.0.1:3773", networkInterfaces: protonAndTailscaleInterfaces, statusJson: `{"Self":{"TailscaleIPs":["100.74.126.34"]}}`, }); @@ -43,6 +87,7 @@ describe("tailscale endpoint provider", () => { for (const statusJson of [null, "not-json", `{"Self":{"TailscaleIPs":[]}}`]) { const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: 3773, + localHttpUrl: "http://127.0.0.1:3773", networkInterfaces: { pvpnksintrf1: protonAndTailscaleInterfaces.pvpnksintrf1, [interfaceName]: protonAndTailscaleInterfaces.tailscale0, @@ -64,6 +109,7 @@ describe("tailscale endpoint provider", () => { Effect.gen(function* () { const endpoints = yield* resolveTailscaleAdvertisedEndpoints({ port: 3773, + localHttpUrl: "http://127.0.0.1:3773", networkInterfaces: { utun3: [{ address: "100.85.0.1", family: "IPv4", internal: false }], utun4: [ diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index 4b75afccdb5bc..c2b05dd3a0c5c 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -5,6 +5,7 @@ import { isTailscaleIpv4Address, parseTailscaleStatus, probeTailscaleHttpsEndpoint, + readT3EnvironmentId, readTailscaleStatus, type TailscaleStatus, } from "@t3tools/tailscale"; @@ -77,6 +78,8 @@ const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( readonly dnsName: string | null; readonly serveEnabled: boolean; readonly servePort?: number; + readonly localHttpUrl: string; + readonly localEnvironmentId?: Effect.Effect, never, HttpClient.HttpClient>; readonly probe?: (baseUrl: string) => Effect.Effect; }): Effect.fn.Return, never, HttpClient.HttpClient> { if (!input.dnsName) { @@ -87,11 +90,18 @@ const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( magicDnsName: input.dnsName, ...(input.servePort === undefined ? {} : { servePort: input.servePort }), }); + // Serve may front another server on this port, so the URL must answer as this backend. const probe = input.probe?.(httpBaseUrl) ?? - probeTailscaleHttpsEndpoint({ - baseUrl: httpBaseUrl, - }); + (input.localEnvironmentId ?? readT3EnvironmentId({ baseUrl: input.localHttpUrl })).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(false), + onSome: (environmentId) => + probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl, environmentId }), + }), + ), + ); const isReachable = input.serveEnabled ? yield* probe : false; return Option.some( @@ -116,6 +126,14 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd readonly port: number; readonly serveEnabled?: boolean; readonly servePort?: number; + /** This desktop's backend, which reports the environment ID the Serve URL must match. */ + readonly localHttpUrl: string; + /** That backend's environment ID, when the caller keeps it between calls. */ + readonly localEnvironmentId?: Effect.Effect< + Option.Option, + never, + HttpClient.HttpClient + >; readonly networkInterfaces: NetworkInterfaces; readonly statusJson?: string | null; readonly readStatus?: Effect.Effect< @@ -130,7 +148,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient > { const readStatus = - input.readStatus ?? readTailscaleStatus.pipe(Effect.orElseSucceed(() => null)); + input.readStatus ?? readTailscaleStatus("passive").pipe(Effect.orElseSucceed(() => null)); const status = input.statusJson === undefined ? yield* readStatus @@ -141,6 +159,10 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ dnsName: status?.magicDnsName ?? null, serveEnabled: input.serveEnabled === true, + localHttpUrl: input.localHttpUrl, + ...(input.localEnvironmentId === undefined + ? {} + : { localEnvironmentId: input.localEnvironmentId }), ...(input.servePort === undefined ? {} : { servePort: input.servePort }), ...(input.probe === undefined ? {} : { probe: input.probe }), }); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index a46dbafab0f41..229439780b251 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -12,8 +12,11 @@ import { import { getAdvertisedEndpoints, getServerExposureState, + discoverTailnetEnvironments, + getTailscaleStatus, setServerExposureMode, setTailscaleServeEnabled, + setTailscaleSignInEnabled, } from "./methods/serverExposure.ts"; import { bootstrapSshBearerSession, @@ -130,6 +133,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setServerExposureMode); yield* ipc.handle(setTailscaleServeEnabled); yield* ipc.handle(getAdvertisedEndpoints); + yield* ipc.handle(getTailscaleStatus); + yield* ipc.handle(setTailscaleSignInEnabled); + yield* ipc.handle(discoverTailnetEnvironments); yield* ipc.handle(getWslState); yield* ipc.handle(setWslBackendEnabled); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0f125c05de307..0061cdcb4fdc7 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -70,6 +70,9 @@ export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-st export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; +export const GET_TAILSCALE_STATUS_CHANNEL = "desktop:get-tailscale-status"; +export const SET_TAILSCALE_SIGN_IN_ENABLED_CHANNEL = "desktop:set-tailscale-sign-in-enabled"; +export const DISCOVER_TAILNET_ENVIRONMENTS_CHANNEL = "desktop:discover-tailnet-environments"; export const GET_WSL_STATE_CHANNEL = "desktop:get-wsl-state"; export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled"; export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index 9a9ce768973b3..02639b3823498 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -2,12 +2,15 @@ import { AdvertisedEndpoint, DesktopServerExposureModeSchema, DesktopServerExposureStateSchema, + DesktopTailscaleStatusSchema, + TailnetPeerEnvironment, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopServerExposure from "../../backend/DesktopServerExposure.ts"; +import * as DesktopTailscaleStatus from "../../backend/DesktopTailscaleStatus.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; @@ -58,6 +61,43 @@ export const setTailscaleServeEnabled = DesktopIpc.makeIpcMethod({ }), }); +export const getTailscaleStatus = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_TAILSCALE_STATUS_CHANNEL, + payload: Schema.Void, + result: DesktopTailscaleStatusSchema, + handler: Effect.fn("desktop.ipc.serverExposure.getTailscaleStatus")(function* () { + const tailscale = yield* DesktopTailscaleStatus.DesktopTailscaleStatus; + return yield* tailscale.read; + }), +}); + +export const setTailscaleSignInEnabled = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_TAILSCALE_SIGN_IN_ENABLED_CHANNEL, + payload: Schema.Boolean, + result: DesktopServerExposureStateSchema, + handler: Effect.fn("desktop.ipc.serverExposure.setTailscaleSignInEnabled")(function* (enabled) { + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const change = yield* serverExposure.setTailscaleSignInEnabled(enabled); + if (change.requiresRelaunch) { + yield* lifecycle.relaunch( + enabled ? "tailscale-sign-in-enabled" : "tailscale-sign-in-disabled", + ); + } + return change.state; + }), +}); + +export const discoverTailnetEnvironments = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DISCOVER_TAILNET_ENVIRONMENTS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(TailnetPeerEnvironment), + handler: Effect.fn("desktop.ipc.serverExposure.discoverTailnetEnvironments")(function* () { + const tailscale = yield* DesktopTailscaleStatus.DesktopTailscaleStatus; + return yield* tailscale.discoverEnvironments; + }), +}); + export const getAdvertisedEndpoints = DesktopIpc.makeIpcMethod({ channel: IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL, payload: Schema.Void, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2fcb8d968c5a4..b17197bea0c33 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -53,6 +53,7 @@ import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; import * as DesktopShutdown from "./app/DesktopShutdown.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; +import * as DesktopTailscaleStatus from "./backend/DesktopTailscaleStatus.ts"; import * as DesktopClientSettings from "./settings/DesktopClientSettings.ts"; import * as DesktopSavedEnvironments from "./settings/DesktopSavedEnvironments.ts"; import * as DesktopSnapShot from "./snapShot/DesktopSnapShot.ts"; @@ -160,6 +161,7 @@ const layerDesktopSsh = layerDesktopSshEnvironment.pipe( ); const layerDesktopServerExposure = DesktopServerExposure.layer.pipe( + Layer.provideMerge(DesktopTailscaleStatus.layer), Layer.provideMerge(DesktopNetworkInterfaces.layer), Layer.provideMerge(layerDesktopFoundation), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index da56807186ac5..e34d0ee46718b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -182,6 +182,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { setTailscaleServeEnabled: (input) => ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL), + getTailscaleStatus: () => ipcRenderer.invoke(IpcChannels.GET_TAILSCALE_STATUS_CHANNEL), + setTailscaleSignInEnabled: (enabled) => + ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SIGN_IN_ENABLED_CHANNEL, enabled), + discoverTailnetEnvironments: () => + ipcRenderer.invoke(IpcChannels.DISCOVER_TAILNET_ENVIRONMENTS_CHANNEL), getWslState: () => ipcRenderer.invoke(IpcChannels.GET_WSL_STATE_CHANNEL), setWslBackendEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_BACKEND_ENABLED_CHANNEL, enabled), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 0135225f90c48..024f0731edbd0 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -181,6 +181,7 @@ describe("DesktopSettings", () => { serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, + tailscaleSignInEnabled: false, updateChannel: "latest", updateChannelConfiguredByUser: false, wslBackendEnabled: false, @@ -276,6 +277,7 @@ describe("DesktopSettings", () => { serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, + tailscaleSignInEnabled: false, updateChannel: "nightly", updateChannelConfiguredByUser: false, wslBackendEnabled: false, @@ -305,6 +307,7 @@ describe("DesktopSettings", () => { serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: 443, + tailscaleSignInEnabled: false, updateChannel: "latest", updateChannelConfiguredByUser: true, wslBackendEnabled: false, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index a0624ced669ea..a444803df8d59 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -33,6 +33,7 @@ export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + readonly tailscaleSignInEnabled: boolean; readonly updateChannel: DesktopUpdateChannel; readonly updateChannelConfiguredByUser: boolean; readonly wslBackendEnabled: boolean; @@ -71,6 +72,7 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { serverExposureMode: "local-only", tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, + tailscaleSignInEnabled: false, updateChannel: "latest", updateChannelConfiguredByUser: false, wslBackendEnabled: false, @@ -93,6 +95,7 @@ const DesktopSettingsDocument = Schema.Struct({ serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), tailscaleServePort: Schema.optionalKey(Schema.Number), + tailscaleSignInEnabled: Schema.optionalKey(Schema.Boolean), updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), wslBackendEnabled: Schema.optionalKey(Schema.Boolean), @@ -157,6 +160,9 @@ export class DesktopAppSettings extends Context.Service< readonly enabled: boolean; readonly port: Option.Option; }) => Effect.Effect; + readonly setTailscaleSignIn: ( + enabled: boolean, + ) => Effect.Effect; readonly setUpdateChannel: ( channel: DesktopUpdateChannel, ) => Effect.Effect; @@ -223,6 +229,7 @@ function normalizeDesktopSettingsDocument( parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), + tailscaleSignInEnabled: parsed.tailscaleSignInEnabled === true, updateChannel: updateChannelConfiguredByUser ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) : defaultSettings.updateChannel, @@ -261,6 +268,9 @@ function toDesktopSettingsDocument( if (settings.tailscaleServePort !== defaults.tailscaleServePort) { document.tailscaleServePort = settings.tailscaleServePort; } + if (settings.tailscaleSignInEnabled !== defaults.tailscaleSignInEnabled) { + document.tailscaleSignInEnabled = settings.tailscaleSignInEnabled; + } if (settings.updateChannel !== defaults.updateChannel) { document.updateChannel = settings.updateChannel; } @@ -325,6 +335,12 @@ function setTailscaleServe( }; } +function setTailscaleSignIn(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.tailscaleSignInEnabled === enabled + ? settings + : { ...settings, tailscaleSignInEnabled: enabled }; +} + function setUpdateChannel( settings: DesktopSettings, requestedChannel: DesktopUpdateChannel, @@ -544,6 +560,11 @@ export const make = Effect.gen(function* () { return yield* persist((settings) => setTailscaleServe(settings, input)); }), + setTailscaleSignIn: Effect.fn("desktop.settings.setTailscaleSignIn")(function* (enabled) { + yield* Effect.annotateCurrentSpan({ enabled }); + + return yield* persist((settings) => setTailscaleSignIn(settings, enabled)); + }), setUpdateChannel: Effect.fn("desktop.settings.setUpdateChannel")(function* (channel) { yield* Effect.annotateCurrentSpan({ channel }); @@ -615,6 +636,9 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET setTailscaleServe: Effect.fn("DesktopAppSettings.setTailscaleServe")(function (input) { return update((settings) => setTailscaleServe(settings, input)); }), + setTailscaleSignIn: Effect.fn("DesktopAppSettings.setTailscaleSignIn")(function (enabled) { + return update((settings) => setTailscaleSignIn(settings, enabled)); + }), setUpdateChannel: Effect.fn("DesktopAppSettings.setUpdateChannel")(function (channel) { return update((settings) => setUpdateChannel(settings, channel)); }), diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index ec3b1ce9a1d59..47aad908da842 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -210,6 +210,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setTailscaleSignIn: () => Effect.die("unexpected Tailscale sign-in update"), setUpdateChannel: (channel) => setUpdateChannelError ? Effect.fail(setUpdateChannelError) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 4f8949823e354..ed3967c77133a 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -181,10 +181,13 @@ const layerDesktopServerExposure = Layer.succeed(DesktopServerExposure.DesktopSe httpBaseUrl: new URL("http://127.0.0.1:3773"), tailscaleServeEnabled: false, tailscaleServePort: 443, + tailscaleSignInEnabled: false, + tailscaleOnly: false, }), configureFromSettings: () => Effect.die("unexpected configureFromSettings"), setMode: () => Effect.die("unexpected setMode"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + setTailscaleSignInEnabled: () => Effect.die("unexpected setTailscaleSignInEnabled"), getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"), } satisfies DesktopServerExposure.DesktopServerExposure["Service"]); @@ -263,6 +266,7 @@ function layerTest(input: { }), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setTailscaleSignIn: () => Effect.die("unexpected Tailscale sign-in update"), setUpdateChannel: () => Effect.die("unexpected update channel change"), setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 8468a9538630c..f4d6d0e06409b 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -58,10 +58,13 @@ const layerServerExposure = Layer.succeed(DesktopServerExposure.DesktopServerExp httpBaseUrl: new URL("http://127.0.0.1:3773"), tailscaleServeEnabled: false, tailscaleServePort: 443, + tailscaleSignInEnabled: false, + tailscaleOnly: false, }), configureFromSettings: () => Effect.die("unexpected configureFromSettings"), setMode: () => Effect.die("unexpected setMode"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + setTailscaleSignInEnabled: () => Effect.die("unexpected setTailscaleSignInEnabled"), getAdvertisedEndpoints: Effect.succeed([]), } satisfies DesktopServerExposure.DesktopServerExposure["Service"]); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 225496bb7a407..d1d32f2284011 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,4 +1,5 @@ import { installPermissionUpdateNotice } from "./features/connection/permissionUpdateNotice"; +import { installTailnetDiscovery } from "./features/connection/tailnetDiscovery"; import { installComposerAttachmentUploads } from "./state/composer-attachment-uploads"; import { installThreadOutboxDelivery } from "./state/threadOutboxDelivery"; import * as Linking from "expo-linking"; @@ -183,6 +184,7 @@ const appServiceDisposers = [ installAppearanceRuntime(), installSubscriptionUsageRefresh(), installPermissionUpdateNotice(), + installTailnetDiscovery(), installThreadOutboxDelivery(), installComposerAttachmentUploads(), startIncomingShareInbox(), diff --git a/apps/mobile/src/connection/onboarding.ts b/apps/mobile/src/connection/onboarding.ts index 54963759ed189..350c9c3680f66 100644 --- a/apps/mobile/src/connection/onboarding.ts +++ b/apps/mobile/src/connection/onboarding.ts @@ -3,7 +3,8 @@ import { createAtomCommandScheduler, createRuntimeCommand, } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { discoverTailnetMachines as discoverTailnetMachinesEffect } from "@t3tools/client-runtime/state/tailnet"; +import type { EnvironmentId, TailnetPeerEnvironment } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { connectionAtomRuntime } from "./runtime"; @@ -28,6 +29,27 @@ export const connectPairingUrl = createRuntimeCommand(connectionAtomRuntime, { ), }); +/** Signs in through Tailscale with no code; None when the machine needs a pairing code. */ +export const connectTailnet = createRuntimeCommand(connectionAtomRuntime, { + label: "mobile:connection:connect-tailnet", + scheduler: onboardingScheduler, + concurrency: { + mode: "singleFlight", + key: (input: ConnectionOnboarding.TailnetConnectionInput) => JSON.stringify(input), + }, + execute: (input: ConnectionOnboarding.TailnetConnectionInput) => + ConnectionOnboarding.ConnectionOnboarding.pipe( + Effect.flatMap((onboarding) => onboarding.registerTailnet(input)), + ), +}); + +export const discoverTailnetMachines = createRuntimeCommand(connectionAtomRuntime, { + label: "mobile:connection:discover-tailnet", + scheduler: onboardingScheduler, + concurrency: { mode: "singleFlight", key: () => "discover-tailnet" }, + execute: (found: ReadonlyArray) => discoverTailnetMachinesEffect(found), +}); + export const updateBearerConnection = createRuntimeCommand(connectionAtomRuntime, { label: "mobile:connection:update-bearer", scheduler: onboardingScheduler, diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 83805e11942b1..f21cccc55c554 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -27,6 +27,7 @@ function targetPersistenceError( operation: | "list-targets" | "list-disabled-targets" + | "list-dismissed-environments" | "register-connection" | "set-connection-routes" | "remove-connection" @@ -57,6 +58,10 @@ export const layer = Layer.effectContext( Effect.map((document) => document.disabledEnvironmentIds), Effect.mapError((error) => targetPersistenceError("list-disabled-targets", error)), ), + listDismissed: catalog.read.pipe( + Effect.map((document) => document.dismissedEnvironmentIds), + Effect.mapError((error) => targetPersistenceError("list-dismissed-environments", error)), + ), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ register: (registration, routes) => @@ -67,9 +72,9 @@ export const layer = Layer.effectContext( catalog .update((document) => setRoutesInCatalog(document, environmentId, routes)) .pipe(Effect.mapError((error) => targetPersistenceError("set-connection-routes", error))), - remove: (environmentId) => + remove: (environmentId, options) => catalog - .update((document) => removeConnectionFromCatalog(document, environmentId)) + .update((document) => removeConnectionFromCatalog(document, environmentId, options)) .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), setEnabled: (environmentId, enabled) => catalog diff --git a/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx b/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx index b3922c4a95eda..10b20c12f69ef 100644 --- a/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx +++ b/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx @@ -4,12 +4,14 @@ import { type ConnectionDiagnosis, connectionDiagnosisText, connectionRoutes, + environmentTailscaleNotice, hasRelayRoute, presentConnectionDiagnosis, type EnvironmentPresentation, } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; import Constants from "expo-constants"; +import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { Linking, View } from "react-native"; @@ -17,7 +19,9 @@ import { AppText as Text } from "../../components/ui/AppText"; import { Button } from "../../components/ui/Button"; import { environmentCatalog } from "../../connection/catalog"; import { cn } from "../../lib/cn"; +import { useNowMinuteMs } from "../../lib/clock"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { relayEnvironmentDiscovery } from "../../state/relay"; const HOP_MARK = { ok: "✓", failed: "✕", unchecked: "–" } as const; const HOP_NOTE = { ok: "", failed: " · Failed", unchecked: " · Not checked" } as const; @@ -31,6 +35,9 @@ export function useConnectionDiagnosis( useAtomValue(environmentCatalog.stateAtom(environmentId)), () => AVAILABLE_CONNECTION_STATE, ); + const relayStatus = useAtomValue(relayEnvironmentDiscovery.stateValueAtom).environments.get( + environmentId, + )?.status; if (presentation === null || !presentation.entry.enabled) return { diagnosis: null, state }; const phase = presentation.connection.phase; if (phase === "connected" || phase === "unsupported") return { diagnosis: null, state }; @@ -40,12 +47,41 @@ export function useConnectionDiagnosis( hostLabel: presentation.entry.target.label, state, via: relayOnly ? "relay" : "direct", + relayStatus: hasRelayRoute(presentation.entry) + ? Option.getOrNull(relayStatus ?? Option.none()) + : null, clientVersion: Constants.expoConfig?.version, serverVersion: presentation.serverConfig?.environment.serverVersion, + hostTailscale: presentation.serverConfig?.tailscale, }); return { diagnosis, state }; } +/** The host's last reported Tailscale problem, for its row while no connection has failed. */ +export function HostTailscaleNotice(props: { readonly presentation: EnvironmentPresentation }) { + return props.presentation.serverConfig?.tailscale?.issues.length ? ( + + ) : null; +} + +// Ticks once a minute, and only for hosts with a Tailscale issue, so ages and expiry stay current. +function HostTailscaleNoticeLines(props: { readonly presentation: EnvironmentPresentation }) { + const now = useNowMinuteMs(); + const notice = environmentTailscaleNotice({ + hostLabel: props.presentation.entry.target.label, + entry: props.presentation.entry, + serverConfig: props.presentation.serverConfig, + now, + }); + if (notice === null) return null; + return ( + <> + {notice.headline} + {notice.fix} + + ); +} + export function ConnectionDiagnosisDetails(props: { readonly hostLabel: string; readonly diagnosis: ConnectionDiagnosis; @@ -74,6 +110,11 @@ export function ConnectionDiagnosisDetails(props: { {`${index + 1}. ${step}`} ))} + {diagnosis.policySnippet ? ( + + {diagnosis.policySnippet} + + ) : null} {diagnosis.facts.map(([label, value]) => `${label}: ${value}`).join("\n")} diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index c7866cea6ce06..be9e7a77bf09d 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -20,7 +20,11 @@ import { cn } from "../../lib/cn"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { serverEnvironment } from "../../state/server"; import { environmentPresentations } from "../../state/presentation"; -import { ConnectionDiagnosisDetails, useConnectionDiagnosis } from "./ConnectionDiagnosisDetails"; +import { + ConnectionDiagnosisDetails, + HostTailscaleNotice, + useConnectionDiagnosis, +} from "./ConnectionDiagnosisDetails"; import { ConnectionFormField } from "./ConnectionFormField"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { environmentStatusText } from "./environmentStatusText"; @@ -131,7 +135,9 @@ export function ConnectionEnvironmentRow(props: { {diagnosis.headline} {connectionState.attempt > 0 ? ( - {`Retrying · attempt ${connectionState.attempt}`} + {connectionState.retryAt === null + ? `Attempt ${connectionState.attempt}` + : `Retrying · attempt ${connectionState.attempt}`} ) : null} @@ -154,6 +160,9 @@ export function ConnectionEnvironmentRow(props: { ) : null} ) : null} + {!diagnosis && enabled && presentation !== null ? ( + + ) : null} {freeze ? ( @@ -250,7 +258,7 @@ function PairingForm({ )} > {pairingConnectionError ?? - "For machines on your local network or tailnet. The machine keeps its own provider credentials."} + "For machines on your local network or tailnet. On your tailnet, a machine name needs no code."} @@ -346,7 +354,7 @@ function PairingScanCard(props: { )} - Scan the code from t3 pair or desktop Connections settings + Scan the code from t3 up, t3 pair, or desktop Connections settings diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index bac5e09721d00..5a613cd530722 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -3,7 +3,7 @@ import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScroll import { useNavigation } from "@react-navigation/native"; import type { EnvironmentId } from "@t3tools/contracts"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -12,6 +12,7 @@ import { useRemoteConnections } from "../../state/use-remote-environment-registr import { LocalEnvironmentList } from "./LocalEnvironmentList"; import { PullRequestRoutingSettings } from "./PullRequestRoutingSettings"; import { LinkedEnvironmentsSection } from "./LinkedEnvironmentsSection"; +import { TailnetDevicesSection } from "./TailnetDevicesSection"; export function ConnectionsRouteScreen() { const { @@ -73,11 +74,16 @@ export function ConnectionsRouteScreen() { {connectedEnvironments .filter((environment) => environment.connectionState === "connected") .map((environment) => ( - + + + + ))} diff --git a/apps/mobile/src/features/connection/TailnetDevicesSection.tsx b/apps/mobile/src/features/connection/TailnetDevicesSection.tsx new file mode 100644 index 0000000000000..323a3c0c88c48 --- /dev/null +++ b/apps/mobile/src/features/connection/TailnetDevicesSection.tsx @@ -0,0 +1,103 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, TailnetDevice } from "@t3tools/contracts"; +import { useState } from "react"; +import { View } from "react-native"; + +import * as Dialog from "../../components/ui/Dialog"; +import { ListActionRow } from "../../components/ui/ListActionRow"; +import { useEnvironmentServerConfig } from "../../state/entities"; +import { useEnvironmentQuery } from "../../state/query"; +import { tailnetEnvironment } from "../../state/tailnet"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { SettingsSection } from "../settings/components/SettingsSection"; + +const failureMessage = (result: Parameters[0]) => { + const error = squashAtomCommandFailure(result); + return error instanceof Error ? error.message : String(error); +}; + +/** + * Devices that signed in to a host through Tailscale, with Block and Unblock. + * Block ends the device's sessions there and refuses its sign-in until Unblock. + */ +export function TailnetDevicesSection(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + /** Outer spacing. Defaults to the environment list's. */ + readonly className?: string; +}) { + const config = useEnvironmentServerConfig(props.environmentId); + const offered = config?.environment.capabilities.tailnetSignIn === true; + const canManage = useAtomValue( + tailnetEnvironment.blockDevice.permissionAtom(props.environmentId), + ); + const listed = useEnvironmentQuery( + offered && canManage + ? tailnetEnvironment.devices({ environmentId: props.environmentId, input: {} }) + : null, + ); + const blockDevice = useAtomCommand(tailnetEnvironment.blockDevice, { reportFailure: false }); + const unblockDevice = useAtomCommand(tailnetEnvironment.unblockDevice, { reportFailure: false }); + const [busyId, setBusyId] = useState(null); + const devices = listed.data?.devices ?? []; + + if (devices.length === 0) return null; + + const apply = async (device: TailnetDevice) => { + setBusyId(device.stableNodeId); + const input = { + environmentId: props.environmentId, + input: { stableNodeId: device.stableNodeId }, + }; + const result = await (device.blockedAt === null ? blockDevice(input) : unblockDevice(input)); + setBusyId(null); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + Dialog.alert( + device.blockedAt === null ? "Could not block the device" : "Could not unblock it", + failureMessage(result), + ); + } + }; + + const toggle = (device: TailnetDevice) => { + const name = device.label ?? device.stableNodeId; + if (device.blockedAt !== null) { + void apply(device); + return; + } + Dialog.confirm({ + title: `Block ${name}?`, + message: `${device.current ? `This is the device you are using, so it loses Tailscale access to ${props.environmentLabel} until it is unblocked. ` : ""}Its Tailscale sessions on ${props.environmentLabel} end now, and it cannot sign in through Tailscale until you unblock it. Sessions it made with a pairing code stay until you revoke them in Settings > Sharing & access on ${props.environmentLabel}. A device that rejoins Tailscale gets a new ID, so block it again or remove it from your tailnet.`, + confirmText: "Block", + destructive: true, + onConfirm: () => void apply(device), + }); + }; + + return ( + + + {devices.map((device) => ( + toggle(device)} + /> + ))} + + + ); +} diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 466fbfa7a282d..19fb9105fcf26 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -33,6 +33,17 @@ describe("extractPairingUrlFromQrPayload", () => { }); describe("parsePairingUrl", () => { + it("reads the t3 up QR, a plain tailnet address, as a host with no code", () => { + const pairingUrl = extractPairingUrlFromQrPayload("http://100.88.1.2:3773"); + expect(parsePairingUrl(pairingUrl)).toEqual({ host: "http://100.88.1.2:3773", code: "" }); + expect(buildPairingUrl("http://100.88.1.2:3773", "")).toBe("http://100.88.1.2:3773"); + // `t3 up` on port 80 prints `:80`, which URL would drop. + expect(parsePairingUrl("http://100.88.1.2:80")).toEqual({ + host: "http://100.88.1.2:80", + code: "", + }); + }); + it("reads hosted pairing links into backend host fields", () => { expect( parsePairingUrl( diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index 0750705315fa2..35a6dc8d43215 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -64,7 +64,10 @@ export function parsePairingUrl(url: string): { host: string; code: string } { parsed.hash = ""; parsed.search = ""; parsed.pathname = "/"; - return { host: parsed.toString().replace(/\/$/, ""), code }; + // URL drops a default port, but a typed `:80` on a tailnet address is not T3 Code's 3773. + const typedPort = /^[a-z][a-z\d+.-]*:\/\/[^/?#]*:(\d+)(?:[/?#]|$)/iu.exec(trimmed)?.[1]; + const host = parsed.toString().replace(/\/$/, ""); + return { host: parsed.port === "" && typedPort ? `${host}:${typedPort}` : host, code }; } catch { return { host: trimmed, code: "" }; } diff --git a/apps/mobile/src/features/connection/tailnetDiscovery.ts b/apps/mobile/src/features/connection/tailnetDiscovery.ts new file mode 100644 index 0000000000000..0efbb739af9ac --- /dev/null +++ b/apps/mobile/src/features/connection/tailnetDiscovery.ts @@ -0,0 +1,90 @@ +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; + +import * as Dialog from "../../components/ui/Dialog"; +import { discoverTailnetMachines } from "../../connection/onboarding"; +import { environmentCatalog } from "../../connection/catalog"; +import { isAppActive, subscribeAppState } from "../../lib/appState"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentPresentations } from "../../state/presentation"; + +const DISCOVERY_INTERVAL_MS = 5 * 60_000; +// Machines connect one by one at launch; one pass covers them all. +const DISCOVERY_MIN_GAP_MS = 30_000; + +/** + * App-lifetime worker: asks each connected machine for the T3 Code machines on + * its tailnet and adds the ones that offer Tailscale sign-in. Runs on resume, + * when a machine connects, and on an interval while active. + */ +export function installTailnetDiscovery(): () => void { + let lastRunAt = 0; + let connectedKey = ""; + let trailing: ReturnType | undefined; + // Each conflict is worth one warning per launch, not one every few minutes. + const warned = new Set(); + + const run = async () => { + lastRunAt = Date.now(); + const result = await runAtomCommand(appAtomRegistry, discoverTailnetMachines, [], { + label: discoverTailnetMachines.label, + reportFailure: false, + }); + if (result._tag !== "Success") return; + const { added, conflicts } = result.value; + if (added.length > 0) { + Dialog.alert( + added.length === 1 ? `Added ${added[0]!.label}` : `Added ${added.length} machines`, + `${added.map((machine) => machine.label).join(", ")} ${ + added.length === 1 ? "was" : "were" + } found on your tailnet. Remove a machine in Environments to stop adding it.`, + ); + } + for (const conflict of conflicts) { + const key = `${conflict.label}@${conflict.address}`; + if (warned.has(key)) continue; + warned.add(key); + Dialog.alert( + `${conflict.label} answered at a new address`, + `${conflict.address} is not one of its saved routes, so it was not added. Make sure that address is really this machine.`, + ); + } + }; + + const update = (force: boolean) => { + if (!isAppActive() || !appAtomRegistry.get(environmentCatalog.catalogValueAtom).isReady) return; + const connected = [...appAtomRegistry.get(environmentPresentations.presentationsAtom)] + .filter(([, presentation]) => presentation.connection.phase === "connected") + .map(([id]) => id) + .join(","); + const changed = connected !== connectedKey; + if (connected === "" || (!force && !changed)) { + connectedKey = connected; + return; + } + // A change inside the gap runs once the gap ends, not at the next interval. + const wait = lastRunAt + DISCOVERY_MIN_GAP_MS - Date.now(); + if (wait > 0) { + trailing ??= setTimeout(() => { + trailing = undefined; + update(true); + }, wait); + return; + } + connectedKey = connected; + void run(); + }; + + const unsubscribePresentations = appAtomRegistry.subscribe( + environmentPresentations.presentationsAtom, + () => update(false), + { immediate: true }, + ); + const unsubscribeAppState = subscribeAppState(() => update(true)); + const timer = setInterval(() => update(true), DISCOVERY_INTERVAL_MS); + return () => { + unsubscribePresentations(); + unsubscribeAppState(); + clearInterval(timer); + clearTimeout(trailing); + }; +} diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index 4b02247c61376..96261b27c6d5a 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -2,8 +2,10 @@ import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import { useAtomValue } from "@effect/atom-react"; import { + AVAILABLE_CONNECTION_STATE, type ConnectionRoute, type ConnectionRouteKind, + type ConnectionRouteOutcome, connectionRouteAddress, connectionRouteId, connectionRouteKind, @@ -11,6 +13,9 @@ import { connectionRoutes, isLearned, pendingRouteOrder, + offTailnetHint, + routeOutcomeText, + unusedWithTailnetSignIn, } from "@t3tools/client-runtime/connection"; import { type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; import { @@ -36,6 +41,7 @@ import { SettingsDragHandle } from "./components/SettingsDragHandle"; import { SettingsSection } from "./components/SettingsSection"; const ICON_SIZE = Platform.OS === "android" ? 24 : 22; +const UNUSED_WITH_TAILNET_SIGN_IN = "Not used with Tailscale sign-in"; const REMOVE_SIZE = 20; const ROUTE_ICONS: Record = { @@ -61,6 +67,10 @@ export function EnvironmentRoutesSection({ const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId)); const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId)); const session = AsyncResult.getOrElse(sessionResult, () => null); + const connectionState = AsyncResult.getOrElse( + useAtomValue(environmentCatalog.stateAtom(environmentId)), + () => AVAILABLE_CONNECTION_STATE, + ); const [permissionsOpen, setPermissionsOpen] = useState(false); const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder"); const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal"); @@ -84,6 +94,8 @@ export function EnvironmentRoutesSection({ const routes = order.flatMap((id) => byId.get(id) ?? []); const activeRouteId = connected && Option.isSome(prepared) ? connectionRouteId(prepared.value.target) : null; + const outcomes = new Map(connectionState.routes.map((outcome) => [outcome.routeId, outcome])); + const hint = offTailnetHint(connectionState); const commit = (next: ReadonlyArray) => { setPending(next); @@ -195,6 +207,8 @@ export function EnvironmentRoutesSection({ position={index + 1} count={routes.length} inUse={id === activeRouteId} + outcome={outcomes.get(id)} + unused={unusedWithTailnetSignIn(entry, route)} editing={editing} offset={index === dragFrom ? (drag?.translation ?? 0) : shift} lifted={index === dragFrom} @@ -218,6 +232,9 @@ export function EnvironmentRoutesSection({ /> ); })} + {hint !== null ? ( + {hint} + ) : null} ); @@ -228,6 +245,8 @@ function RouteRow(props: { readonly position: number; readonly count: number; readonly inUse: boolean; + readonly outcome: ConnectionRouteOutcome | undefined; + readonly unused: boolean; readonly editing: boolean; readonly offset: number; readonly lifted: boolean; @@ -241,6 +260,13 @@ function RouteRow(props: { const { route, lifted, offset } = props; const label = connectionRouteLabel(route); const address = connectionRouteAddress(route); + const status = props.inUse + ? "In use" + : props.unused + ? UNUSED_WITH_TAILNET_SIGN_IN + : props.outcome + ? routeOutcomeText(props.outcome) + : null; const style = useAnimatedStyle(() => ({ transform: [ { @@ -281,7 +307,7 @@ function RouteRow(props: { label, address, isLearned(route) ? "Found automatically" : null, - props.inUse ? "In use" : null, + status, `Route ${props.position} of ${props.count}`, ] .filter((part) => part !== null) @@ -306,6 +332,19 @@ function RouteRow(props: { {props.inUse ? ( + ) : props.unused ? ( + {UNUSED_WITH_TAILNET_SIGN_IN} + ) : props.outcome ? ( + + {routeOutcomeText(props.outcome)} + ) : null} {address !== null ? ( diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index 21edb7f9edc6c..da2013521c452 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -27,6 +27,7 @@ import { environmentSession, useEnvironmentScope, readEnvironmentScope } from ". import { useAtomCommand } from "../../state/use-atom-command"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; +import { TailnetDevicesSection } from "../connection/TailnetDevicesSection"; import { EnvironmentRoutesSection } from "./EnvironmentRoutesSection"; import { OutdatedServerUpdateSection } from "./OutdatedServerUpdateSection"; import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; @@ -230,6 +231,13 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm ) : null} {notice ? {notice} : null} + {connected ? ( + + ) : null} {config && !outdated ? ( <> diff --git a/apps/mobile/src/state/tailnet.ts b/apps/mobile/src/state/tailnet.ts new file mode 100644 index 0000000000000..30577c10bdd68 --- /dev/null +++ b/apps/mobile/src/state/tailnet.ts @@ -0,0 +1,5 @@ +import { createTailnetEnvironmentAtoms } from "@t3tools/client-runtime/state/tailnet"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const tailnetEnvironment = createTailnetEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index c75456461bf64..929bf005686e3 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -4,6 +4,7 @@ import { useNavigation } from "@react-navigation/native"; import { Discovery } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; @@ -19,7 +20,10 @@ import { environmentCatalog } from "../connection/catalog"; import { createRemoteEnvironmentProjectionAtoms } from "./remote-environment-projections"; import { serverEnvironment } from "./server"; import { relayEnvironmentDiscovery } from "./relay"; -import { connectPairingUrl as connectPairingUrlCommand } from "../connection/onboarding"; +import { + connectPairingUrl as connectPairingUrlCommand, + connectTailnet as connectTailnetCommand, +} from "../connection/onboarding"; const RELAY_REMOVAL_ACCOUNT_NOTE: Record = { registered: @@ -78,6 +82,38 @@ export async function pairEnvironment( return { ok: false, error: message }; } +/** + * Signs in through Tailscale at a machine name, 100.x address, or `t3 up` QR + * URL. Asks for a pairing link when the machine does not offer it. + */ +export async function signInOverTailnet( + address: string, + expectedEnvironmentId?: EnvironmentId, +): Promise { + const result = await runAtomCommand( + appAtomRegistry, + connectTailnetCommand, + { address, ...(expectedEnvironmentId === undefined ? {} : { expectedEnvironmentId }) }, + { reportFailure: false }, + ); + if (AsyncResult.isSuccess(result)) { + if (Option.isNone(result.value)) { + return { + ok: false, + error: + "That machine does not offer Tailscale sign-in. Paste a pairing link from it instead.", + }; + } + appAtomRegistry.set(connectionPairingUrlAtom, ""); + return { ok: true, error: null }; + } + const error = Cause.squash(result.cause); + return { + ok: false, + error: error instanceof Error ? error.message : "Failed to sign in through Tailscale.", + }; +} + const remoteEnvironmentProjections = createRemoteEnvironmentProjectionAtoms({ presentationAtom: environmentPresentations.presentationAtom, preparedConnectionAtom: environmentSession.preparedConnectionValueAtom, diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 813d23bc33dc6..1082a42b58d56 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -22,6 +22,7 @@ import { DpopFailureReason, type DpopFailureReason as DpopFailureReasonType, mcpClientScopes, + TAILNET_SUBJECT_PREFIX, } from "@t3tools/contracts"; import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; @@ -35,6 +36,7 @@ import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/http/HttpServerRequest"; +import * as SqlClient from "effect/sql/SqlClient"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ServerConfig from "../config.ts"; @@ -717,6 +719,7 @@ export const make = Effect.gen(function* () { const descriptor = yield* policy.getDescriptor(); const config = yield* ServerConfig.ServerConfig; const managedPolicy = yield* ManagedPolicy.ManagedPolicy; + const sql = yield* SqlClient.SqlClient; const devAuth = resolveReusableDevAuth(config); const authenticateToken = ( @@ -831,6 +834,9 @@ export const make = Effect.gen(function* () { auth: descriptor, ...authScopeResponse(session.scopes), sessionMethod: session.method, + ...(session.subject.startsWith(TAILNET_SUBJECT_PREFIX) + ? { signedInWithTailscale: true } + : {}), ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), }) satisfies AuthSessionState, ), @@ -952,6 +958,9 @@ export const make = Effect.gen(function* () { ); }; + // Consuming the grant (which checks the Tailscale block list) and creating the + // session share one write transaction, so a grant spent after a block lands is + // refused, and a session made before it is revoked by the block's next step. const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => { return resolveBootstrapGrant(credential, { @@ -964,7 +973,12 @@ export const make = Effect.gen(function* () { requestedScopes === undefined ? grant.scopes : [...new Set(requestedScopes)].filter((scope) => grant.scopes.includes(scope)); - if (grantedScopes.length === 0) { + // A Tailscale session holds exactly what its grant gives, so the tailnet policy alone + // decides its scopes and a recheck can tell when the grant changed. + const narrowedTailnetGrant = + grant.subject.startsWith(TAILNET_SUBJECT_PREFIX) && + grantedScopes.length !== grant.scopes.length; + if (grantedScopes.length === 0 || narrowedTailnetGrant) { return yield* new ServerAuthScopeNotGrantedError({}); } return yield* sessions @@ -991,6 +1005,11 @@ export const make = Effect.gen(function* () { ); }), ), + sql.withTransaction, + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new ServerAuthAuthenticatedAccessTokenIssueError({ cause })), + }), Effect.flatMap((session) => DateTime.now.pipe( Effect.map( @@ -1433,6 +1452,7 @@ const revokePairedClientsThePolicyDisallows = Effect.fn( const sessions = (yield* stores.sessions.listActive()).filter( (session) => disallowed.has(session.subject) || + (!remoteAllowed && session.subject.startsWith(TAILNET_SUBJECT_PREFIX)) || // The startup link also pairs browsers on this machine; only a device elsewhere goes. (!remoteAllowed && session.subject === INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT && diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index cca9a729f3cb2..9eec971c5e79d 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -1,4 +1,8 @@ -import { AuthStandardClientScopes, type ManagedPolicyDocument } from "@t3tools/contracts"; +import { + AuthStandardClientScopes, + type ManagedPolicyDocument, + TAILNET_SUBJECT_PREFIX, +} from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -73,6 +77,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = const pairingLinks = yield* PairingGrantStore.PairingGrantStore; const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; const phone = yield* sessions.issue({ subject: "one-time-token" }); + const tailnet = yield* sessions.issue({ subject: `${TAILNET_SUBJECT_PREFIX}nPhone` }); const bot = yield* sessions.issue({ subject: "cli-issued-session" }); const startup = (ipAddress: string) => sessions.issue({ @@ -91,6 +96,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = const remaining = (yield* sessions.listActive()).map((session) => session.sessionId); assert.notInclude(remaining, phone.sessionId); + assert.notInclude(remaining, tailnet.sessionId); assert.notInclude(remaining, startupPhone.sessionId); assert.include(remaining, startupBrowser.sessionId); assert.include(remaining, bot.sessionId); @@ -103,6 +109,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = const sessions = yield* SessionStore.SessionStore; const phone = yield* sessions.issue({ subject: "one-time-token" }); const connect = yield* sessions.issue({ subject: "cloud-connect" }); + const tailnet = yield* sessions.issue({ subject: `${TAILNET_SUBJECT_PREFIX}nPhone` }); yield* EnvironmentAuth.make.pipe( Effect.provide( @@ -119,6 +126,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth administrative operations", (it) = const remaining = (yield* sessions.listActive()).map((session) => session.sessionId); assert.include(remaining, phone.sessionId); + assert.include(remaining, tailnet.sessionId); assert.notInclude(remaining, connect.sessionId); }).pipe(Effect.provide(layerEnvironmentAuth())), ); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 4e940cd36cc51..2ba382d04b8ef 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -1,4 +1,4 @@ -import { AuthStandardClientScopes } from "@t3tools/contracts"; +import { AuthStandardClientScopes, tailnetSubject } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; @@ -232,6 +232,16 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(consumed.scopes).toEqual(change.pairingLink.scopes); expect(yield* Queue.take(changes)).toEqual({ type: "pairingLinkRemoved", id: issued.id }); } + + // Tailscale sign-in grants are neither listed nor streamed: the next change is the next link. + const signIn = yield* grants.issueOneTimeToken({ subject: tailnetSubject("nStudio") }); + expect((yield* grants.listActive()).map((link) => link.id)).not.toContain(signIn.id); + yield* grants.consume(signIn.credential); + const next = yield* grants.issueOneTimeToken(); + expect(yield* Queue.take(changes)).toMatchObject({ + type: "pairingLinkUpserted", + pairingLink: { id: next.id }, + }); }).pipe(Effect.scoped, Effect.provide(layerPairingGrantStore())), ); }); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 0709f371684fb..37bcf81452d0f 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -3,6 +3,7 @@ import { type AuthEnvironmentScope, type AuthPairingLink, type ServerAuthBootstrapMethod, + TAILNET_SUBJECT_PREFIX, } from "@t3tools/contracts"; import { DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS, @@ -261,6 +262,9 @@ const PAIRING_TOKEN_PATTERN = new RegExp( "i", ); +// Tailscale sign-in mints a grant per sign-in and renewal; they are not links anyone shares. +const isListedSubject = (subject: string) => !subject.startsWith(TAILNET_SUBJECT_PREFIX); + const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const config = yield* ServerConfig.ServerConfig; @@ -337,24 +341,26 @@ const make = Effect.gen(function* () { const now = yield* DateTime.now; const rows = yield* pairingLinks.listActive({ now }); - return rows.map((row) => - row.label - ? ({ - id: row.id, - scopes: row.scopes, - subject: row.subject, - label: row.label, - createdAt: row.createdAt, - expiresAt: row.expiresAt, - } satisfies AuthPairingLink) - : ({ - id: row.id, - scopes: row.scopes, - subject: row.subject, - createdAt: row.createdAt, - expiresAt: row.expiresAt, - } satisfies AuthPairingLink), - ); + return rows + .filter((row) => isListedSubject(row.subject)) + .map((row) => + row.label + ? ({ + id: row.id, + scopes: row.scopes, + subject: row.subject, + label: row.label, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + } satisfies AuthPairingLink) + : ({ + id: row.id, + scopes: row.scopes, + subject: row.subject, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + } satisfies AuthPairingLink), + ); }, Effect.mapError((cause) => new ActivePairingLinksLoadError({ cause })), ); @@ -424,14 +430,16 @@ const make = Effect.gen(function* () { }), ), ); - yield* emitUpsert({ - id, - scopes: input?.scopes ?? AuthStandardClientScopes, - subject: input?.subject ?? "one-time-token", - ...(input?.label ? { label: input.label } : {}), - createdAt: now, - expiresAt, - }); + if (isListedSubject(subject)) { + yield* emitUpsert({ + id, + scopes: input?.scopes ?? AuthStandardClientScopes, + subject, + ...(input?.label ? { label: input.label } : {}), + createdAt: now, + expiresAt, + }); + } return issued; }); @@ -561,7 +569,9 @@ const make = Effect.gen(function* () { .pipe(Effect.mapError((cause) => new BootstrapCredentialConsumeAvailableError({ cause }))); if (Option.isSome(consumed)) { - yield* emitRemoved(consumed.value.id); + if (isListedSubject(consumed.value.subject)) { + yield* emitRemoved(consumed.value.id); + } return { method: consumed.value.method, scopes: consumed.value.scopes, diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 8044740a7e2ee..87a6f7fd20ce9 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -1,4 +1,6 @@ import { + AuthAccessReadScope, + AuthAccessWriteScope, AuthEnvironmentMaintainScope, AuthDiagnosticsReadScope, AuthFilesystemReadScope, @@ -19,6 +21,7 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import * as RpcTest from "effect/rpc/RpcTest"; @@ -133,6 +136,50 @@ describe("RPC scope middleware", () => { ); }); +describe("tailnet authorization", () => { + const tested = [WS_METHODS.tailnetPeersList, WS_METHODS.tailnetDevicesBlock] as const; + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !(tested as ReadonlyArray).includes(tag), + ), + ); + + it.effect.each([ + { + scopes: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope], + peers: false, + block: false, + }, + { scopes: [AuthAccessReadScope], peers: true, block: false }, + { scopes: [AuthAccessReadScope, AuthAccessWriteScope], peers: true, block: true }, + ])( + "lists peers only with access:read and blocks only with access:write: %o", + ({ scopes, peers, block }) => + Effect.gen(function* () { + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.tailnetPeersList, () => + Effect.succeed({ peers: [] }), + ), + group.toLayerHandler(WS_METHODS.tailnetDevicesBlock, () => + Effect.succeed({ revokedSessionCount: 0 }), + ), + RpcAuthorization.layer(scopes), + ), + ), + ); + const peersExit = yield* Effect.exit(client[WS_METHODS.tailnetPeersList]({})); + const blockExit = yield* Effect.exit( + client[WS_METHODS.tailnetDevicesBlock]({ stableNodeId: "nPhone" }), + ); + expect(Exit.isSuccess(peersExit)).toBe(peers); + expect(Exit.isSuccess(blockExit)).toBe(block); + }).pipe(Effect.scoped), + ); +}); + describe("settings mutation authorization", () => { const group = WsRpcGroup.omit( ...[...WsRpcGroup.requests.keys()].filter( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index f040dbc459406..eaa5384b6d7aa 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -29,6 +29,7 @@ import { RpcScopeAuthorization, WS_METHODS, WsRpcGroup, + tailnetScopeDenial, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -87,6 +88,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope, [WS_METHODS.serverGetDatabaseStorage]: AuthOrchestrationReadScope, [WS_METHODS.peerLinksList]: AuthAccessReadScope, + // Narrow tokens, such as MCP clients, get no map of the tailnet. + [WS_METHODS.tailnetPeersList]: AuthAccessReadScope, [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope, [WS_METHODS.serverPrepareAcpRegistryAgent]: AuthProvidersManageScope, @@ -222,9 +225,14 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope return requiredScope; } -export const rpcAuthorizationError = (requiredScope: AuthEnvironmentScope) => +export const rpcAuthorizationError = ( + requiredScope: AuthEnvironmentScope, + grantedByTailnet = false, +) => new EnvironmentAuthorizationError({ - message: `The authenticated token is missing required scope: ${requiredScope}.`, + message: grantedByTailnet + ? tailnetScopeDenial(requiredScope) + : `The authenticated token is missing required scope: ${requiredScope}.`, ...authScopeRequiredResponse(requiredScope), }); @@ -281,11 +289,15 @@ const requiredScopesForRpcCall = ( export const layer = ( scopes: ReadonlyArray, onAuthorized?: (method: string, payload: unknown) => Effect.Effect, + /** The session came from a tailnet grant, so a missing scope is the grant's to add. */ + grantedByTailnet = false, ) => Layer.succeed(RpcScopeAuthorization)((effect, { rpc, payload }) => { const requiredScopes = requiredScopesForRpcCall(rpc._tag, payload); const requiredScope = requiredScopes.find((scope) => !scopes.includes(scope)); - if (requiredScope !== undefined) return Effect.fail(rpcAuthorizationError(requiredScope)); + if (requiredScope !== undefined) { + return Effect.fail(rpcAuthorizationError(requiredScope, grantedByTailnet)); + } return onAuthorized === undefined ? effect : Effect.tap(effect, () => onAuthorized(rpc._tag, payload)); diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 023e57d68a76e..dd07edf17936f 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -83,7 +83,7 @@ const makeDiskSessionStoreLayer = Effect.fn("makeDiskSessionStoreLayer")(functio }); it.layer(NodeServices.layer)("SessionStore.layer", (it) => { - it.effect.each(["revoke", "revokeAllExcept", "replace"] as const)( + it.effect.each(["revoke", "revokeAllExcept", "revokeBySubject", "replace"] as const)( "invalidates every connection for a session on %s and leaves other sessions active", (operation) => Effect.gen(function* () { @@ -105,7 +105,13 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { .pipe(Effect.forkScoped({ startImmediately: true })); if (operation === "revoke") yield* sessions.revoke(client.sessionId); else if (operation === "revokeAllExcept") yield* sessions.revokeAllExcept(owner.sessionId); - else yield* sessions.issue({ ...relaySessionInput, replaceSessionId: client.sessionId }); + else if (operation === "revokeBySubject") { + const sibling = yield* sessions.issue(relaySessionInput); + expect(yield* sessions.revokeBySubject(relaySessionInput.subject)).toBe(2); + expect((yield* Effect.flip(sessions.verify(sibling.token)))._tag).toBe( + "SessionTokenRevokedError", + ); + } else yield* sessions.issue({ ...relaySessionInput, replaceSessionId: client.sessionId }); yield* Fiber.join(first); yield* Fiber.join(second); expect(yield* Deferred.isDone(ownerInvalidated)).toBe(false); diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index be4d1786126ea..d470e89582c2a 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -351,6 +351,17 @@ export class OtherSessionsRevocationError extends Schema.TaggedError()( + "SubjectSessionsRevocationError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke the subject's sessions."; + } +} + export const SessionCredentialInternalError = Schema.Union([ SessionClaimsEncodingError, SessionCredentialIssueError, @@ -360,6 +371,7 @@ export const SessionCredentialInternalError = Schema.Union([ ActiveSessionsListError, SessionRevocationError, OtherSessionsRevocationError, + SubjectSessionsRevocationError, ]); export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type; @@ -420,6 +432,10 @@ export class SessionStore extends Context.Service< readonly revokeAllExcept: ( sessionId: AuthSessionId, ) => Effect.Effect; + /** Ends every session for `subject` and closes its sockets. Returns how many it ended. */ + readonly revokeBySubject: ( + subject: string, + ) => Effect.Effect; readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; } @@ -1004,22 +1020,14 @@ const make = Effect.gen(function* () { } return revoked; }, + // Stopped between the write and the notice, the session's open sockets would keep it. + Effect.uninterruptible, ); - const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( - "SessionStore.revokeAllExcept", - )(function* (sessionId) { - const revokedAt = yield* DateTime.now; - const revokedSessionIds = yield* authSessions - .revokeAllExcept({ - currentSessionId: sessionId, - revokedAt, - }) - .pipe( - Effect.mapError( - (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }), - ), - ); + // Untraced: it runs inside the revokeAllExcept and revokeBySubject spans. + const forgetRevokedSessions = Effect.fnUntraced(function* ( + revokedSessionIds: ReadonlyArray, + ) { if (revokedSessionIds.length > 0) { yield* Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); @@ -1040,6 +1048,42 @@ const make = Effect.gen(function* () { return revokedSessionIds.length; }); + const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( + "SessionStore.revokeAllExcept", + )( + function* (sessionId) { + const revokedAt = yield* DateTime.now; + const revokedSessionIds = yield* authSessions + .revokeAllExcept({ + currentSessionId: sessionId, + revokedAt, + }) + .pipe( + Effect.mapError( + (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }), + ), + ); + return yield* forgetRevokedSessions(revokedSessionIds); + }, + // As in revokeBySubject: the write and its notices go together. + Effect.uninterruptible, + ); + + const revokeBySubject: SessionStore["Service"]["revokeBySubject"] = Effect.fn( + "SessionStore.revokeBySubject", + )( + function* (subject) { + const revokedAt = yield* DateTime.now; + const revokedSessionIds = yield* authSessions + .revokeBySubject({ subject, revokedAt }) + .pipe(Effect.mapError((cause) => new SubjectSessionsRevocationError({ cause }))); + return yield* forgetRevokedSessions(revokedSessionIds); + }, + // Stopped between the write and the notice, a retry finds nothing left to revoke, and + // open sockets would wait out their credentials. + Effect.uninterruptible, + ); + const awaitInvalidation = Effect.fn(function* ( sessionId: AuthSessionId, proofKeyThumbprint?: string, @@ -1113,6 +1157,7 @@ const make = Effect.gen(function* () { }, revoke, revokeAllExcept, + revokeBySubject, markConnected, markDisconnected, }); diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts new file mode 100644 index 0000000000000..35209a282c7c5 --- /dev/null +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -0,0 +1,1882 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign. +import * as NodeCrypto from "node:crypto"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthStandardClientScopes, TAILNET_APP_CAPABILITY } from "@t3tools/contracts"; +import { computeDpopJwkThumbprint, type DpopPublicJwk } from "@t3tools/shared/dpop"; +import { parseTailscaleStatus, parseTailscaleWhois } from "@t3tools/tailscale"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type * as HttpServerRequest from "effect/http/HttpServerRequest"; + +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; +import * as AuthTailnetDeviceBlocks from "../persistence/AuthTailnetDeviceBlocks.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import * as TailscaleHost from "../tailscale/TailscaleHost.ts"; +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import * as ServerSecretStore from "./ServerSecretStore.ts"; +import * as SessionStore from "./SessionStore.ts"; +import * as TailnetSignIn from "./TailnetSignIn.ts"; + +// 2^53 + 1 and 2^53: equal as JS numbers, different Tailscale users. +const OWNER_ID = "9007199254740993"; +const NEIGHBOR_ID = "9007199254740992"; +const HOST_ADDRESS = "100.100.1.1"; +const PHONE_ADDRESS = "100.88.1.2"; + +type JsonObject = Record; + +// Writes user IDs as bare int64 digits, the way Tailscale does. +const tailscaleJson = (value: JsonObject) => JSON.stringify(value).replace(/"int64:(\d+)"/gu, "$1"); + +const hostStatus = (overrides: JsonObject = {}) => ({ + Version: "1.102.2-t6cac91817", + BackendState: "Running", + TUN: true, + Self: { + ID: "nBox", + UserID: `int64:${OWNER_ID}`, + DNSName: "box.tail.ts.net.", + TailscaleIPs: [HOST_ADDRESS], + }, + ...overrides, +}); + +const phoneNode = (overrides: JsonObject = {}): JsonObject => ({ + StableID: "nPhone", + User: `int64:${OWNER_ID}`, + Addresses: [`${PHONE_ADDRESS}/32`], + AllowedIPs: [`${PHONE_ADDRESS}/32`], + ComputedName: "phone", + ...overrides, +}); + +/** What `tailscale status` and `tailscale whois` print; a test may change it between calls. */ +interface Tailnet { + status: JsonObject; + /** What the host's kept status read still says, as for a few seconds after a change. */ + staleStatus?: JsonObject; + /** How many times sign-in read `tailscale status` now rather than the kept read. */ + freshReads?: number; + /** The caller's address is over its whois limit. */ + rateLimited?: boolean; + /** The read now with this count (1 is the first) fails, as when tailscaled stalls. */ + failFreshReadAt?: number; + /** Runs before each read now, with its count, so a test can switch accounts at one. */ + onFreshRead?: (count: number) => void; + /** Holds the read now with this count until `release`. */ + holdFreshRead?: { + readonly at: number; + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; + node: JsonObject | null; + /** The whois `CapMap`: the app capabilities grants give the device. */ + capMap?: JsonObject; + /** The login whois reports for `node`. */ + loginName?: string; + /** Other devices whois answers for, by address. */ + devices?: Record; + /** Holds the next sign-in code a sign-in creates until `release`. */ + holdCodeIssue?: { + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; + /** Holds the next read of the sign-in codes the recheck lists until `release`. */ + holdCodeList?: { + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; + /** + * Holds the next whois for `address` until `release`. It answers from the tailnet as it + * was when the whois began, or with `late`, as it is once released. + */ + hold?: { + readonly address: string; + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + readonly late?: boolean; + }; +} + +/** A whois `CapMap` with these T3 Code grant values. */ +const granted = (...values: ReadonlyArray): JsonObject => ({ + [TAILNET_APP_CAPABILITY]: values, +}); + +// `tailscale status` lists the device that asks among its peers, as a real host does. +const withCaller = (tailnet: Tailnet, status: JsonObject): JsonObject => { + const node = tailnet.node; + const peers = (status.Peer ?? {}) as Record; + const self = status.Self as JsonObject | undefined; + // The host itself is listed under `Self`, not among the peers. + if ( + node === null || + node.StableID === self?.ID || + Object.values(peers).some((peer) => peer.ID === node.StableID) + ) { + return status; + } + const addresses = (node.Addresses as ReadonlyArray | undefined) ?? []; + return { + ...status, + Peer: { + ...peers, + caller: { + ID: node.StableID, + TailscaleIPs: addresses.map((address) => address.replace(/\/\d+$/u, "")), + UserID: node.User, + ...(node.Tags === undefined ? {} : { Tags: node.Tags }), + }, + }, + }; +}; + +const layerTailscaleHost = (tailnet: Tailnet) => + Layer.succeed( + TailscaleHost.TailscaleHost, + TailscaleHost.TailscaleHost.of({ + inUse: Effect.succeed(true), + status: Effect.suspend(() => + parseTailscaleStatus( + tailscaleJson(withCaller(tailnet, tailnet.staleStatus ?? tailnet.status)), + ), + ), + freshStatus: Effect.gen(function* () { + const count = (tailnet.freshReads ?? 0) + 1; + tailnet.freshReads = count; + tailnet.onFreshRead?.(count); + const hold = tailnet.holdFreshRead; + if (hold?.at === count) { + yield* Deferred.succeed(hold.started, undefined); + yield* Deferred.await(hold.release); + } + return yield* count === tailnet.failFreshReadAt + ? parseTailscaleStatus("not json") + : parseTailscaleStatus(tailscaleJson(withCaller(tailnet, tailnet.status))); + }), + whois: (address, options) => + Effect.gen(function* () { + if (tailnet.rateLimited === true && options?.fromServer !== true) { + return yield* new TailscaleHost.TailscaleWhoisRateLimitedError({}); + } + const answer = () => { + const device = tailnet.devices?.[address] ?? { + node: tailnet.node, + capMap: tailnet.capMap, + }; + return device.node === null + ? "{}" + : tailscaleJson({ + Node: device.node, + CapMap: device.capMap ?? null, + ...(tailnet.loginName === undefined || tailnet.devices?.[address] !== undefined + ? {} + : { UserProfile: { LoginName: tailnet.loginName } }), + }); + }; + const early = answer(); + const hold = tailnet.hold; + if (hold?.address === address) { + delete tailnet.hold; + yield* Deferred.succeed(hold.started, undefined); + yield* Deferred.await(hold.release); + if (hold.late === true) return yield* parseTailscaleWhois(answer()); + } + const json = early; + return yield* parseTailscaleWhois(json); + }), + }), + ); + +const layerCodeList = (tailnet: Tailnet) => + Layer.effect( + AuthPairingLinks.AuthPairingLinkRepository, + Effect.gen(function* () { + const links = yield* AuthPairingLinks.AuthPairingLinkRepository; + return AuthPairingLinks.AuthPairingLinkRepository.of({ + ...links, + listActive: (input) => + Effect.gen(function* () { + const hold = tailnet.holdCodeList; + if (hold !== undefined) { + delete tailnet.holdCodeList; + yield* Deferred.succeed(hold.started, undefined); + yield* Deferred.await(hold.release); + } + return yield* links.listActive(input); + }), + }); + }), + ); + +const layerCodeIssue = (tailnet: Tailnet) => + Layer.effect( + EnvironmentAuth.EnvironmentAuth, + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + return EnvironmentAuth.EnvironmentAuth.of({ + ...auth, + createPairingLink: (input) => + Effect.gen(function* () { + const hold = tailnet.holdCodeIssue; + if (hold !== undefined) { + delete tailnet.holdCodeIssue; + yield* Deferred.succeed(hold.started, undefined); + yield* Deferred.await(hold.release); + } + return yield* auth.createPairingLink(input); + }), + }); + }), + ); + +const layerTest = ( + tailnet: Tailnet, + overrides: Partial = {}, +) => + TailnetSignIn.layer.pipe( + Layer.provide(layerCodeList(tailnet)), + Layer.provide(layerCodeIssue(tailnet)), + Layer.provideMerge(EnvironmentAuth.layer.pipe(Layer.provide(ManagedPolicy.layerTest()))), + Layer.provideMerge(SqlitePersistence.layerMemory), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provide(ServerEnvironment.layerIdentity), + Layer.provide(layerTailscaleHost(tailnet)), + Layer.provideMerge( + Layer.effect( + ServerConfig.ServerConfig, + ServerConfig.ServerConfig.pipe( + Effect.map((config) => ({ ...config, tailscaleSignInEnabled: true, ...overrides })), + ), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-tailnet-test-" }))), + ), + ); + +/** What `t3 tailscale require-grant on` writes. */ +const requireGrantOn = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString(`${config.stateDir}/${TailnetSignIn.REQUIRE_GRANT_FILE}`, "test\n"); +}); + +const makeDpopKey = () => { + const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk; + let proofCount = 0; + return { + thumbprint: computeDpopJwkThumbprint(publicJwk), + sign: (url: string, iat: number) => { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + const header = encode({ typ: "dpop+jwt", alg: "ES256", jwk: publicJwk }); + const payload = encode({ htm: "POST", htu: url, jti: `proof-${++proofCount}`, iat }); + const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { + key: privateKey, + dsaEncoding: "ieee-p1363", + }).toString("base64url"); + return `${header}.${payload}.${signature}`; + }, + }; +}; + +interface RequestShape { + readonly origin?: string; + readonly host?: string; + readonly remoteAddress?: string; + readonly localAddress?: string; + readonly dpop?: boolean; +} + +const signInRequest = Effect.fn("test.signInRequest")(function* ( + key: ReturnType, + shape: RequestShape = {}, +) { + const host = shape.host ?? `${HOST_ADDRESS}:3773`; + const iat = Math.floor((yield* DateTime.now).epochMilliseconds / 1_000); + return { + method: "POST", + url: "/api/auth/tailnet", + originalUrl: "/api/auth/tailnet", + cookies: {}, + headers: { + host, + ...(shape.origin === undefined ? {} : { origin: shape.origin }), + ...(shape.dpop === false ? {} : { dpop: key.sign(`http://${host}/api/auth/tailnet`, iat) }), + }, + source: { + socket: { + remoteAddress: shape.remoteAddress ?? PHONE_ADDRESS, + localAddress: shape.localAddress ?? HOST_ADDRESS, + localPort: 3773, + }, + }, + } as unknown as HttpServerRequest.HttpServerRequest; +}); + +const signIn = (key: ReturnType, shape?: RequestShape) => + Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + return yield* service.signIn(yield* signInRequest(key, shape)); + }); + +const exchange = (credential: string, key: ReturnType) => + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + return yield* auth.exchangeBootstrapCredentialForAccessToken( + credential, + undefined, + { deviceType: "mobile" }, + { proofKeyThumbprint: key.thumbprint }, + ); + }); + +const isRefusal = Schema.is(TailnetSignIn.TailnetSignInRefusedError); + +/** "signed in", the refusal reason, or the tag of any other failure. */ +const outcomeOf = (effect: Effect.Effect) => + effect.pipe( + Effect.as("signed in"), + Effect.catch((error) => Effect.succeed(isRefusal(error) ? error.reason : error._tag)), + ); + +/** The refusal reason, or the tag of any other failure. */ +const refusalOf = (effect: Effect.Effect) => + Effect.flip(effect).pipe( + Effect.map((error): string => (isRefusal(error) ? error.reason : error._tag)), + ); + +it.layer(NodeServices.layer)("TailnetSignIn", (it) => { + it.effect.each<{ + readonly name: string; + readonly request?: RequestShape; + readonly status?: JsonObject; + readonly node?: JsonObject | null; + readonly capMap?: JsonObject; + readonly config?: Partial; + readonly requireGrant?: boolean; + readonly refused: string; + }>([ + { name: "sign-in is off", config: { tailscaleSignInEnabled: false }, refused: "disabled" }, + { name: "Origin is null", request: { origin: "null" }, refused: "browser_request" }, + { + name: "a web page asks", + request: { origin: "https://evil.example" }, + refused: "browser_request", + }, + { + name: "Host is a rebound name", + request: { host: "evil.example:3773" }, + refused: "browser_request", + }, + { + name: "Host names another port", + request: { host: HOST_ADDRESS }, + refused: "browser_request", + }, + { + name: "the peer is not on the tailnet", + request: { remoteAddress: "192.168.1.5" }, + refused: "not_direct_tailnet", + }, + { + name: "the request comes through Serve or Funnel", + request: { remoteAddress: "127.0.0.1", localAddress: "127.0.0.1" }, + refused: "not_direct_tailnet", + }, + { + name: "it arrives on the LAN interface", + request: { localAddress: "192.168.1.20" }, + refused: "not_direct_tailnet", + }, + { + name: "the proof is missing", + request: { dpop: false }, + refused: "ServerAuthInvalidCredentialError", + }, + { + name: "Tailscale uses userspace networking", + status: hostStatus({ TUN: false }), + refused: "tailscale_unavailable", + }, + { + name: "Tailscale is too old", + status: hostStatus({ Version: "1.62.0" }), + refused: "tailscale_outdated", + }, + { + name: "the device was tagged after its lookup", + status: hostStatus({ + Peer: { + phone: { + ID: "nPhone", + TailscaleIPs: [PHONE_ADDRESS], + UserID: `int64:${OWNER_ID}`, + Tags: ["tag:ci"], + }, + }, + }), + refused: "tailscale_unavailable", + }, + { + name: "the device's tags changed after its lookup", + status: hostStatus({ + Peer: { + phone: { + ID: "nPhone", + TailscaleIPs: [PHONE_ADDRESS], + UserID: `int64:${OWNER_ID}`, + Tags: ["tag:reader"], + }, + }, + }), + node: phoneNode({ Tags: ["tag:admin"] }), + capMap: granted({ scopes: ["admin"] }), + refused: "tailscale_unavailable", + }, + { + name: "the device's user changed after its lookup", + status: hostStatus({ + Peer: { + phone: { ID: "nPhone", TailscaleIPs: [PHONE_ADDRESS], UserID: `int64:${NEIGHBOR_ID}` }, + }, + }), + refused: "tailscale_unavailable", + }, + { + name: "the device's node key expired", + status: hostStatus({ + Peer: { + phone: { + ID: "nPhone", + TailscaleIPs: [PHONE_ADDRESS], + UserID: `int64:${OWNER_ID}`, + Expired: true, + }, + }, + }), + refused: "not_direct_tailnet", + }, + { + name: "the host is tagged with no owner set", + status: hostStatus({ + Self: { ID: "nBox", Tags: ["tag:server"], TailscaleIPs: [HOST_ADDRESS] }, + }), + refused: "owner_unknown", + }, + { + name: "the device is tagged", + node: phoneNode({ Tags: ["tag:ci"] }), + refused: "tagged_device", + }, + { + name: "the device is shared in", + node: phoneNode({ Sharer: "int64:555" }), + refused: "shared_device", + }, + { + name: "it is this host's own node", + node: phoneNode({ StableID: "nBox" }), + refused: "own_node", + }, + { + name: "another user owns the device", + node: phoneNode({ User: `int64:${NEIGHBOR_ID}` }), + refused: "other_user", + }, + { + name: "the device is a subnet router", + node: phoneNode({ + AllowedIPs: [`${PHONE_ADDRESS}/32`, "192.168.1.0/24"], + PrimaryRoutes: ["192.168.1.0/24"], + }), + refused: "routes_for_others", + }, + { + name: "the host requires a grant and the owner's device has none", + requireGrant: true, + refused: "no_grant", + }, + { + name: "the device's grant names no scope T3 Code knows", + capMap: granted({ scopes: ["sudo"] }), + refused: "grant_unusable", + }, + { + name: "the policy grants a subnet router", + node: phoneNode({ + AllowedIPs: [`${PHONE_ADDRESS}/32`, "192.168.1.0/24"], + PrimaryRoutes: ["192.168.1.0/24"], + }), + capMap: granted({ scopes: ["standard"] }), + refused: "routes_for_others", + }, + { + name: "the device's grant has no value", + capMap: granted(), + refused: "grant_unusable", + }, + { + name: "the device's grant names inherited object keys", + capMap: granted({ scopes: ["__proto__", "constructor", "toString"] }), + refused: "grant_unusable", + }, + { + name: "a grant reaches the host's own node", + node: phoneNode({ StableID: "nBox" }), + capMap: granted({ scopes: ["standard"] }), + refused: "own_node", + }, + { + name: "whois leaves out a required field", + node: { StableID: "nPhone", User: `int64:${OWNER_ID}`, Addresses: [`${PHONE_ADDRESS}/32`] }, + refused: "tailscale_unavailable", + }, + ])("refuses when $name", ({ request, status, node, capMap, config, requireGrant, refused }) => + Effect.gen(function* () { + if (requireGrant === true) yield* requireGrantOn; + expect(yield* refusalOf(signIn(makeDpopKey(), request))).toBe(refused); + }).pipe( + Effect.provide( + layerTest( + { + status: status ?? hostStatus(), + node: node ?? phoneNode(), + ...(capMap === undefined ? {} : { capMap }), + }, + config, + ), + ), + ), + ); + + const READ_SCOPES = [ + "orchestration:read", + "diagnostics:read", + "terminal:read", + "filesystem:read", + "access:read", + "relay:read", + ]; + // A standard grant cannot manage access or the T3 Connect link, so it cannot let in a device + // the policy does not name. + const STANDARD_GRANT_SCOPES = AuthStandardClientScopes.filter( + (scope) => scope !== "access:write" && scope !== "relay:write", + ); + const TAGGED_HOST = hostStatus({ + Self: { ID: "nBox", Tags: ["tag:t3-box"], TailscaleIPs: [HOST_ADDRESS] }, + }); + + it.effect.each<{ + readonly name: string; + readonly request?: RequestShape; + readonly status?: JsonObject; + readonly node?: JsonObject; + readonly capMap?: JsonObject; + readonly config?: Partial; + readonly requireGrant?: boolean; + readonly scopes?: ReadonlyArray; + }>([ + { name: "a native app" }, + { + name: "a tagged device the policy grants", + node: phoneNode({ Tags: ["tag:ci"] }), + capMap: granted({ scopes: ["standard"] }), + scopes: STANDARD_GRANT_SCOPES, + }, + { name: "a device the policy grants admin", capMap: granted({ scopes: ["admin"] }) }, + { + name: "another user's device the policy grants read-only access", + node: phoneNode({ User: `int64:${NEIGHBOR_ID}` }), + capMap: granted({ scopes: ["read"] }), + scopes: READ_SCOPES, + }, + { + name: "a granted device on a tagged host with no owner, which requires grants", + status: TAGGED_HOST, + capMap: granted({ scopes: ["orchestration:read"] }, { scopes: ["read", "nope"] }), + requireGrant: true, + scopes: READ_SCOPES, + }, + { + name: "an exit node", + node: phoneNode({ AllowedIPs: [`${PHONE_ADDRESS}/32`, "0.0.0.0/0", "::/0"] }), + }, + { name: "the desktop renderer", request: { origin: "t3code://app" } }, + { name: "an IPv4-mapped peer address", request: { remoteAddress: `::ffff:${PHONE_ADDRESS}` } }, + { name: "the MagicDNS name", request: { host: "box.tail.ts.net:3773" } }, + ])( + "signs in $name, and the exchange makes a tailnet session", + ({ request, status, node, capMap, config, requireGrant, scopes }) => + Effect.gen(function* () { + if (requireGrant === true) yield* requireGrantOn; + const key = makeDpopKey(); + const grant = yield* signIn(key, request); + const token = yield* exchange(grant.credential, key); + const sessions = yield* SessionStore.SessionStore; + const session = yield* sessions.verify(token.access_token); + expect(session).toMatchObject({ + subject: "tailscale:nPhone", + method: "dpop-access-token", + proofKeyThumbprint: key.thumbprint, + }); + expect(session.scopes).toEqual(scopes ?? AuthStandardClientScopes); + }).pipe( + Effect.provide( + layerTest( + { + status: status ?? hostStatus(), + node: node ?? phoneNode(), + ...(capMap === undefined ? {} : { capMap }), + }, + config, + ), + ), + ), + ); + + it.effect("a request without a valid proof never makes T3 Code read Tailscale again", () => { + const tailnet: Tailnet = { status: hostStatus(), node: phoneNode() }; + return Effect.gen(function* () { + expect(yield* refusalOf(signIn(makeDpopKey(), { dpop: false }))).toBe( + "ServerAuthInvalidCredentialError", + ); + expect(tailnet.freshReads ?? 0).toBe(0); + // Over its lookup limit, it is refused before its proof costs a replay-marker write. + tailnet.rateLimited = true; + expect(yield* refusalOf(signIn(makeDpopKey(), { dpop: false }))).toBe("rate_limited"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a whois answered before an account switch is not judged by the new account", () => { + const tailnet: Tailnet = { + status: hostStatus(), + node: phoneNode(), + capMap: granted({ scopes: ["admin"] }), + }; + return Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.hold = { address: PHONE_ADDRESS, started, release }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + // The new account names another device at the phone's address. + tailnet.node = phoneNode({ StableID: "nPhoneAtWork" }); + tailnet.status = hostStatus({ + Self: { ...hostStatus().Self, ID: "nBoxAtWork" }, + Peer: { + phone: { ID: "nPhoneAtWork", TailscaleIPs: [PHONE_ADDRESS], UserID: `int64:${OWNER_ID}` }, + }, + }); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("tailscale_unavailable"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a Tailscale session gets exactly its grant's scopes, never a narrower request", () => + Effect.gen(function* () { + const key = makeDpopKey(); + const grant = yield* signIn(key); + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const narrowed = auth.exchangeBootstrapCredentialForAccessToken( + grant.credential, + ["orchestration:read"], + { deviceType: "mobile" }, + { proofKeyThumbprint: key.thumbprint }, + ); + expect(yield* refusalOf(narrowed)).toBe("ServerAuthScopeNotGrantedError"); + }).pipe(Effect.provide(layerTest({ status: hostStatus(), node: phoneNode() }))), + ); + + it.effect.each([ + { device: "a shared-in device", node: phoneNode({ Sharer: "int64:555" }) }, + { device: "a tagged device", node: phoneNode({ Tags: ["tag:ci"] }) }, + ])("$device's suggested grant names it and this machine only, by address", ({ node }) => + Effect.gen(function* () { + yield* requireGrantOn; + const error = yield* Effect.flip(signIn(makeDpopKey())); + const grant = isRefusal(error) ? error.suggestedGrant : undefined; + expect(grant).toContain(`"src": ["${PHONE_ADDRESS}"]`); + expect(grant).toContain(`"dst": ["${HOST_ADDRESS}"]`); + // Addresses do not say whose they are, so a note names both. + expect(grant).toContain("// phone on box"); + }).pipe( + Effect.provide( + layerTest({ + status: hostStatus({ + Self: { ...hostStatus().Self, Tags: ["tag:t3-box"] }, + }), + node, + loginName: "friend@other.example", + }), + ), + ), + ); + + it.effect("an unusable grant gets no suggested replacement", () => + Effect.gen(function* () { + const error = yield* Effect.flip(signIn(makeDpopKey())); + expect(isRefusal(error) ? [error.reason, error.suggestedGrant] : error).toEqual([ + "grant_unusable", + undefined, + ]); + }).pipe( + Effect.provide( + layerTest({ + status: hostStatus(), + node: phoneNode(), + capMap: granted({ scopes: ["raed"] }), + }), + ), + ), + ); + + it.effect("signs in the configured owner's device on a tagged host", () => + Effect.gen(function* () { + const grant = yield* signIn(makeDpopKey()); + expect(grant.credential).toBeTruthy(); + }).pipe( + Effect.provide( + layerTest( + { + status: hostStatus({ + Self: { ID: "nBox", Tags: ["tag:server"], TailscaleIPs: [HOST_ADDRESS] }, + }), + node: phoneNode(), + }, + { tailscaleOwnerUserId: OWNER_ID }, + ), + ), + ), + ); + + it.effect("reruns every check on renewal", () => { + const tailnet: Tailnet = { status: hostStatus(), node: phoneNode() }; + return Effect.gen(function* () { + const key = makeDpopKey(); + yield* signIn(key); + tailnet.node = phoneNode({ Tags: ["tag:agents"] }); + expect(yield* refusalOf(signIn(key))).toBe("tagged_device"); + // A grant lets the tagged device in; once it is gone, a scan finds none and the owner + // rule refuses the tag again. + tailnet.capMap = granted({ scopes: ["standard"] }); + yield* signIn(key); + delete tailnet.capMap; + expect(yield* refusalOf(signIn(key))).toBe("tagged_device"); + tailnet.node = null; + expect(yield* refusalOf(signIn(key))).toBe("tailscale_unavailable"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + describe("once the policy grants T3 Code here", () => { + const LAPTOP_ADDRESS = "100.88.1.3"; + const laptop = phoneNode({ + StableID: "nLaptop", + Addresses: [`${LAPTOP_ADDRESS}/32`], + AllowedIPs: [`${LAPTOP_ADDRESS}/32`], + ComputedName: "laptop", + }); + const peers = { + phone: { ID: "nPhone", TailscaleIPs: [PHONE_ADDRESS], UserID: `int64:${OWNER_ID}` }, + laptop: { ID: "nLaptop", TailscaleIPs: [LAPTOP_ADDRESS], UserID: `int64:${OWNER_ID}` }, + }; + const withLaptop = (capMap?: JsonObject): Tailnet => ({ + status: hostStatus({ Peer: peers }), + node: phoneNode(), + devices: { [LAPTOP_ADDRESS]: { node: laptop, ...(capMap === undefined ? {} : { capMap }) } }, + }); + const revoked = (token: string) => + SessionStore.SessionStore.pipe( + Effect.flatMap((sessions) => sessions.verify(token)), + Effect.match({ + onFailure: (error) => error._tag === "SessionTokenRevokedError", + onSuccess: () => false, + }), + ); + // Another user's device. Refused, it leaves no sign-in code, so no background recheck + // runs a scan while a test moves the clock. + const STRANGER_ADDRESS = "100.88.1.30"; + const stranger = phoneNode({ + StableID: "nStranger", + User: "int64:999", + Addresses: [`${STRANGER_ADDRESS}/32`], + AllowedIPs: [`${STRANGER_ADDRESS}/32`], + }); + const strangerPeer = { ID: "nStranger", TailscaleIPs: [STRANGER_ADDRESS], UserID: "int64:999" }; + // More of the owner's own devices. + const DESK_ADDRESS = "100.88.1.40"; + const ownDevice = (id: string, address: string) => + phoneNode({ StableID: id, Addresses: [`${address}/32`], AllowedIPs: [`${address}/32`] }); + const ownPeer = (id: string, address: string) => ({ + ID: id, + TailscaleIPs: [address], + UserID: `int64:${OWNER_ID}`, + }); + const signInLaptop = (key: ReturnType) => + signIn(key, { remoteAddress: LAPTOP_ADDRESS }).pipe( + Effect.flatMap((grant) => exchange(grant.credential, key)), + ); + + it.effect("the owner's devices without a grant are refused before anyone signs in", () => + Effect.gen(function* () { + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("no_grant"); + }).pipe(Effect.provide(layerTest(withLaptop(granted({ scopes: ["standard"] }))))), + ); + + it.effect( + "a granted peer that Tailscale lists with no ID still turns the owner rule off", + () => { + const { ID: _, ...unidentified } = peers.laptop; + const tailnet = withLaptop(granted({ scopes: ["standard"] })); + tailnet.status = hostStatus({ Peer: { ...peers, laptop: unidentified } }); + return Effect.gen(function* () { + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("no_grant"); + }).pipe(Effect.provide(layerTest(tailnet))); + }, + ); + + it.effect("turning sign-in off ends Tailscale sessions at the next start", () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + const key = makeDpopKey(); + const session = yield* exchange((yield* signIn(key)).credential, key); + const unusedKey = makeDpopKey(); + // The laptop has a sign-in code but no session yet. + const unused = yield* signIn(unusedKey, { remoteAddress: LAPTOP_ADDRESS }); + const config = yield* ServerConfig.ServerConfig; + yield* TailnetSignIn.make.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + ...config, + tailscaleSignInEnabled: false, + }), + Effect.provide(layerTailscaleHost(tailnet)), + Effect.scoped, + ); + expect(yield* revoked(session.access_token)).toBe(true); + // A sign-in code not yet exchanged cannot bring the device back. + expect((yield* Effect.exit(exchange(unused.credential, unusedKey)))._tag).toBe("Failure"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a device that loses or narrows its grant, or leaves, is cut off", () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + const phoneKey = makeDpopKey(); + const phone = yield* exchange((yield* signIn(phoneKey)).credential, phoneKey); + + // The first sign-in through a grant ends the owner rule without waiting for a scan. + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["standard"] }) }, + }; + const laptopKey = makeDpopKey(); + const standard = yield* signInLaptop(laptopKey); + yield* service.recheckSessions; + expect(yield* revoked(phone.access_token)).toBe(true); + + // A changed grant ends each session it no longer matches, narrower or wider. + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + yield* service.recheckSessions; + expect(yield* revoked(standard.access_token)).toBe(true); + const read = yield* signInLaptop(laptopKey); + const readCode = yield* signIn(laptopKey, { remoteAddress: LAPTOP_ADDRESS }); + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["standard"] }) }, + }; + const widened = yield* signInLaptop(laptopKey); + const standardCode = yield* signIn(laptopKey, { remoteAddress: LAPTOP_ADDRESS }); + yield* service.recheckSessions; + expect(yield* revoked(read.access_token)).toBe(true); + expect(yield* revoked(widened.access_token)).toBe(false); + // Of the codes not yet exchanged, only the one from the older grant goes. + expect(yield* refusalOf(exchange(readCode.credential, laptopKey))).toBe( + "ServerAuthInvalidCredentialError", + ); + yield* exchange(standardCode.credential, laptopKey); + + // Removing the grant ends the session and the sign-in not yet exchanged. + const pending = yield* signIn(laptopKey, { remoteAddress: LAPTOP_ADDRESS }); + tailnet.devices = { [LAPTOP_ADDRESS]: { node: laptop } }; + yield* service.recheckSessions; + expect(yield* revoked(widened.access_token)).toBe(true); + expect(yield* refusalOf(exchange(pending.credential, laptopKey))).toBe( + "ServerAuthInvalidCredentialError", + ); + + // So does leaving the tailnet, or a node key that expired, but a Tailscale that is + // starting proves nothing. + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + for (const gone of [ + { phone: peers.phone }, + { ...peers, laptop: { ...peers.laptop, Expired: true } }, + ]) { + tailnet.status = hostStatus({ Peer: peers }); + const last = yield* signInLaptop(laptopKey); + tailnet.status = hostStatus({ BackendState: "Starting" }); + yield* service.recheckSessions; + expect(yield* revoked(last.access_token)).toBe(false); + tailnet.status = hostStatus({ Peer: gone }); + yield* service.recheckSessions; + expect(yield* revoked(last.access_token)).toBe(true); + } + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a Tailscale too old for sign-in ends sessions once the grace runs out", () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + const session = yield* signInLaptop(makeDpopKey()); + tailnet.status = hostStatus({ Peer: peers, Version: "1.62.0" }); + yield* TestClock.adjust("30 seconds"); + expect(yield* revoked(session.access_token)).toBe(false); + yield* TestClock.adjust("2 minutes"); + expect(yield* revoked(session.access_token)).toBe(true); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("an unused sign-in is revoked with its grant, even with no session", () => { + // Tagged, so the owner rule cannot let it back in once the grant is gone. + const ciBox = { ...laptop, Tags: ["tag:ci"] }; + const tailnet: Tailnet = { + ...withLaptop(), + status: hostStatus({ Peer: { ...peers, laptop: { ...peers.laptop, Tags: ["tag:ci"] } } }), + devices: { [LAPTOP_ADDRESS]: { node: ciBox, capMap: granted({ scopes: ["standard"] }) } }, + }; + return Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + const key = makeDpopKey(); + const pending = yield* signIn(key, { remoteAddress: LAPTOP_ADDRESS }); + tailnet.devices = { [LAPTOP_ADDRESS]: { node: ciBox } }; + yield* service.recheckSessions; + expect(yield* refusalOf(exchange(pending.credential, key))).toBe( + "ServerAuthInvalidCredentialError", + ); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect.each([{ when: "before it lists codes" }, { when: "after it lists codes" }])( + "a sign-in code exchanged while the recheck narrows its grant ends too ($when)", + ({ when }) => { + const tailnet = withLaptop(granted({ scopes: ["admin"] })); + return Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + const key = makeDpopKey(); + // The recheck the first grant starts is let through, then waited out. + const firstStarted = yield* Deferred.make(); + const firstGoes = yield* Deferred.make(); + yield* signInLaptop(key); + tailnet.hold = { address: LAPTOP_ADDRESS, started: firstStarted, release: firstGoes }; + yield* Deferred.await(firstStarted); + yield* Deferred.succeed(firstGoes, undefined); + yield* service.recheckSessions; + const pending = yield* signIn(key, { remoteAddress: LAPTOP_ADDRESS }); + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + // After the codes, the recheck lists sessions, then reads Tailscale. + if (when === "before it lists codes") tailnet.holdCodeList = { started, release }; + else tailnet.holdFreshRead = { at: (tailnet.freshReads ?? 0) + 1, started, release }; + const recheck = yield* service.recheckSessions.pipe(Effect.forkChild); + yield* Deferred.await(started); + const exchanged = yield* exchange(pending.credential, key); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(recheck); + expect(yield* revoked(exchanged.access_token)).toBe(true); + }).pipe(Effect.provide(layerTest(tailnet))); + }, + ); + + it.effect("removing every grant lets the owner's devices back in after the next scan", () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + // A scan that found no grants is cached when the laptop's grant appears and is used. + yield* signIn(makeDpopKey()); + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["standard"] }) }, + }; + yield* signInLaptop(makeDpopKey()); + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("no_grant"); + tailnet.devices = { [LAPTOP_ADDRESS]: { node: laptop } }; + yield* TestClock.adjust("5 minutes"); + yield* signIn(makeDpopKey()); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + // Switching Tailscale accounts switches tailnets, so the last account's scan says nothing. + it.effect("after a switch to another Tailscale account, its own policy decides at once", () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + yield* signIn(makeDpopKey()); + // The kept status read still names the old account, with the same address and owner. + tailnet.staleStatus = tailnet.status; + tailnet.status = hostStatus({ + Peer: peers, + Self: { ...hostStatus().Self, ID: "nBoxAtWork" }, + }); + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["standard"] }) }, + }; + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("no_grant"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a policy scan that ran across an account switch decides nothing", () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + // The scan reads this account, then the laptop's whois answers after a switch. + tailnet.hold = { address: LAPTOP_ADDRESS, started, release }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + tailnet.status = hostStatus({ + Peer: peers, + Self: { ...hostStatus().Self, ID: "nBoxAtWork" }, + }); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("policy_unknown"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a device that joins during a policy scan keeps the scan from deciding", () => { + const TABLET_ADDRESS = "100.88.1.9"; + const tablet = { ID: "nTablet", TailscaleIPs: [TABLET_ADDRESS], UserID: `int64:${OWNER_ID}` }; + const tailnet: Tailnet = { + status: hostStatus({ Peer: { phone: peers.phone, tablet } }), + node: phoneNode(), + devices: { + [TABLET_ADDRESS]: { + node: phoneNode({ StableID: "nTablet", Addresses: [`${TABLET_ADDRESS}/32`] }), + }, + }, + }; + return Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.hold = { address: TABLET_ADDRESS, started, release }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + // The laptop joins with a grant after the scan read its list of devices. + tailnet.status = hostStatus({ Peer: { ...peers, tablet } }); + tailnet.devices = { + ...tailnet.devices, + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("policy_unknown"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a grant a scan found stands when a device joins during it", () => { + const TABLET_ADDRESS = "100.88.1.9"; + const tablet = { ID: "nTablet", TailscaleIPs: [TABLET_ADDRESS], UserID: `int64:${OWNER_ID}` }; + const tabletDevice = { + node: phoneNode({ StableID: "nTablet", Addresses: [`${TABLET_ADDRESS}/32`] }), + }; + const tailnet = withLaptop(); + tailnet.status = hostStatus({ Peer: { ...peers, stranger: strangerPeer } }); + tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; + return Effect.gen(function* () { + // A scan that finds no grants, from the refused stranger. + expect(yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: STRANGER_ADDRESS }))).toBe( + "other_user", + ); + // The laptop gets a grant, and the next scan reads it. + tailnet.devices = { + ...tailnet.devices, + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + yield* TestClock.adjust("1 minute"); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.hold = { address: LAPTOP_ADDRESS, started, release }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + // A tablet joins while that scan runs. + tailnet.status = hostStatus({ Peer: { ...peers, stranger: strangerPeer, tablet } }); + tailnet.devices = { ...tailnet.devices, [TABLET_ADDRESS]: tabletDevice }; + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("no_grant"); + // The laptop can no longer be read; the grant found is kept for five minutes as a + // full scan, so the older "no grants" never comes back in that time. + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: null }, + [STRANGER_ADDRESS]: { node: stranger }, + [TABLET_ADDRESS]: tabletDevice, + }; + yield* TestClock.adjust("11 seconds"); + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("no_grant"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a grant a scan saw stands when the scan cannot finish its checks", () => { + const tailnet = withLaptop(); + tailnet.status = hostStatus({ + Peer: { + ...peers, + stranger: strangerPeer, + }, + }); + tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; + return Effect.gen(function* () { + // A scan that finds no grants, from the refused stranger. + yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: STRANGER_ADDRESS })); + tailnet.devices = { + ...tailnet.devices, + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + yield* TestClock.adjust("1 minute"); + // The scan sees and keeps the laptop's grant, then its account check after the lookups + // cannot read Tailscale: the reads are the sign-in's, the grant's, then that check's. + tailnet.failFreshReadAt = (tailnet.freshReads ?? 0) + 3; + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("policy_unknown"); + // The laptop can no longer be read; the grant seen stands, not the older "no grants". + tailnet.devices = { ...tailnet.devices, [LAPTOP_ADDRESS]: { node: null } }; + yield* TestClock.adjust("11 seconds"); + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("no_grant"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a grant a scan saw stands when the scan is cut short", () => { + const TABLET_ADDRESS = "100.88.1.9"; + const tailnet = withLaptop(); + tailnet.status = hostStatus({ + Peer: { + ...peers, + stranger: strangerPeer, + tablet: { ID: "nTablet", TailscaleIPs: [TABLET_ADDRESS], UserID: `int64:${OWNER_ID}` }, + }, + }); + tailnet.devices = { + ...tailnet.devices, + [STRANGER_ADDRESS]: { node: stranger }, + [TABLET_ADDRESS]: { + node: phoneNode({ StableID: "nTablet", Addresses: [`${TABLET_ADDRESS}/32`] }), + }, + }; + return Effect.gen(function* () { + // A scan that finds no grants, from the refused stranger. + yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: STRANGER_ADDRESS })); + tailnet.devices = { + ...tailnet.devices, + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + yield* TestClock.adjust("1 minute"); + // The scan reads the laptop's grant, then the phone leaves while the tablet's lookup runs. + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.hold = { address: TABLET_ADDRESS, started, release }; + const phone = yield* signIn(makeDpopKey()).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(phone); + // The laptop can no longer be read; the grant seen stands, not the older "no grants". + tailnet.devices = { ...tailnet.devices, [LAPTOP_ADDRESS]: { node: null } }; + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("no_grant"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect.each([ + { + name: "another device at the laptop's address", + // Its node ID differs, so its grant never reaches confirmation. + backHome: "signed in", + atWork: { + node: phoneNode({ StableID: "nLaptopAtWork", Addresses: [`${LAPTOP_ADDRESS}/32`] }), + capMap: granted({ scopes: ["read"] }), + }, + }, + // Another profile on the same tailnet sees the same laptop, with its own grants. The grant + // may have been read here before the switch, so nothing older stands in for a full scan. + { + name: "the same laptop", + backHome: "policy_unknown", + atWork: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }, + ])( + "a grant read from another account ($name) during a scan is not kept here", + ({ atWork, backHome }) => { + const tailnet = withLaptop(); + tailnet.status = hostStatus({ Peer: { ...peers, stranger: strangerPeer } }); + tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; + const home = tailnet.status; + return Effect.gen(function* () { + // A scan that finds no grants, from the refused stranger. + yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: STRANGER_ADDRESS })); + yield* TestClock.adjust("1 minute"); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.hold = { address: LAPTOP_ADDRESS, started, release, late: true }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + // The host switches accounts; there, the laptop's address names a granted device. + tailnet.status = hostStatus({ + Peer: peers, + Self: { ...hostStatus().Self, ID: "nBoxAtWork" }, + }); + tailnet.devices = { ...tailnet.devices, [LAPTOP_ADDRESS]: atWork }; + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("policy_unknown"); + // Back home, with the laptop unreadable, that grant does not decide. + tailnet.status = home; + tailnet.devices = { ...tailnet.devices, [LAPTOP_ADDRESS]: { node: null } }; + yield* TestClock.adjust("11 seconds"); + expect(yield* outcomeOf(signIn(makeDpopKey()))).toBe(backHome); + }).pipe(Effect.provide(layerTest(tailnet))); + }, + ); + + it.effect("a scan cut short while it confirms a grant still keeps it", () => { + const tailnet = withLaptop(); + tailnet.status = hostStatus({ Peer: { ...peers, stranger: strangerPeer } }); + tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; + return Effect.gen(function* () { + // A scan that finds no grants, from the refused stranger. + yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: STRANGER_ADDRESS })); + tailnet.devices = { + ...tailnet.devices, + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + yield* TestClock.adjust("1 minute"); + // The phone leaves while the scan confirms the laptop's grant: the sign-in's read, then + // the grant's. The confirmation finishes before the scan stops. + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.holdFreshRead = { at: (tailnet.freshReads ?? 0) + 2, started, release }; + const phone = yield* signIn(makeDpopKey()).pipe(Effect.forkChild); + yield* Deferred.await(started); + const leaving = yield* Fiber.interrupt(phone).pipe(Effect.forkChild); + // Lets the interrupt reach the phone before the read is released. + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(leaving); + // With the laptop unreadable, the grant kept decides, not the older "no grants". + tailnet.devices = { ...tailnet.devices, [LAPTOP_ADDRESS]: { node: null } }; + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("no_grant"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect('a failed grant confirmation leaves no older "no grants" behind', () => { + const tailnet = withLaptop(); + tailnet.status = hostStatus({ Peer: { ...peers, stranger: strangerPeer } }); + tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; + return Effect.gen(function* () { + // A scan that finds no grants, from the refused stranger. + yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: STRANGER_ADDRESS })); + tailnet.devices = { + ...tailnet.devices, + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + yield* TestClock.adjust("1 minute"); + // The reads are the sign-in's, the grant's (which fails), then the account check's, + // during which the phone leaves. + const reads = tailnet.freshReads ?? 0; + tailnet.failFreshReadAt = reads + 2; + const started = yield* Deferred.make(); + tailnet.holdFreshRead = { at: reads + 3, started, release: yield* Deferred.make() }; + const phone = yield* signIn(makeDpopKey()).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(phone); + // With the laptop unreadable, nothing proves "no grants", so the owner rule stays off. + tailnet.devices = { ...tailnet.devices, [LAPTOP_ADDRESS]: { node: null } }; + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("policy_unknown"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a scan that saw another account while it confirmed a grant decides nothing", () => { + const tailnet = withLaptop(); + tailnet.status = hostStatus({ Peer: { ...peers, stranger: strangerPeer } }); + tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; + const home = tailnet.status; + return Effect.gen(function* () { + yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: STRANGER_ADDRESS })); + yield* TestClock.adjust("1 minute"); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.hold = { address: LAPTOP_ADDRESS, started, release, late: true }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + // The laptop's lookup answers on another profile, where it is granted, and the host is + // back home by the scan's own account check. + tailnet.status = hostStatus({ + Peer: peers, + Self: { ...hostStatus().Self, ID: "nBoxAtWork" }, + }); + tailnet.devices = { + ...tailnet.devices, + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + const accountCheck = (tailnet.freshReads ?? 0) + 2; + tailnet.onFreshRead = (count) => { + if (count === accountCheck) tailnet.status = home; + }; + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("policy_unknown"); + // Home grants nothing, so once that failed scan expires the owner's phone signs in. + tailnet.devices = { ...tailnet.devices, [LAPTOP_ADDRESS]: { node: laptop } }; + yield* TestClock.adjust("11 seconds"); + yield* signIn(makeDpopKey()); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + // A grant that names no usable scope still names the device in the policy. + it.effect.each([{ scope: "read" }, { scope: "raed" }])( + "a grant the session recheck sees ($scope) turns the owner rule off", + ({ scope }) => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + // The laptop signs in by the owner rule, then gets a grant the recheck reads. + const session = yield* signInLaptop(makeDpopKey()); + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: [scope] }) }, + }; + yield* TestClock.adjust("30 seconds"); + expect(yield* revoked(session.access_token)).toBe(true); + // Once the laptop cannot be read, the older "no grants" does not let the phone in. + tailnet.devices = { [LAPTOP_ADDRESS]: { node: null } }; + yield* TestClock.adjust("31 seconds"); + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("no_grant"); + }).pipe(Effect.provide(layerTest(tailnet))); + }, + ); + + it.effect.each([ + { via: "its own lookup" }, + { via: "a policy scan" }, + { via: "a sign-in's scan before it reads Tailscale" }, + ])( + "an owner-rule sign-in during the recheck that first sees a grant ($via) ends in it", + ({ via }) => { + const tailnet = withLaptop(); + tailnet.status = hostStatus({ + Peer: { ...peers, desk: ownPeer("nDesk", DESK_ADDRESS), stranger: strangerPeer }, + }); + const desk = ownDevice("nDesk", DESK_ADDRESS); + tailnet.devices = { + ...tailnet.devices, + [DESK_ADDRESS]: { node: desk }, + [STRANGER_ADDRESS]: { node: stranger }, + }; + const grant = (address: string, node: JsonObject) => { + tailnet.devices = { + ...tailnet.devices, + [address]: { node, capMap: granted({ scopes: ["read"] }) }, + }; + }; + return Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + yield* signInLaptop(makeDpopKey()); + if (via === "its own lookup") grant(LAPTOP_ADDRESS, laptop); + // While the recheck reads Tailscale or looks the laptop up, the phone signs in by the + // owner rule. + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const beforeStatus = via === "a sign-in's scan before it reads Tailscale"; + if (beforeStatus) { + tailnet.holdFreshRead = { at: (tailnet.freshReads ?? 0) + 1, started, release }; + } else tailnet.hold = { address: LAPTOP_ADDRESS, started, release }; + const recheck = yield* service.recheckSessions.pipe(Effect.forkChild); + yield* Deferred.await(started); + const phoneKey = makeDpopKey(); + const phone = yield* exchange((yield* signIn(phoneKey)).credential, phoneKey); + if (via !== "its own lookup") { + // The idle desk gets a grant, and the kept "no grants" expires, so the next owner + // rule decision scans afresh: the stranger's, or the laptop's in the recheck. + grant(DESK_ADDRESS, desk); + yield* TestClock.adjust("61 seconds"); + } + if (beforeStatus) { + yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: STRANGER_ADDRESS })); + } + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(recheck); + expect(yield* revoked(phone.access_token)).toBe(true); + }).pipe(Effect.provide(layerTest(tailnet))); + }, + ); + + it.effect( + "a grant the recheck reads after a profile switch is not counted for the old one", + () => { + const atWork = hostStatus({ + Peer: peers, + Self: { ...hostStatus().Self, ID: "nBoxAtWork" }, + }); + const tailnet: Tailnet = { ...withLaptop(), status: atWork }; + return Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + // On this profile nothing is granted; the laptop signs in by the owner rule. + yield* signInLaptop(makeDpopKey()); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.hold = { address: LAPTOP_ADDRESS, started, release, late: true }; + const recheck = yield* service.recheckSessions.pipe(Effect.forkChild); + yield* Deferred.await(started); + // The recheck's lookup answers on another profile of the same tailnet, which grants it. + tailnet.status = hostStatus({ Peer: peers }); + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(recheck); + // Back on this profile, still granting nothing, the owner's phone signs in. + tailnet.status = atWork; + tailnet.devices = { [LAPTOP_ADDRESS]: { node: laptop } }; + yield* signIn(makeDpopKey()); + }).pipe(Effect.provide(layerTest(tailnet))); + }, + ); + + it.effect.each<{ + readonly name: string; + readonly scope: string; + readonly statusFails?: boolean; + readonly refused: string; + readonly phone: string; + }>([ + { + name: "for an unusable grant", + scope: "raed", + refused: "grant_unusable", + phone: "no_grant", + }, + // Not shown to be this account's, the grant is not counted, but nothing older stands. + { + name: "when Tailscale cannot be read", + scope: "read", + statusFails: true, + refused: "tailscale_unavailable", + phone: "policy_unknown", + }, + ])( + "a grant seen by a sign-in refused $name keeps the owner's phone out", + ({ scope, statusFails, refused, phone }) => { + const tailnet = withLaptop(); + tailnet.status = hostStatus({ Peer: { ...peers, stranger: strangerPeer } }); + tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; + return Effect.gen(function* () { + // A scan that finds no grants, from the refused stranger. + yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: STRANGER_ADDRESS })); + tailnet.devices = { + ...tailnet.devices, + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: [scope] }) }, + }; + if (statusFails === true) tailnet.failFreshReadAt = (tailnet.freshReads ?? 0) + 1; + expect(yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: LAPTOP_ADDRESS }))).toBe( + refused, + ); + // Once the laptop cannot be read, the older "no grants" does not let the phone in. + tailnet.devices = { ...tailnet.devices, [LAPTOP_ADDRESS]: { node: null } }; + yield* TestClock.adjust("61 seconds"); + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe(phone); + }).pipe(Effect.provide(layerTest(tailnet))); + }, + ); + + it.effect( + "a grant the recheck sees during a scan that finds none keeps the owner rule off", + () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + // The laptop signs in through a grant, which then lapses. + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + // The recheck that first grant starts is the next to look the laptop up; it is let + // through, then waited out, as rechecks run one at a time. + const recheckStarted = yield* Deferred.make(); + const recheckGoes = yield* Deferred.make(); + yield* signInLaptop(makeDpopKey()); + tailnet.hold = { address: LAPTOP_ADDRESS, started: recheckStarted, release: recheckGoes }; + yield* Deferred.await(recheckStarted); + yield* Deferred.succeed(recheckGoes, undefined); + yield* service.recheckSessions; + tailnet.devices = { [LAPTOP_ADDRESS]: { node: laptop } }; + // A scan reads the laptop with no grant and waits; the grant comes back and the + // recheck reads it before the scan ends. + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.hold = { address: LAPTOP_ADDRESS, started, release }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + yield* service.recheckSessions; + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("no_grant"); + }).pipe(Effect.provide(layerTest(tailnet))); + }, + ); + + it.effect("an owner sign-in while the recheck confirms a grant's account is refused", () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + // Signed in by the owner rule, the laptop leaves a "no grants" scan kept for a minute. + yield* signInLaptop(makeDpopKey()); + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + // The recheck sees the grant and holds on the status read that confirms the account. + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.holdFreshRead = { at: (tailnet.freshReads ?? 0) + 2, started, release }; + const recheck = yield* service.recheckSessions.pipe(Effect.forkChild); + yield* Deferred.await(started); + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("no_grant"); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(recheck); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect( + "a grant the recheck cannot tie to an account keeps a running scan from deciding", + () => { + const TABLET_ADDRESS = "100.88.1.9"; + const tailnet = withLaptop(); + tailnet.status = hostStatus({ + Peer: { + ...peers, + desk: ownPeer("nDesk", DESK_ADDRESS), + tablet: ownPeer("nTablet", TABLET_ADDRESS), + }, + }); + // An admin grant holds the owner rule's scopes, so the desk's sign-in code outlives it. + const deskGranted = (on: boolean) => { + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop }, + [DESK_ADDRESS]: { + node: ownDevice("nDesk", DESK_ADDRESS), + ...(on ? { capMap: granted({ scopes: ["admin"] }) } : {}), + }, + [TABLET_ADDRESS]: { node: ownDevice("nTablet", TABLET_ADDRESS) }, + }; + }; + // A recheck that cannot read Tailscale from its account check after the desk's grant + // on, through the pass it then runs again. + const recheckInDoubt = Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + const from = (tailnet.freshReads ?? 0) + 2; + tailnet.onFreshRead = (count) => { + if (count >= from) tailnet.failFreshReadAt = count; + }; + yield* service.recheckSessions; + delete tailnet.onFreshRead; + delete tailnet.failFreshReadAt; + }); + return Effect.gen(function* () { + deskGranted(false); + yield* signIn(makeDpopKey(), { remoteAddress: DESK_ADDRESS }); + // A grant in doubt throws away the cached scan, so the phone scans afresh. + deskGranted(true); + yield* recheckInDoubt; + deskGranted(false); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.hold = { address: TABLET_ADDRESS, started, release }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + // The scan has read the desk with no grant; the grant comes back, and a recheck sees + // it but cannot tie it to this account. The scan then ends on "no grants". + deskGranted(true); + yield* recheckInDoubt; + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("policy_unknown"); + }).pipe(Effect.provide(layerTest(tailnet))); + }, + ); + + it.effect("a device tagged during its owner-rule scan is not let in as the owner's", () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.hold = { address: LAPTOP_ADDRESS, started, release }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + tailnet.status = hostStatus({ + Peer: { ...peers, phone: { ...peers.phone, Tags: ["tag:ci"] } }, + }); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("tailscale_unavailable"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect.each([ + { change: "require-grant turned on" }, + { change: "the host tagged" }, + { change: "the device blocked" }, + ])("an owner-rule sign-in refuses when $change after its scan", ({ change }) => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + // The third read is sign-in's last: after its own first and its scan's final one. + const last = (tailnet.freshReads ?? 0) + 3; + if (change === "the host tagged") { + tailnet.onFreshRead = (count) => { + if (count === last) { + tailnet.status = hostStatus({ + Peer: peers, + Self: { ...hostStatus().Self, Tags: ["tag:server"] }, + }); + } + }; + const error = yield* Effect.flip(signIn(makeDpopKey())); + expect( + isRefusal(error) ? [error.reason, error.suggestedGrant !== undefined] : error, + ).toEqual(["policy_unknown", true]); + } else { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.holdFreshRead = { at: last, started, release }; + const phone = yield* Effect.flip(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* requireGrantOn; + const blocked = change === "the device blocked"; + if (blocked) yield* (yield* TailnetSignIn.TailnetSignIn).blockDevice("nPhone"); + yield* Deferred.succeed(release, undefined); + // Refused as any no-grant refusal is, with the grant to add; a block answers first. + const error = yield* Fiber.join(phone); + expect( + isRefusal(error) ? [error.reason, error.suggestedGrant !== undefined] : error, + ).toEqual(blocked ? ["blocked", false] : ["no_grant", true]); + } + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a host tagged during a scan does not admit its former owner", () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.hold = { address: LAPTOP_ADDRESS, started, release }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + // Tagged with no owner set, the host has no owner, so only grants could let in. + tailnet.status = hostStatus({ + Peer: peers, + Self: { ...hostStatus().Self, Tags: ["tag:server"] }, + }); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("policy_unknown"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a grant sign-in on another account does not lock the owner out here", () => { + const tailnet = withLaptop(); + const home = tailnet.status; + return Effect.gen(function* () { + yield* signIn(makeDpopKey()); + // On another account, the laptop signs in through a grant there. + tailnet.status = hostStatus({ + Peer: peers, + Self: { ...hostStatus().Self, ID: "nBoxAtWork" }, + }); + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + yield* signIn(makeDpopKey(), { remoteAddress: LAPTOP_ADDRESS }); + // Back home, where nothing is granted, the owner's phone still signs in. + tailnet.status = home; + tailnet.devices = { [LAPTOP_ADDRESS]: { node: laptop } }; + yield* signIn(makeDpopKey()); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect( + "a peer with no ID that replaces another during a scan keeps it from deciding", + () => { + const FIRST = "100.88.1.21"; + const SECOND = "100.88.1.22"; + const unidentified = (address: string) => ({ + TailscaleIPs: [address], + UserID: `int64:${OWNER_ID}`, + }); + const tailnet: Tailnet = { + status: hostStatus({ Peer: { phone: peers.phone, other: unidentified(FIRST) } }), + node: phoneNode(), + devices: { + [FIRST]: { node: phoneNode({ StableID: "nFirst", Addresses: [`${FIRST}/32`] }) }, + }, + }; + return Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.hold = { address: FIRST, started, release }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + // As many peers with no ID as before, but a granted laptop at another address. + tailnet.status = hostStatus({ + Peer: { phone: peers.phone, other: unidentified(SECOND) }, + }); + tailnet.devices = { + ...tailnet.devices, + [SECOND]: { + node: phoneNode({ StableID: "nSecond", Addresses: [`${SECOND}/32`] }), + capMap: granted({ scopes: ["read"] }), + }, + }; + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("policy_unknown"); + }).pipe(Effect.provide(layerTest(tailnet))); + }, + ); + + it.effect("with no node ID for this host, the owner rule does not apply", () => { + const { ID: _, ...self } = hostStatus().Self; + const tailnet = withLaptop(); + tailnet.status = hostStatus({ Peer: peers, Self: self }); + return Effect.gen(function* () { + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("policy_unknown"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect("a grant sign-in during a scan that finds no grants keeps the owner rule off", () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + // The scan reads the laptop with no grant, then waits while the grant lands and is used. + tailnet.hold = { address: LAPTOP_ADDRESS, started, release }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["standard"] }) }, + }; + yield* signInLaptop(makeDpopKey()); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("no_grant"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + + it.effect( + "a peer whois cannot read reuses the last full scan for five minutes, then fails closed", + () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + yield* signIn(makeDpopKey()); + tailnet.devices = { [LAPTOP_ADDRESS]: { node: null } }; + yield* TestClock.adjust("270 seconds"); + yield* signIn(makeDpopKey()); + yield* TestClock.adjust("30 seconds"); + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("policy_unknown"); + }).pipe(Effect.provide(layerTest(tailnet))); + }, + ); + + it.effect("a device whois cannot read keeps access for two minutes, then loses it", () => { + const tailnet = withLaptop(granted({ scopes: ["standard"] })); + return Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + const session = yield* signInLaptop(makeDpopKey()); + tailnet.devices = { [LAPTOP_ADDRESS]: { node: null } }; + yield* service.recheckSessions; + expect(yield* revoked(session.access_token)).toBe(false); + yield* TestClock.adjust("2 minutes"); + yield* service.recheckSessions; + expect(yield* revoked(session.access_token)).toBe(true); + + // A Tailscale that stays down vouches for no one past the same grace period. + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["standard"] }) }, + }; + const next = yield* signInLaptop(makeDpopKey()); + tailnet.status = hostStatus({ BackendState: "Stopped" }); + yield* service.recheckSessions; + expect(yield* revoked(next.access_token)).toBe(false); + yield* TestClock.adjust("2 minutes"); + yield* service.recheckSessions; + expect(yield* revoked(next.access_token)).toBe(true); + }).pipe(Effect.provide(layerTest(tailnet))); + }); + }); + + it.effect("blocking ends live sessions and refuses a grant minted before the block", () => { + const tailnet: Tailnet = { status: hostStatus(), node: phoneNode() }; + return Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + const sessions = yield* SessionStore.SessionStore; + const key = makeDpopKey(); + const live = yield* exchange((yield* signIn(key)).credential, key); + const pending = yield* signIn(key); + + expect(yield* service.blockDevice("nPhone")).toBe(1); + expect((yield* Effect.flip(sessions.verify(live.access_token)))._tag).toBe( + "SessionTokenRevokedError", + ); + expect(yield* refusalOf(exchange(pending.credential, key))).toBe( + "ServerAuthInvalidCredentialError", + ); + expect(yield* refusalOf(signIn(key))).toBe("blocked"); + // Refused anyway, as a tagged device with no grant, it hears it is blocked, not to add one. + tailnet.node = phoneNode({ Tags: ["tag:ci"] }); + expect(yield* refusalOf(signIn(key))).toBe("blocked"); + tailnet.node = phoneNode(); + expect(yield* service.listDevices("tailscale:nPhone")).toMatchObject([ + { stableNodeId: "nPhone", label: "phone", activeSessionCount: 0, current: true }, + ]); + + expect(yield* service.unblockDevice("nPhone")).toBe(true); + // A code from before the block stays revoked once the device is unblocked. + expect(yield* refusalOf(exchange(pending.credential, key))).toBe( + "ServerAuthInvalidCredentialError", + ); + const renewed = yield* exchange((yield* signIn(key)).credential, key); + expect((yield* sessions.verify(renewed.access_token)).subject).toBe("tailscale:nPhone"); + expect(yield* service.listDevices("bearer:laptop")).toMatchObject([ + { stableNodeId: "nPhone", blockedAt: null, activeSessionCount: 1, current: false }, + ]); + + // A block whose revocation never ran ends the device's sessions at the next recheck, + // even when Tailscale cannot look the device up, or cannot be read at all. + const blockList = yield* AuthTailnetDeviceBlocks.AuthTailnetDeviceBlockRepository; + let session = renewed; + for (const unreadable of ["the device", "Tailscale"] as const) { + const blockedAt = yield* DateTime.now; + yield* blockList.block({ stableNodeId: "nPhone", label: null, blockedAt }); + if (unreadable === "the device") { + tailnet.status = hostStatus({ + Peer: { + phone: { ID: "nPhone", TailscaleIPs: [PHONE_ADDRESS], UserID: `int64:${OWNER_ID}` }, + }, + }); + tailnet.node = null; + } else tailnet.failFreshReadAt = (tailnet.freshReads ?? 0) + 1; + yield* service.recheckSessions; + expect((yield* Effect.flip(sessions.verify(session.access_token)))._tag).toBe( + "SessionTokenRevokedError", + ); + yield* service.unblockDevice("nPhone"); + tailnet.status = hostStatus(); + tailnet.node = phoneNode(); + session = yield* exchange((yield* signIn(key)).credential, key); + } + + // A sign-in that checked the block list before the block landed gets no code. + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.holdCodeIssue = { started, release }; + const racing = yield* refusalOf(signIn(key)).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* service.blockDevice("nPhone"); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(racing)).toBe("blocked"); + }).pipe(Effect.provide(layerTest(tailnet))); + }); +}); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts new file mode 100644 index 0000000000000..c7cb8933c7c28 --- /dev/null +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -0,0 +1,902 @@ +import { + type AuthEnvironmentScope, + type AuthSessionId, + type AuthTailnetSignInResult, + AuthStandardClientScopes, + TAILNET_SUBJECT_PREFIX, + TailnetAccessError, + type TailnetDevice, + tailnetGrantSnippet, + TailnetSignInRefusedReason, + tailnetSubject, +} from "@t3tools/contracts"; +import { + isTailscaleIpv4Address, + type TailscaleStatus, + type TailscaleWhois, +} from "@t3tools/tailscale"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Semaphore from "effect/Semaphore"; +import * as Schema from "effect/Schema"; +import type * as HttpServerRequest from "effect/http/HttpServerRequest"; + +import * as ServerConfig from "../config.ts"; +import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts"; +import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; +import * as ManagedLimits from "../policy/managedLimits.ts"; +import * as AuthTailnetDeviceBlocks from "../persistence/AuthTailnetDeviceBlocks.ts"; +import * as TailscaleHost from "../tailscale/TailscaleHost.ts"; +import { verifyRequestDpopProof } from "./dpop.ts"; +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import * as ServerSecretStore from "./ServerSecretStore.ts"; +import * as SessionStore from "./SessionStore.ts"; +import { peersJoined, policyGrantedPeers, tailnetGrantScopes } from "./tailnetGrants.ts"; + +const GRANT_TTL = Duration.minutes(2); +const RECHECK_INTERVAL = Duration.seconds(30); +const POLICY_SCAN_TTL = Duration.minutes(5); +const NO_GRANT_SCAN_TTL = Duration.minutes(1); +// A failed scan is kept briefly, so sign-in tries cannot make the host run whois on every peer each time. +const FAILED_SCAN_TTL = Duration.seconds(10); +/** A device whose whois keeps failing this long loses its sessions. */ +const LOOKUP_GRACE = Duration.minutes(2); +/** In the state dir. While it exists, only devices with a T3 Code grant sign in. */ +export const REQUIRE_GRANT_FILE = "tailscale-require-grant"; + +export class TailnetSignInRefusedError extends Schema.TaggedError()( + "TailnetSignInRefusedError", + { + reason: TailnetSignInRefusedReason, + /** A tailnet policy grant that would let this device in, when one would. */ + suggestedGrant: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Tailscale sign-in refused (${this.reason}).`; + } +} + +export class TailnetDeviceBlockLookupError extends Schema.TaggedError()( + "TailnetDeviceBlockLookupError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to check whether the device is blocked."; + } +} + +export class TailnetSignIn extends Context.Service< + TailnetSignIn, + { + /** + * Mints a two-minute grant bound to the request's DPoP key, with the scopes + * the device's tailnet grant names, or standard scopes for the owner's own + * untagged device. Renewal calls this again, so every check reruns. + */ + readonly signIn: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect< + AuthTailnetSignInResult, + | TailnetSignInRefusedError + | TailnetDeviceBlockLookupError + | EnvironmentAuth.ServerAuthInvalidCredentialError + | EnvironmentAuth.ServerAuthInternalError + | ManagedLimits.ManagedPolicyRefusedError + >; + /** `currentSubject` is the asking session's, which marks that device `current`. */ + readonly listDevices: ( + currentSubject: string, + ) => Effect.Effect, TailnetAccessError>; + /** Refuses the device here and ends its sessions. Returns how many sessions ended. */ + readonly blockDevice: (stableNodeId: string) => Effect.Effect; + readonly unblockDevice: (stableNodeId: string) => Effect.Effect; + /** + * Reruns the access checks for every device with a live Tailscale session and + * ends the sessions of devices that lost access. Runs every 30 seconds. + */ + readonly recheckSessions: Effect.Effect; + } +>()("t3/auth/TailnetSignIn") {} + +type AccessDecision = + | { + readonly _tag: "Allowed"; + readonly scopes: ReadonlyArray; + readonly via: "grant" | "owner"; + } + | { readonly _tag: "Refused"; readonly reason: TailnetSignInRefusedReason }; + +const refused = (reason: TailnetSignInRefusedReason): AccessDecision => ({ + _tag: "Refused", + reason, +}); + +interface RequestSocket { + readonly localAddress?: string | undefined; + readonly remoteAddress?: string | undefined; + readonly localPort?: number | undefined; +} + +// The socket, never X-Forwarded-For: Serve and Funnel arrive from loopback and must stay refused. +const requestSocket = (request: HttpServerRequest.HttpServerRequest): RequestSocket => { + const source = request.source as { readonly socket?: RequestSocket } | undefined; + return source?.socket ?? {}; +}; + +const plainIpv4 = (address: string | undefined) => address?.replace(/^::ffff:/i, ""); + +const isTrustedOrigin = (origin: string | undefined) => + origin === undefined || (DESKTOP_RENDERER_ORIGINS as ReadonlyArray).includes(origin); + +/** + * DNS rebinding sends this host's address under another name. Accept only the + * names and addresses Tailscale gives this host, on the port the socket serves. + */ +const isOwnHost = (host: string | undefined, status: TailscaleStatus, localPort: number) => { + if (host === undefined) return false; + const names = [ + ...status.tailnetIpv4Addresses, + ...(status.magicDnsName === null + ? [] + : [status.magicDnsName, status.magicDnsName.split(".")[0]!]), + ].map((name) => name.toLowerCase()); + const accepted = new Set( + names.flatMap((name) => (localPort === 80 ? [name, `${name}:80`] : [`${name}:${localPort}`])), + ); + return accepted.has(host.toLowerCase()); +}; + +// An exit node forwards its peers' traffic to the internet, never other devices +// into the tailnet, so it still speaks only for itself. A subnet router does not. +const EXIT_NODE_ROUTES = ["0.0.0.0/0", "::/0"]; + +/** Names a device in revocation logs the way the sign-in line does, when Tailscale still knows it. */ +interface DeviceName { + readonly device: string; + readonly user: string | null; +} + +/** + * Refusals that a tailnet policy grant for this device would turn into a sign-in. An unusable + * grant is left out: its fix is the scope the operator meant, not one this suggests. + */ +const GRANT_FIXES: ReadonlySet = new Set([ + "no_grant", + "policy_unknown", + "owner_unknown", + "tagged_device", + "shared_device", + "other_user", +]); + +/** Traffic a subnet router forwards from its LAN can arrive as the router, grant or not. */ +const routesForOthers = (node: TailscaleWhois) => { + const accepted = new Set([...node.addresses, ...EXIT_NODE_ROUTES]); + return node.primaryRoutes.length > 0 || node.allowedIps.some((ip) => !accepted.has(ip)); +}; + +/** The device's name, with its user's login when that user does not own this host. */ +const label = (node: TailscaleWhois, status: TailscaleStatus): string | null => { + const name = node.computedName ?? node.loginName; + if (name === null) return null; + const otherUser = + node.tags.length === 0 && node.loginName !== null && node.userId !== status.selfUserId; + return otherUser && name !== node.loginName ? `${name} (${node.loginName})` : name; +}; + +const sameTags = (a: ReadonlyArray, b: ReadonlyArray) => + a.length === b.length && a.every((tag) => b.includes(tag)); + +/** With no grant: the first check this node fails, or null when it is the owner's own device. */ +const refuseNode = ( + node: TailscaleWhois, + ownerUserId: string, +): TailnetSignInRefusedReason | null => { + if (node.tags.length > 0) return "tagged_device"; + if (node.sharerUserId !== null) return "shared_device"; + if (node.userId !== ownerUserId) return "other_user"; + return null; +}; + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const host = yield* TailscaleHost.TailscaleHost; + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const blocks = yield* AuthTailnetDeviceBlocks.AuthTailnetDeviceBlockRepository; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const crypto = yield* Crypto.Crypto; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; + const requireGrantPath = path.join(config.stateDir, REQUIRE_GRANT_FILE); + + // Grants that sign-ins or rechecks saw since the last scan that found none, usable or not, + // per Tailscale account. The first stops the owner rule there before the next scan. A scan + // that finds no grants clears its account's count, unless one came meanwhile. + const grantsSeen = yield* Ref.make>(new Map()); + const grantsSeenOn = (account: string | null) => + Ref.get(grantsSeen).pipe(Effect.map((counts) => counts.get(account) ?? 0)); + // Bumped when a sign-in, the recheck or a scan sees a grant, before it knows the account: a + // scan already running then may not end on "no grants", and a recheck pass runs again. + const grantsInDoubt = yield* Ref.make(0); + // Bumped each time the owner rule lets a device in: a recheck keeping it, or a sign-in code. + const ownerRuleAdmissions = yield* Ref.make(0); + /** Counts a grant seen on `account`; true when it is the first there. */ + const countGrant = (account: string | null) => + Ref.modify(grantsSeen, (counts) => { + const before = counts.get(account) ?? 0; + return [before === 0, new Map(counts).set(account, before + 1)] as const; + }); + // The last scan that settled the question: it read every peer, or found a grant. A scan + // that could not reuses it for a while, so one failed lookup does not lock the owner out. + // Each scan names the Tailscale account (this machine's node) it read, since switching + // accounts switches tailnets and policies. + const lastCleanScan = yield* Ref.make<{ + readonly account: string | null; + readonly granted: boolean; + readonly at: number; + } | null>(null); + // The status the decision that needs a scan just read, so the scan reads no other first. + const scanFrom = yield* Ref.make(null); + const [scanPolicy, rescanPolicy] = yield* Effect.cachedInvalidateWithTTL( + Effect.gen(function* () { + // A switch after this status is caught below. + const status = (yield* Ref.get(scanFrom)) ?? (yield* host.status); + const account = status.selfNodeId; + const seenBefore = yield* grantsSeenOn(account); + const doubtBefore = yield* Ref.get(grantsInDoubt); + // Kept once confirmed, so a scan cut short still leaves it for a later scan to fall back + // on, never an older "no grants". The account is checked first, as another profile on + // the same tailnet sees the same peers with other grants; one grant reads it at a time. + const grantKept = yield* Ref.make(false); + const switchSeen = yield* Ref.make(false); + const keepGrant = Effect.gen(function* () { + if (yield* Ref.getAndSet(grantKept, true)) return; + yield* Ref.update(grantsInDoubt, (count) => count + 1); + // An older "no grants" is dropped until a full scan, so a read cut short or failed + // leaves nothing to fall back on. A switch fails the scan below, and the older result + // stays dropped, as the grant may have been read on this account before it. + yield* Ref.update(lastCleanScan, (last) => + last?.account === account && !last.granted ? null : last, + ); + const now = yield* host.freshStatus; + if (now.selfNodeId !== account) return yield* Ref.set(switchSeen, true); + const at = yield* Clock.currentTimeMillis; + yield* Ref.set(lastCleanScan, { account, granted: true, at }); + }).pipe( + // A later grant in the scan tries again; the status read times out, so it cannot hang. + Effect.tapError(() => Ref.set(grantKept, false)), + Effect.ignore, + Effect.uninterruptible, + ); + const { granted, unreadable } = yield* policyGrantedPeers( + status, + (address) => host.whois(address, { fromServer: true }), + keepGrant, + ); + // Lookups that ran after a switch read another tailnet, so the scan says nothing. A + // device that joined since the scanned list was not looked up, so "no grants" is + // unproven; a grant found still stands, as it only turns the owner rule off. + const after = yield* host.freshStatus; + // Tagging the host or untagging it changes who owns it, so the owner a sign-in read + // before the scan may no longer be the owner. + if ( + after.selfNodeId !== account || + (yield* Ref.get(switchSeen)) || + TailscaleHost.tailnetOwnerUserId(after, config.tailscaleOwnerUserId) !== + TailscaleHost.tailnetOwnerUserId(status, config.tailscaleOwnerUserId) + ) { + return yield* Effect.fail(["this host"]); + } + if ( + granted.length === 0 && + (peersJoined(status, after) || (yield* Ref.get(grantsInDoubt)) !== doubtBefore) + ) { + return yield* Effect.fail(["devices or grants that changed during the check"]); + } + const now = yield* Clock.currentTimeMillis; + // "No grants" is rechecked sooner, so a new grant is noticed within a minute. + const ttlOf = (found: boolean) => (found ? POLICY_SCAN_TTL : NO_GRANT_SCAN_TTL); + if (granted.length > 0 || unreadable.length === 0) { + const found = granted.length > 0; + yield* Ref.set(lastCleanScan, { account, granted: found, at: now }); + if (!found) + yield* Ref.update(grantsSeen, (counts) => + (counts.get(account) ?? 0) === seenBefore ? new Map(counts).set(account, 0) : counts, + ); + return { account, granted: found, ttl: ttlOf(found) }; + } + const last = yield* Ref.get(lastCleanScan); + const left = + last === null || last.account !== account + ? 0 + : Duration.toMillis(POLICY_SCAN_TTL) - (now - last.at); + if (last === null || left <= 0) return yield* Effect.fail(unreadable); + // Reused no longer than the full scan it came from is good for. + return { + account, + granted: last.granted, + ttl: Duration.min(ttlOf(last.granted), Duration.millis(left)), + }; + }), + (exit) => (exit._tag === "Failure" ? FAILED_SCAN_TTL : exit.value.ttl), + ); + // A grant was seen, its account not yet known: no "no grants" stands from here, kept or + // from a scan running meanwhile. A sign-in dropped partway still does all of it. + const doubtNoGrants = Effect.gen(function* () { + yield* Ref.update(grantsInDoubt, (count) => count + 1); + yield* Ref.update(lastCleanScan, (last) => (last?.granted === false ? null : last)); + yield* rescanPolicy; + }).pipe(Effect.uninterruptible); + /** + * Why the owner rule is off, or null while it applies. It is off once the policy grants + * T3 Code here or the operator says so, and while the policy cannot be read. + */ + const ownerRuleOff = Effect.fn("TailnetSignIn.ownerRuleOff")(function* (status: TailscaleStatus) { + const account = status.selfNodeId; + if (yield* fs.exists(requireGrantPath).pipe(Effect.orElseSucceed(() => true))) { + return "no_grant" as const; + } + // A host Tailscale names no node for cannot tell one account's scan from another's. + if (account === null) return "policy_unknown" as const; + yield* Ref.set(scanFrom, status); + // Scanned first, so a scan that finds no grants can clear the sign-in count. A scan of + // another account says nothing here, so it is redone; one that still differs is unknown. + const scan = yield* scanPolicy.pipe( + Effect.filterOrElse( + (cached) => cached.account === account, + () => rescanPolicy.pipe(Effect.andThen(scanPolicy)), + ), + Effect.option, + Effect.map(Option.filter((scanned) => scanned.account === account)), + ); + if (Option.isSome(scan) && scan.value.granted) return "no_grant" as const; + if ((yield* grantsSeenOn(account)) > 0) return "no_grant" as const; + return Option.isNone(scan) ? ("policy_unknown" as const) : null; + }); + + /** A grant in the tailnet policy decides. With none, the owner's own devices may sign in. */ + const decide = Effect.fn("TailnetSignIn.decide")(function* ( + node: TailscaleWhois, + status: TailscaleStatus, + ) { + if (node.stableNodeId === status.selfNodeId) return refused("own_node"); + if (routesForOthers(node)) return refused("routes_for_others"); + const granted = tailnetGrantScopes(node.capabilities); + if (granted !== null) { + return granted.length === 0 + ? refused("grant_unusable") + : ({ _tag: "Allowed", scopes: granted, via: "grant" } satisfies AccessDecision); + } + const off = yield* ownerRuleOff(status); + if (off !== null) return refused(off); + const ownerUserId = TailscaleHost.tailnetOwnerUserId(status, config.tailscaleOwnerUserId); + if (ownerUserId === null) return refused("owner_unknown"); + const refusal = refuseNode(node, ownerUserId); + if (refusal !== null) return refused(refusal); + return { + _tag: "Allowed", + scopes: AuthStandardClientScopes, + via: "owner", + } satisfies AccessDecision; + }); + + const refuse = (reason: TailnetSignInRefusedReason, cause?: unknown, suggestedGrant?: string) => + // Every server that is not set up for sign-in, and every browser, sees these routinely. + (reason === "disabled" || reason === "browser_request" ? Effect.logDebug : Effect.logWarning)( + "Refused Tailscale sign-in.", + ).pipe( + Effect.annotateLogs({ reason, ...(cause === undefined ? {} : { cause }) }), + Effect.andThen( + Effect.fail( + new TailnetSignInRefusedError({ + reason, + ...(cause === undefined ? {} : { cause }), + ...(suggestedGrant === undefined ? {} : { suggestedGrant }), + }), + ), + ), + ); + + const signIn: TailnetSignIn["Service"]["signIn"] = Effect.fn("TailnetSignIn.signIn")( + function* (request) { + if (config.tailscaleSignInEnabled !== true) return yield* refuse("disabled"); + // A hard 403: production CORS allows any origin and the `dpop` header. + if (!isTrustedOrigin(request.headers.origin)) return yield* refuse("browser_request"); + + const socket = requestSocket(request); + const remoteAddress = plainIpv4(socket.remoteAddress); + const localAddress = plainIpv4(socket.localAddress); + const localPort = socket.localPort; + if (localPort === undefined) return yield* refuse("browser_request"); + if ( + remoteAddress === undefined || + localAddress === undefined || + !isTailscaleIpv4Address(remoteAddress) || + !isTailscaleIpv4Address(localAddress) + ) { + return yield* refuse("not_direct_tailnet"); + } + const hostRefusal = (status: TailscaleStatus): TailnetSignInRefusedReason | null => + status.backendState !== "Running" || status.userspaceNetworking === true + ? "tailscale_unavailable" + : !TailscaleHost.isSupportedTailscaleVersion(status.version) + ? "tailscale_outdated" + : !isOwnHost(request.headers.host, status, localPort) + ? "browser_request" + : !status.tailnetIpv4Addresses.includes(localAddress) + ? "not_direct_tailnet" + : null; + // The per-address whois limit comes first, so a request over it costs no proof check, + // and only one under it with a valid proof reads `tailscale status`. + // A missing required field fails the parse, so it refuses like an unreachable daemon. + const node = yield* host + .whois(remoteAddress) + .pipe( + Effect.catch((cause) => + refuse( + cause._tag === "TailscaleWhoisRateLimitedError" + ? "rate_limited" + : "tailscale_unavailable", + cause, + ), + ), + ); + const proofKeyThumbprint = yield* verifyRequestDpopProof({ request }).pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), + Effect.provideService(Crypto.Crypto, crypto), + ); + // Even if a check below refuses this sign-in, the grant it saw may be this account's. + if (tailnetGrantScopes(node.capabilities) !== null) yield* doubtNoGrants; + // Decided on a status read now, as one kept from before an account switch names the + // old tailnet's policy and owner. A switch also moves this host's tailnet address, so a + // request that arrived on the old one fails the address check below. + const status = yield* host.freshStatus.pipe( + Effect.catch((cause) => refuse("tailscale_unavailable", cause)), + ); + const refusal = hostRefusal(status); + if (refusal !== null) return yield* refuse(refusal); + // The caller must be the peer a status names at its address, as the recheck asks, with a + // live key and the tags its lookup saw: a whois answered before an account switch or a + // tag change is not judged as the device it no longer is. + const checkCaller = (current: TailscaleStatus) => { + if (node.stableNodeId === current.selfNodeId) return Effect.void; + const peer = current.peers.find( + (candidate) => + candidate.stableNodeId === node.stableNodeId && + candidate.tailnetIpv4Addresses.includes(remoteAddress), + ); + if (peer === undefined) return refuse("tailscale_unavailable"); + // Off the tailnet until it logs in to Tailscale again, which only the device can do. + if (peer.keyExpired) return refuse("not_direct_tailnet", "node key expired"); + return sameTags(peer.tags, node.tags) && + (peer.userId === null || peer.userId === node.userId) + ? Effect.void + : refuse("tailscale_unavailable", "device changed during sign-in"); + }; + yield* checkCaller(status); + const decision = yield* decide(node, status); + // The policy names this device, so it decides here now, even when the grant is refused + // as unusable; owner-rule sessions end on the recheck this starts. + if ( + tailnetGrantScopes(node.capabilities) !== null && + (yield* countGrant(status.selfNodeId)) + ) { + yield* recheckSessions.pipe(Effect.forkDetach); + } + const isBlocked = blocks + .isBlocked({ stableNodeId: node.stableNodeId }) + .pipe(Effect.mapError((cause) => new TailnetDeviceBlockLookupError({ cause }))); + // Checked before any refusal, as a grant it suggests would not let a blocked device in. + if (yield* isBlocked) return yield* refuse("blocked"); + const refuseWithGrant = (reason: TailnetSignInRefusedReason, cause?: unknown) => + refuse( + reason, + cause, + GRANT_FIXES.has(reason) + ? // This device on this machine only: a login or tag would cover others too. + tailnetGrantSnippet( + remoteAddress, + localAddress, + localPort, + `${label(node, status) ?? remoteAddress} on ${status.magicDnsName?.split(".")[0] ?? localAddress}; remove when either leaves the tailnet, as addresses get reused`, + ) + : undefined, + ); + if (decision._tag === "Refused") return yield* refuseWithGrant(decision.reason); + const { scopes, via } = decision; + // The owner rule may have waited on a policy scan, during which the device, this host, + // its owner, or the require-grant setting may have changed. + if (via === "owner") { + const final = yield* host.freshStatus.pipe( + Effect.catch((cause) => refuse("tailscale_unavailable", cause)), + ); + const finalRefusal = hostRefusal(final); + if (finalRefusal !== null) return yield* refuse(finalRefusal); + // A block that landed meanwhile answers first, as no grant would let the device in. + if (yield* isBlocked) return yield* refuse("blocked"); + yield* checkCaller(final); + if ( + final.selfNodeId !== status.selfNodeId || + TailscaleHost.tailnetOwnerUserId(final, config.tailscaleOwnerUserId) !== + TailscaleHost.tailnetOwnerUserId(status, config.tailscaleOwnerUserId) + ) { + return yield* refuseWithGrant("policy_unknown", "this host changed during sign-in"); + } + if (yield* fs.exists(requireGrantPath).pipe(Effect.orElseSucceed(() => true))) { + return yield* refuseWithGrant("no_grant"); + } + } + + knownUsers.set(node.stableNodeId, node.loginName ?? node.userId); + const deviceLabel = label(node, status); + const issued = yield* environmentAuth.createPairingLink({ + subject: tailnetSubject(node.stableNodeId), + proofKeyThumbprint, + ttl: GRANT_TTL, + scopes, + ...(deviceLabel === null ? {} : { label: deviceLabel }), + }); + // A block that landed since the check above may have revoked codes before this one. + if (yield* isBlocked) { + yield* environmentAuth.revokePairingLink(issued.id); + return yield* refuse("blocked"); + } + // Counted once its code exists, so a recheck that sees the count can list the code. + if (via === "owner") yield* Ref.update(ownerRuleAdmissions, (count) => count + 1); + const device = node.computedName ?? node.stableNodeId; + yield* Effect.logInfo("Tailscale sign-in.").pipe( + Effect.annotateLogs({ + device, + user: node.loginName ?? node.userId, + via, + scopes: scopes.join(" "), + }), + ); + return { credential: issued.credential, expiresAt: issued.expiresAt }; + }, + ); + + const lookupFailingSince = new Map(); + // Who each signed-in device belongs to, so a revocation log names the user after Tailscale forgets it. + const knownUsers = new Map(); + + const endAccess = Effect.fn("TailnetSignIn.endAccess")(function* ( + stableNodeId: string, + reason: string, + name: DeviceName, + ) { + const subject = tailnetSubject(stableNodeId); + const revokedAt = yield* DateTime.now; + // A sign-in credential not yet exchanged would bring the device back. + const links = yield* pairingLinks.revokeUnusedBySubject({ subject, revokedAt }); + const ended = yield* sessions.revokeBySubject(subject); + lookupFailingSince.delete(stableNodeId); + yield* Effect.logWarning("Ended Tailscale access for a device that lost it.").pipe( + Effect.annotateLogs({ + ...name, + stableNodeId, + reason, + sessions: ended, + pendingSignIns: links, + }), + ); + }); + + /** The scopes this device may hold now, why it should lose all access, or null while unsure. */ + const recheckDevice = Effect.fn("TailnetSignIn.recheckDevice")(function* ( + stableNodeId: string, + status: TailscaleStatus, + /** Whether a status read shows the host still on `status`'s account; once per recheck. */ + stillOnAccount: Effect.Effect, + ) { + // Gone from the peer list means removed from the tailnet, or cut off by the policy. + const peer = status.peers.find((candidate) => candidate.stableNodeId === stableNodeId); + const address = peer?.tailnetIpv4Addresses[0] ?? peer?.tailnetIpv6Addresses[0]; + if (address === undefined) return { end: "not_on_tailnet" } as const; + // Off the tailnet until it signs in to Tailscale again; a LAN route could still reach here. + if (peer?.keyExpired === true) return { end: "key_expired" } as const; + const lookup = yield* host.whois(address, { fromServer: true }).pipe(Effect.option); + if (Option.isNone(lookup) || lookup.value.stableNodeId !== stableNodeId) { + // A blocked device ends without a lookup, so its cleanup does not wait out the grace. + if (yield* blocks.isBlocked({ stableNodeId })) return { end: "blocked" } as const; + const now = yield* Clock.currentTimeMillis; + const since = lookupFailingSince.get(stableNodeId) ?? now; + lookupFailingSince.set(stableNodeId, since); + return now - since >= Duration.toMillis(LOOKUP_GRACE) + ? ({ end: "tailscale_unavailable" } as const) + : null; + } + lookupFailingSince.delete(stableNodeId); + const node = lookup.value; + knownUsers.set(stableNodeId, node.loginName ?? node.userId); + const decision = yield* decide(node, status); + if (decision._tag === "Allowed" && decision.via === "owner") { + yield* Ref.update(ownerRuleAdmissions, (count) => count + 1); + } + // A grant, usable or not, counts as a grant sign-in does once the host is shown still on + // this account; unconfirmed, it may be another account's. + if (tailnetGrantScopes(node.capabilities) !== null) { + yield* doubtNoGrants; + if (yield* stillOnAccount) yield* countGrant(status.selfNodeId); + } + if (decision._tag === "Refused") return { end: decision.reason, node } as const; + if (yield* blocks.isBlocked({ stableNodeId })) return { end: "blocked", node } as const; + return { allowed: new Set(decision.scopes), node } as const; + }); + + /** + * A Tailscale credential holds exactly its grant's scopes, so one that differs either way + * was issued under an older grant. It ends, and the device signs in again. + */ + const sameScopes = (held: ReadonlyArray, allowed: ReadonlySet) => + held.length === allowed.size && held.every((scope) => allowed.has(scope)); + + let statusFailingSince: number | null = null; + // The periodic recheck and the one a sign-in's first grant starts share the state above. + const recheckLock = yield* Semaphore.make(1); + + /** One recheck pass; true when, while it ran, a grant was seen and the owner rule let a device in. */ + const recheckPass = Effect.gen(function* () { + // Read before the credentials are listed, so all the pass's work falls after them. + const doubtBefore = yield* Ref.get(grantsInDoubt); + const admissionsBefore = yield* Ref.get(ownerRuleAdmissions); + // Unused sign-in credentials count too: exchanging one would bring the device back. + const now = yield* DateTime.now; + const devices = new Map< + string, + { + sessions: Array<{ + readonly sessionId: AuthSessionId; + readonly scopes: ReadonlyArray; + }>; + links: Array<{ + readonly id: string; + readonly scopes: ReadonlyArray; + }>; + /** The sign-in label, a code's before a session's, for logs once Tailscale forgets it. */ + label: string | null; + } + >(); + const deviceOf = (subject: string, label: string | null) => { + const stableNodeId = subject.slice(TAILNET_SUBJECT_PREFIX.length); + const device = devices.get(stableNodeId) ?? { sessions: [], links: [], label }; + device.label ??= label; + devices.set(stableNodeId, device); + return device; + }; + // Codes first: one exchanged before the sessions are listed has its session there, and + // one exchanged after is caught by the reread below. + for (const link of yield* pairingLinks.listActive({ now })) { + if (link.subject.startsWith(TAILNET_SUBJECT_PREFIX)) + deviceOf(link.subject, link.label).links.push(link); + } + for (const session of yield* sessions.listActive()) { + if (session.subject.startsWith(TAILNET_SUBJECT_PREFIX)) { + deviceOf(session.subject, session.client.label ?? null).sessions.push(session); + } + } + const nameOf = (stableNodeId: string, node?: TailscaleWhois): DeviceName => ({ + device: node?.computedName ?? devices.get(stableNodeId)?.label ?? stableNodeId, + user: node?.loginName ?? node?.userId ?? knownUsers.get(stableNodeId) ?? null, + }); + for (const id of lookupFailingSince.keys()) if (!devices.has(id)) lookupFailingSince.delete(id); + for (const id of knownUsers.keys()) if (!devices.has(id)) knownUsers.delete(id); + if (devices.size === 0) { + statusFailingSince = null; + return false; + } + // A daemon that is starting or logged out lists no peers, which proves nothing for a + // while, and one too old for sign-in is not trusted either. Past the grace period + // nothing vouches for these devices, so their access ends. + const status = yield* host.freshStatus.pipe(Effect.option); + if ( + status._tag === "None" || + status.value.backendState !== "Running" || + !TailscaleHost.isSupportedTailscaleVersion(status.value.version) + ) { + const nowMs = DateTime.toEpochMillis(now); + statusFailingSince ??= nowMs; + if (nowMs - statusFailingSince < Duration.toMillis(LOOKUP_GRACE)) { + // A block needs no answer from Tailscale. + yield* Effect.forEach( + devices.keys(), + (id) => + Effect.flatMap(blocks.isBlocked({ stableNodeId: id }), (blocked) => + blocked ? endAccess(id, "blocked", nameOf(id)) : Effect.void, + ), + { discard: true }, + ); + return false; + } + const reason = + status._tag === "Some" && status.value.backendState === "Running" + ? "tailscale_outdated" + : "tailscale_unavailable"; + yield* Effect.forEach(devices.keys(), (id) => endAccess(id, reason, nameOf(id)), { + discard: true, + }); + return false; + } + statusFailingSince = null; + const account = status.value.selfNodeId; + const stillOnAccount = yield* Effect.cached( + host.freshStatus.pipe( + Effect.map((now) => now.selfNodeId === account), + Effect.orElseSucceed(() => false), + ), + ); + yield* Effect.forEach( + devices, + Effect.fnUntraced(function* ([stableNodeId, held]) { + const result = yield* recheckDevice(stableNodeId, status.value, stillOnAccount); + if (result === null) return; + const name = nameOf(stableNodeId, "node" in result ? result.node : undefined); + if ("end" in result) return yield* endAccess(stableNodeId, result.end, name); + const subject = tailnetSubject(stableNodeId); + // Only codes from an older grant go; one that matches stays usable. + const staleLinks = held.links.filter((link) => !sameScopes(link.scopes, result.allowed)); + const revokedLinks = yield* Effect.forEach(staleLinks, (link) => + pairingLinks.revoke({ id: link.id, revokedAt: now }), + ); + const links = revokedLinks.filter(Boolean).length; + // A code exchanged since the list above made its session in the same transaction, so + // with stale codes listed, sessions are read again once those are revoked. + const current = + staleLinks.length === 0 + ? held.sessions + : (yield* sessions.listActive()).filter((session) => session.subject === subject); + const stale = current.filter((session) => !sameScopes(session.scopes, result.allowed)); + yield* Effect.forEach(stale, (session) => sessions.revoke(session.sessionId), { + discard: true, + }); + if (stale.length > 0 || links > 0) { + yield* Effect.logInfo("Ended Tailscale sessions from an older grant.").pipe( + Effect.annotateLogs({ + ...name, + stableNodeId, + scopes: [...result.allowed].join(" "), + sessions: stale.length, + pendingSignIns: links, + }), + ); + } + }), + { concurrency: 8, discard: true }, + ); + return ( + (yield* Ref.get(grantsInDoubt)) !== doubtBefore && + (yield* Ref.get(ownerRuleAdmissions)) !== admissionsBefore + ); + }); + + const recheckSessions: TailnetSignIn["Service"]["recheckSessions"] = Effect.gen(function* () { + // A device the owner rule let in during a pass may have come before a grant the pass saw, + // so the pass runs once more. + if (yield* recheckPass) yield* recheckPass; + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not recheck Tailscale sessions.", { cause }), + ), + recheckLock.withPermits(1), + Effect.withSpan("TailnetSignIn.recheckSessions"), + ); + + const accessError = (message: string) => (cause: unknown) => + new TailnetAccessError({ message, cause }); + + const listDevices: TailnetSignIn["Service"]["listDevices"] = Effect.fn( + "TailnetSignIn.listDevices", + )(function* (currentSubject) { + const [active, blocked] = yield* Effect.all( + [ + sessions.listActive().pipe(Effect.mapError(accessError("Could not list sessions."))), + blocks.list().pipe(Effect.mapError(accessError("Could not list blocked devices."))), + ], + { concurrency: "unbounded" }, + ); + const devices = new Map(); + for (const session of active) { + if (!session.subject.startsWith(TAILNET_SUBJECT_PREFIX)) continue; + const stableNodeId = session.subject.slice(TAILNET_SUBJECT_PREFIX.length); + const existing = devices.get(stableNodeId); + devices.set(stableNodeId, { + stableNodeId, + label: existing?.label ?? session.client.label ?? null, + blockedAt: null, + activeSessionCount: (existing?.activeSessionCount ?? 0) + 1, + current: session.subject === currentSubject, + }); + } + for (const block of blocked) { + const existing = devices.get(block.stableNodeId); + devices.set(block.stableNodeId, { + stableNodeId: block.stableNodeId, + label: existing?.label ?? block.label, + blockedAt: block.blockedAt, + activeSessionCount: existing?.activeSessionCount ?? 0, + current: `${TAILNET_SUBJECT_PREFIX}${block.stableNodeId}` === currentSubject, + }); + } + return [...devices.values()]; + }); + + const blockDevice: TailnetSignIn["Service"]["blockDevice"] = Effect.fn( + "TailnetSignIn.blockDevice", + )(function* (stableNodeId) { + const subject = tailnetSubject(stableNodeId); + const label = + (yield* sessions + .listActive() + .pipe(Effect.mapError(accessError("Could not list sessions.")))).find( + (session) => session.subject === subject, + )?.client.label ?? null; + const blockedAt = yield* DateTime.now; + // A grant exchange checks blocks in its own write, so once the block lands none can make + // a session; one made before is revoked here. Not one transaction: an open socket that + // wakes on the revocation must read it committed. A dropped request still does both. + // Unused codes go too, so a quick unblock does not bring one back with older scopes. + return yield* blocks + .block({ stableNodeId, label, blockedAt }) + .pipe( + Effect.andThen(pairingLinks.revokeUnusedBySubject({ subject, revokedAt: blockedAt })), + Effect.andThen(sessions.revokeBySubject(subject)), + Effect.uninterruptible, + Effect.mapError(accessError("Could not block the device.")), + ); + }); + + const unblockDevice: TailnetSignIn["Service"]["unblockDevice"] = Effect.fn( + "TailnetSignIn.unblockDevice", + )(function* (stableNodeId) { + return yield* blocks + .unblock({ stableNodeId }) + .pipe(Effect.mapError(accessError("Could not unblock the device."))); + }); + + if (config.tailscaleSignInEnabled === true) { + yield* recheckSessions.pipe( + Effect.repeat(Schedule.spaced(RECHECK_INTERVAL)), + Effect.forkScoped, + ); + } else { + // Turning sign-in off ends Tailscale sessions and unused sign-in codes now, not when + // they expire. + const now = yield* DateTime.now; + // Sessions come last, so a device's session label wins over its code's. + const signedIn = new Map( + [ + ...(yield* pairingLinks.listActive({ now })), + ...(yield* sessions.listActive()).map((session) => ({ + subject: session.subject, + label: session.client.label ?? null, + })), + ] + .filter(({ subject }) => subject.startsWith(TAILNET_SUBJECT_PREFIX)) + .map(({ subject, label }) => [subject.slice(TAILNET_SUBJECT_PREFIX.length), label]), + ); + // Fails the start rather than leave a Tailscale session usable with sign-in off. + yield* Effect.forEach(signedIn, ([stableNodeId, label]) => + endAccess(stableNodeId, "sign_in_off", { device: label ?? stableNodeId, user: null }), + ); + } + + return TailnetSignIn.of({ signIn, listDevices, blockDevice, unblockDevice, recheckSessions }); +}); + +export const layer = Layer.effect(TailnetSignIn, make).pipe( + Layer.provideMerge(AuthTailnetDeviceBlocks.layer), +); diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts index 4047974500c48..640a85c74f6e6 100644 --- a/apps/server/src/auth/http.test.ts +++ b/apps/server/src/auth/http.test.ts @@ -157,3 +157,16 @@ it.effect("sets the selected browser session cookies through the HTTP route", () ); }).pipe(Effect.provide(NodeServices.layer)), ); + +it.effect("a scope denial tells a Tailscale session what to add to its grant", () => + Effect.gen(function* () { + const tailnet = yield* Effect.flip( + AuthHttp.failEnvironmentScopeRequired("access:write", "tailscale:nPhone"), + ); + expect(tailnet.tailnetGrantFix).toContain('Add "access:write" or "admin"'); + const paired = yield* Effect.flip( + AuthHttp.failEnvironmentScopeRequired("access:write", "bearer:laptop"), + ); + expect(paired.tailnetGrantFix).toBeUndefined(); + }), +); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 2358e73d6d866..51b29ce0ec1b0 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -15,8 +15,11 @@ import { EnvironmentResourceNotFoundError, type EnvironmentResourceNotFoundReason, EnvironmentScopeRequiredError, + EnvironmentTailnetSignInRefusedError, EnvironmentAuthenticatedAuth, EnvironmentAuthenticatedPrincipal, + TAILNET_SUBJECT_PREFIX, + tailnetScopeDenial, } from "@t3tools/contracts"; import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts"; import { parseOAuthScope } from "@t3tools/shared/oauthScope"; @@ -33,6 +36,7 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; +import * as TailnetSignIn from "./TailnetSignIn.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; @@ -119,13 +123,17 @@ export function failEnvironmentInvalidRequest(reason: EnvironmentRequestInvalidR ); } -export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope) { +/** `subject` is the session's, so a Tailscale session's denial can point at its grant. */ +export function failEnvironmentScopeRequired(requiredScope: AuthEnvironmentScope, subject: string) { return currentEnvironmentTraceId.pipe( Effect.flatMap((traceId) => Effect.fail( new EnvironmentScopeRequiredError({ code: "insufficient_scope", ...authScopeRequiredResponse(requiredScope), + ...(subject.startsWith(TAILNET_SUBJECT_PREFIX) + ? { tailnetGrantFix: tailnetScopeDenial(requiredScope) } + : {}), traceId, }), ), @@ -179,7 +187,7 @@ export const authenticateMediaRequest = ), ); if (!session.scopes.includes(requiredScope)) { - return yield* failEnvironmentScopeRequired(requiredScope); + return yield* failEnvironmentScopeRequired(requiredScope, session.subject); } return session; }); @@ -220,7 +228,7 @@ export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope" ) { const session = yield* EnvironmentAuthenticatedPrincipal; if (!session.scopes.has(scope)) { - return yield* failEnvironmentScopeRequired(scope); + return yield* failEnvironmentScopeRequired(scope, session.subject); } return session; }); @@ -454,7 +462,7 @@ export const layer = HttpApiBuilder.group( } for (const delegatedScope of delegatedScopes) { if (!session.scopes.has(delegatedScope)) { - return yield* failEnvironmentScopeRequired(delegatedScope); + return yield* failEnvironmentScopeRequired(delegatedScope, session.subject); } } return yield* serverAuth.issuePairingCredential(args.payload); @@ -519,3 +527,57 @@ export const layer = HttpApiBuilder.group( ); }), ); + +export const layerTailnet = HttpApiBuilder.group( + EnvironmentHttpApi, + "tailnet", + Effect.fnUntraced(function* (handlers) { + const tailnetSignIn = yield* TailnetSignIn.TailnetSignIn; + + return handlers.handle( + "signIn", + Effect.fn("environment.auth.tailnetSignIn")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const request = yield* HttpServerRequest.HttpServerRequest; + yield* appendCredentialResponseHeaders; + return yield* tailnetSignIn.signIn(request); + }, + Effect.catchTags({ + TailnetSignInRefusedError: (error) => + currentEnvironmentTraceId.pipe( + Effect.flatMap((traceId) => + Effect.fail( + new EnvironmentTailnetSignInRefusedError({ + code: "tailnet_sign_in_refused", + reason: error.reason, + traceId, + ...(error.suggestedGrant === undefined + ? {} + : { suggestedGrant: error.suggestedGrant }), + }), + ), + ), + ), + ServerAuthInvalidCredentialError: (error) => + appendDpopChallengeHeader.pipe( + Effect.andThen( + failEnvironmentAuthInvalid( + "invalid_credential", + EnvironmentAuth.serverAuthDpopFailureReason(error), + ), + ), + ), + TailnetDeviceBlockLookupError: (error) => + failEnvironmentInternal("internal_error", error), + // Unreachable in practice: a policy without remote clients keeps this server off the tailnet. + ManagedPolicyRefusedError: (error) => + failEnvironmentInternal("pairing_credential_issuance_failed", error), + }), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("pairing_credential_issuance_failed", error), + ), + ), + ); + }), +); diff --git a/apps/server/src/auth/tailnetGrants.test.ts b/apps/server/src/auth/tailnetGrants.test.ts new file mode 100644 index 0000000000000..56b091e6fbfdf --- /dev/null +++ b/apps/server/src/auth/tailnetGrants.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from "@effect/vitest"; + +import { peersJoined } from "./tailnetGrants.ts"; + +// Peers with no ID as `key@address`, or a bare key for one with no address. +const list = (ids: ReadonlyArray, unidentified: ReadonlyArray = []) => ({ + peers: ids.map((stableNodeId) => ({ stableNodeId })), + unidentifiedPeers: unidentified.map((peer) => { + const [key = "", address] = peer.split("@"); + return { key, address: address ?? null }; + }), +}); + +it.each([ + { name: "the same devices", before: list(["a", "b"]), after: list(["b", "a"]), joined: false }, + { name: "a device left", before: list(["a", "b"]), after: list(["a"]), joined: false }, + { name: "a device joined", before: list(["a"]), after: list(["a", "b"]), joined: true }, + { + name: "a peer with no ID replaced another", + before: list(["a"], ["k1@100.88.1.21"]), + after: list(["a"], ["k2@100.88.1.22"]), + joined: true, + }, + { + name: "another node with no ID took the same address", + before: list([], ["k1@100.88.1.21"]), + after: list([], ["k2@100.88.1.21"]), + joined: true, + }, + { + name: "the same peers with no ID, one with no address", + before: list([], ["k1", "k2@100.88.1.21"]), + after: list([], ["k2@100.88.1.21", "k1"]), + joined: false, + }, +])( + "a scan of the earlier list missed a device when $name: $joined", + ({ before, after, joined }) => { + expect(peersJoined(before, after)).toBe(joined); + }, +); diff --git a/apps/server/src/auth/tailnetGrants.ts b/apps/server/src/auth/tailnetGrants.ts new file mode 100644 index 0000000000000..97dd121db0ab7 --- /dev/null +++ b/apps/server/src/auth/tailnetGrants.ts @@ -0,0 +1,111 @@ +import { + AuthGrantScope, + type AuthEnvironmentScope, + TAILNET_APP_CAPABILITY, + TAILNET_GRANT_PRESETS, +} from "@t3tools/contracts"; +import type { TailscaleStatus, TailscaleWhois } from "@t3tools/tailscale"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const isGrantScope = Schema.is(AuthGrantScope); +const isGrantValue = Schema.is(Schema.Struct({ scopes: Schema.Array(Schema.String) })); + +/** + * The scopes the device's T3 Code grants add up to, or null when the policy + * grants it none. A grant that names no known scope gives none: unknown names + * grant nothing, so a typo cannot widen access. + */ +export const tailnetGrantScopes = ( + capabilities: TailscaleWhois["capabilities"], +): ReadonlyArray | null => { + if (!Object.hasOwn(capabilities, TAILNET_APP_CAPABILITY)) return null; + const scopes = new Set(); + for (const grant of capabilities[TAILNET_APP_CAPABILITY] ?? []) { + for (const name of isGrantValue(grant) ? grant.scopes : []) { + for (const scope of TAILNET_GRANT_PRESETS.get(name) ?? (isGrantScope(name) ? [name] : [])) { + scopes.add(scope); + } + } + } + return [...scopes]; +}; + +type PeerList = Pick & { + readonly peers: ReadonlyArray<{ readonly stableNodeId: string }>; +}; + +/** + * Whether `after` lists a device `before` did not, so a scan of `before` never looked it up. + * Peers with no ID are compared by node key and address, so one replacing another counts. + */ +export const peersJoined = (before: PeerList, after: PeerList): boolean => { + const known = new Set(before.peers.map((peer) => peer.stableNodeId)); + if (after.peers.some((peer) => !known.has(peer.stableNodeId))) return true; + // By node key and address, so neither a new node at an old address nor a moved one hides. + const keyOf = (peer: TailscaleStatus["unidentifiedPeers"][number]) => + `${peer.key} ${peer.address ?? ""}`; + const unidentified = new Map(); + for (const peer of before.unidentifiedPeers) { + unidentified.set(keyOf(peer), (unidentified.get(keyOf(peer)) ?? 0) + 1); + } + return after.unidentifiedPeers.some((peer) => { + const left = unidentified.get(keyOf(peer)) ?? 0; + unidentified.set(keyOf(peer), left - 1); + return left === 0; + }); +}; + +/** + * The peers this host's tailnet policy grants T3 Code, by name, and the peers + * whois could not read. A box cannot read its policy without root, but whois + * shows each peer's grants toward it. `onGrant` runs as each grant is confirmed. + */ +export const policyGrantedPeers = Effect.fn("tailnetGrants.policyGrantedPeers")(function* ( + status: TailscaleStatus, + whois: (address: string) => Effect.Effect, + onGrant: Effect.Effect = Effect.void, +) { + const peers: ReadonlyArray<{ + readonly address: string | undefined; + readonly name: string; + readonly stableNodeId: string | null; + }> = [ + ...status.peers.map((peer) => { + const address = peer.tailnetIpv4Addresses[0] ?? peer.tailnetIpv6Addresses[0]; + return { + address, + name: peer.hostName ?? address ?? peer.stableNodeId, + stableNodeId: peer.stableNodeId, + }; + }), + ...status.unidentifiedPeers.map(({ address }) => ({ + address: address ?? undefined, + name: address ?? "a peer with no ID", + stableNodeId: null, + })), + ]; + const results = yield* Effect.forEach( + peers, + ({ address, name, stableNodeId }) => { + if (address === undefined) return Effect.succeed({ name, granted: null }); + return whois(address).pipe( + Effect.map((node) => ({ + name, + // An address that now names another node, as after an account switch, is unread. + granted: + stableNodeId !== null && node.stableNodeId !== stableNodeId + ? null + : (Object.hasOwn(node.capabilities, TAILNET_APP_CAPABILITY) as boolean | null), + })), + Effect.orElseSucceed(() => ({ name, granted: null })), + Effect.tap(({ granted }) => (granted === true ? onGrant : Effect.void)), + ); + }, + { concurrency: 8 }, + ); + return { + granted: results.flatMap((peer) => (peer.granted === true ? [peer.name] : [])), + unreadable: results.flatMap((peer) => (peer.granted === null ? [peer.name] : [])), + }; +}); diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index 32f00bde63999..9e3f633746823 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -28,6 +28,7 @@ import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; import { sshHelperCommand } from "./cli/sshHelper.ts"; import { serviceCommand } from "./cli/service.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; +import { tailscaleCommand } from "./cli/tailscale.ts"; import { themeCommand } from "./cli/theme.ts"; import { traceCommand } from "./cli/trace.ts"; import { triageCommand } from "./cli/triage.ts"; @@ -93,6 +94,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => sshHelperCommand, servicePreflightCommand, + tailscaleCommand, themeCommand, traceCommand, doctorCommand, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 297b225b6adce..2ff2da9252824 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -50,6 +50,8 @@ const minimalDesktopFlags = (baseDir: string) => ({ tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), lan: Option.none(), + tailscaleSignInEnabled: Option.none(), + tailscaleOwnerUserId: Option.none(), }); const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); @@ -233,6 +235,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), lan: Option.none(), + tailscaleSignInEnabled: Option.none(), + tailscaleOwnerUserId: Option.none(), }; const layerConfig = ConfigProvider.layer( ConfigProvider.fromEnv({ @@ -278,6 +282,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), lan: Option.none(), + tailscaleSignInEnabled: Option.none(), + tailscaleOwnerUserId: Option.none(), }; const layerConfig = ConfigProvider.layer( ConfigProvider.fromEnv({ env: { T3CODE_DEV_AUTH_TOKEN: secret } }), @@ -334,6 +340,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), lan: Option.none(), + tailscaleSignInEnabled: Option.none(), + tailscaleOwnerUserId: Option.none(), }, Option.none(), ).pipe( @@ -411,6 +419,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), lan: Option.none(), + tailscaleSignInEnabled: Option.none(), + tailscaleOwnerUserId: Option.none(), }, Option.some("Debug"), ).pipe( @@ -559,6 +569,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), lan: Option.none(), + tailscaleSignInEnabled: Option.none(), + tailscaleOwnerUserId: Option.none(), }, Option.none(), ).pipe( @@ -608,6 +620,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), lan: Option.none(), + tailscaleSignInEnabled: Option.none(), + tailscaleOwnerUserId: Option.none(), }, Option.none(), ).pipe( @@ -649,6 +663,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), lan: Option.none(), + tailscaleSignInEnabled: Option.none(), + tailscaleOwnerUserId: Option.none(), }, Option.none(), { @@ -712,6 +728,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), lan: Option.none(), + tailscaleSignInEnabled: Option.none(), + tailscaleOwnerUserId: Option.none(), }, Option.none(), ).pipe( @@ -752,6 +770,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), lan: Option.none(), + tailscaleSignInEnabled: Option.none(), + tailscaleOwnerUserId: Option.none(), }); it.effect( @@ -873,7 +893,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); - it.effect("listens on private networks per T3CODE_LAN, else under the service launcher", () => + it.effect("listens per T3CODE_LAN and Tailscale sign-in, else per the service launcher", () => Effect.gen(function* () { const { join } = yield* Path.Path; const baseDir = join(NodeOS.tmpdir(), "t3-cli-config-service-host"); @@ -882,7 +902,11 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { readonly env?: Record; }) => resolveServerConfig(minimalWebFlags(baseDir), Option.none()).pipe( - Effect.map(({ host, privateNetworkOnly }) => ({ host, privateNetworkOnly })), + Effect.map(({ host, privateNetworkOnly, tailscaleOnly }) => ({ + host, + privateNetworkOnly, + tailscaleOnly, + })), Effect.provide( Layer.mergeAll( ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), @@ -921,6 +945,15 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { host: "0.0.0.0", privateNetworkOnly: true, }); + // Tailscale sign-in without the local network accepts only the tailnet. + const signIn = { T3CODE_TAILSCALE_SIGN_IN: "true" }; + expect( + yield* resolveHost({ connected: true, env: { ...signIn, T3CODE_LAN: "false" } }), + ).toEqual({ host: "0.0.0.0", privateNetworkOnly: undefined, tailscaleOnly: true }); + expect(yield* resolveHost({ connected: true, env: signIn })).toEqual({ + host: "0.0.0.0", + privateNetworkOnly: true, + }); // Without the launcher's IPC channel the context was inherited, e.g. by // `t3 serve` started from an agent inside the service. expect(yield* resolveHost({ connected: false })).toEqual({ diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index f86fde0f59750..be576c4c368db 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -85,6 +85,21 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, ); +/** Tailscale user IDs are int64; keep them as digits. */ +export const TailscaleUserId = Schema.String.check(Schema.isPattern(/^[1-9]\d{0,18}$/)); +const tailscaleSignInFlag = Flag.Boolean("tailscale-sign-in").pipe( + Flag.withDescription( + "Let Tailscale devices sign in with no pairing code: devices your tailnet policy grants t3.codes/cap/t3code, else the owner's own untagged devices (equivalent to T3CODE_TAILSCALE_SIGN_IN).", + ), + Flag.optional, +); +const tailscaleOwnerFlag = Flag.String("tailscale-owner").pipe( + Flag.withSchema(TailscaleUserId), + Flag.withDescription( + "Tailscale user ID that owns this host when it is tagged (equivalent to T3CODE_TAILSCALE_OWNER). `t3 up` sets it.", + ), + Flag.optional, +); const lanFlag = Flag.Boolean("lan").pipe( Flag.withDescription( "Accept connections from your local network and tailnet, not only this machine (equivalent to T3CODE_LAN).", @@ -171,6 +186,14 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + tailscaleSignInEnabled: Config.Boolean("T3CODE_TAILSCALE_SIGN_IN").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + tailscaleOwnerUserId: Config.schema(TailscaleUserId, "T3CODE_TAILSCALE_OWNER").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), lan: Config.Boolean("T3CODE_LAN").pipe(Config.option, Config.map(Option.getOrUndefined)), }); @@ -207,6 +230,8 @@ export interface CliServerFlags { readonly logWebSocketEvents: Option.Option; readonly tailscaleServeEnabled: Option.Option; readonly tailscaleServePort: Option.Option; + readonly tailscaleSignInEnabled: Option.Option; + readonly tailscaleOwnerUserId: Option.Option; readonly lan: Option.Option; } @@ -242,6 +267,8 @@ export const sharedServerCommandFlags = { logWebSocketEvents: logWebSocketEventsFlag, tailscaleServeEnabled: tailscaleServeFlag, tailscaleServePort: tailscaleServePortFlag, + tailscaleSignInEnabled: tailscaleSignInFlag, + tailscaleOwnerUserId: tailscaleOwnerFlag, lan: lanFlag, } as const; @@ -286,6 +313,8 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + tailscaleSignInEnabled: flags.tailscaleSignInEnabled ?? Option.none(), + tailscaleOwnerUserId: flags.tailscaleOwnerUserId ?? Option.none(), lan: flags.lan ?? Option.none(), } satisfies CliServerFlags; const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; @@ -414,6 +443,20 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct ), () => 443, ); + const tailscaleSignInEnabled = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.tailscaleSignInEnabled, + Option.fromUndefinedOr(env.tailscaleSignInEnabled), + Option.fromUndefinedOr(bootstrap?.tailscaleSignInEnabled), + ), + () => false, + ); + const tailscaleOwnerUserId = Option.getOrUndefined( + resolveOptionPrecedence( + normalizedFlags.tailscaleOwnerUserId, + Option.fromUndefinedOr(env.tailscaleOwnerUserId), + ), + ); const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir(); const explicitHost = resolveOptionPrecedence( normalizedFlags.host, @@ -433,8 +476,16 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct Effect.map((launcher) => launcher.managed), Effect.orElseSucceed(() => false), )); + // Tailscale sign-in needs tailnet devices to reach the server. Without the + // local network, it accepts only loopback and tailnet connections. + const tailscaleOnly = + bootstrap?.tailscaleOnly ?? + (tailscaleSignInEnabled && + !privateNetworkOnly && + Option.isNone(explicitHost) && + mode === "web"); const host = Option.getOrElse(explicitHost, () => - privateNetworkOnly ? "0.0.0.0" : mode === "desktop" ? "127.0.0.1" : undefined, + privateNetworkOnly || tailscaleOnly ? "0.0.0.0" : mode === "desktop" ? "127.0.0.1" : undefined, ); const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); @@ -519,6 +570,9 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + ...(tailscaleSignInEnabled ? { tailscaleSignInEnabled } : {}), + ...(tailscaleOwnerUserId === undefined ? {} : { tailscaleOwnerUserId }), + ...(tailscaleOnly ? { tailscaleOnly } : {}), }; return config; @@ -543,6 +597,8 @@ export const resolveCliAuthConfig = ( logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + tailscaleSignInEnabled: Option.none(), + tailscaleOwnerUserId: Option.none(), lan: Option.none(), }, cliLogLevel, diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 28a6f464344ec..170b7503d51bd 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -9,6 +9,7 @@ import { type RelayClientInstallProgressStage, } from "@t3tools/contracts"; import { RelayOkResponse } from "@t3tools/contracts/relay"; +import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Cause from "effect/Cause"; @@ -27,6 +28,7 @@ import * as References from "effect/References"; import * as Schema from "effect/Schema"; import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as HttpApiClient from "effect/http-api/HttpApiClient"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; @@ -521,6 +523,8 @@ export const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function | Crypto.Crypto | FileSystem.FileSystem | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | NetService.NetService | HttpClient.HttpClient | Prompt.Environment | ServerConfig.ServerConfig diff --git a/apps/server/src/cli/environment.ts b/apps/server/src/cli/environment.ts index 869c085cbad2b..1420f06d94263 100644 --- a/apps/server/src/cli/environment.ts +++ b/apps/server/src/cli/environment.ts @@ -14,6 +14,7 @@ import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as TailscaleHost from "../tailscale/TailscaleHost.ts"; import * as PeerLinks from "../peer/PeerLinks.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; @@ -42,7 +43,12 @@ const runWithPeerLinks = Effect.fn("environment.runWithPeerLinks")(function* ()( @@ -197,7 +198,7 @@ const formatPairOutput = (input: { "", `Pairing URL: ${input.pairingUrl}`, `App link: ${buildPairingAppLink(input.pairingUrl)}`, - `Token: ${input.token}`, + `Pairing code: ${input.token}`, `Expires: ${DateTime.formatIso(input.expiresAt)}`, ...input.notes.flatMap((note) => ["", `Note: ${note}`]), "", @@ -361,7 +362,7 @@ const awaitEnvironmentDescriptor = Effect.fn(function* (baseUrl: string) { const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase")( function* (input: { readonly target: DiscoveredPairTarget; readonly servePort: number }) { const notes: Array = []; - const status = yield* readTailscaleStatus.pipe( + const status = yield* readTailscaleStatus("active").pipe( Effect.mapError((cause) => new TailscaleUnavailableError({ cause })), ); if (status.magicDnsName === null) { @@ -450,7 +451,7 @@ const mintPairingLink = Effect.fn("pair.mintPairingLink")(function* (input: { const ttlFlag = Flag.String("ttl").pipe( Flag.withSchema(DurationFromString), Flag.withDescription( - "Token TTL, for example `5m`, `1h`, or `15 minutes`. Defaults to 5 minutes.", + "Pairing code TTL, for example `5m`, `1h`, or `15 minutes`. Defaults to 5 minutes.", ), Flag.optional, ); @@ -482,7 +483,7 @@ export const pairCommand = Command.make("pair", { tailscaleServePort: tailscaleServePortFlag, }).pipe( Command.withDescription( - "Mint a pairing token for a running T3 Code server and print it as a QR code.", + "Mint a pairing code for a running T3 Code server and print it as a QR code.", ), Command.withHandler((flags) => Effect.gen(function* () { @@ -524,6 +525,8 @@ export const pairCommand = Command.make("pair", { notes.push( "This URL is only reachable from this machine. To reach it from your other devices, run `t3 up`, or re-run with --tailscale.", ); + } else if (target.state.tailscaleOnly) { + notes.push("This URL works only from your tailnet."); } else if (target.state.privateNetworkOnly) { notes.push( "This URL works only from this machine's local network. From elsewhere, re-run with --tailscale or use T3 Connect.", diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts new file mode 100644 index 0000000000000..ae280129197e7 --- /dev/null +++ b/apps/server/src/cli/tailscale.ts @@ -0,0 +1,247 @@ +import * as NodeUtil from "node:util"; + +import { + type ServerTailscaleIssue, + ServerTailscaleStatus, + TAILNET_APP_CAPABILITY, + tailscaleIssueHeadline, + tailscaleStateFromBackend, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import { readTailscaleStatus, readTailscaleWhois, type TailscaleStatus } from "@t3tools/tailscale"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; + +import { REQUIRE_GRANT_FILE } from "../auth/TailnetSignIn.ts"; +import { SERVICE_ENV_FILE } from "../cloud/serviceProtocol.ts"; +import { peersJoined, policyGrantedPeers } from "../auth/tailnetGrants.ts"; +import { + formatTailscaleHealth, + readErrorFacts, + readTailscaleHealth, + signInIssues, + stateFix, +} from "../environment/TailscaleHealth.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +const encodeStatusJson = Schema.encodeEffect(fromJsonStringPretty(ServerTailscaleStatus)); + +const tailscaleStatusCommand = Command.make("status", { + json: Flag.Boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), + ), +}).pipe( + Command.withDescription("Show this machine's Tailscale state and what to fix."), + Command.withHandler((flags) => + Effect.gen(function* () { + const status = yield* readTailscaleHealth; + yield* Console.log( + flags.json ? yield* encodeStatusJson(status) : formatTailscaleHealth(status), + ); + }), + ), +); + +/** Writes the state file that makes this host require a grant, as `t3 up --require-grant` does. */ +export const turnOnRequireGrant = Effect.fn("cli.tailscale.turnOnRequireGrant")(function* ( + stateDir: string, + why: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString( + path.join(stateDir, REQUIRE_GRANT_FILE), + `${DateTime.formatIso(yield* DateTime.now)} ${why}\n`, + ); +}); + +/** A Tailscale issue on this machine as one line, as `t3 tailscale status` words it. */ +const issueLine = (issue: ServerTailscaleIssue) => + `${tailscaleIssueHeadline("this machine", issue, null, 0)}. ${issue.fix}`; + +/** Why a `tailscale status` read failed, as `t3 tailscale status` words it. */ +export const readFailure = ( + error: Parameters[0], + platform: NodeJS.Platform, +): string => issueLine(readErrorFacts(error, platform)); + +/** Why Tailscale sign-in cannot work on this host, or null when it can. */ +export const signInBlocker = ( + status: TailscaleStatus, + platform: NodeJS.Platform, +): string | null => { + const state = tailscaleStateFromBackend(status.backendState); + if (state !== "running") return issueLine({ cause: state, fix: stateFix(state, platform) }); + const [first] = signInIssues(status); + return first === undefined ? null : issueLine(first); +}; + +/** + * Peers the tailnet policy grants T3 Code here, whether this host is tagged, and what stops + * sign-in here, if anything. "changed" when the account or its devices changed during the + * check: lookups after a switch read another tailnet, and a device that joined was not read. + */ +const readPolicyGrantedPeers = Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + const read = yield* Effect.result(readTailscaleStatus("active")); + if (Result.isFailure(read)) { + const blocker = readFailure(read.failure, platform); + return { granted: [], unreadable: [], tagged: false, noNode: false, blocker, readFailed: true }; + } + const status = read.success; + const facts = { + tagged: status.selfTags.length > 0, + noNode: status.selfNodeId === null, + blocker: signInBlocker(status, platform), + }; + // Where sign-in cannot work, the policy does not matter, so nothing more is read. + if (facts.blocker !== null) return { granted: [], unreadable: [], ...facts }; + const scan = yield* policyGrantedPeers(status, readTailscaleWhois); + const read2 = yield* Effect.result(readTailscaleStatus("active")); + if (Result.isFailure(read2)) { + const blocker = readFailure(read2.failure, platform); + return { granted: [], unreadable: [], ...facts, blocker, readFailed: true }; + } + const after = read2.success; + if (after.selfNodeId !== status.selfNodeId) return "changed" as const; + // The host may have changed during the scan, so its latest state decides. + const lateBlocker = signInBlocker(after, platform); + if (lateBlocker !== null) return { granted: [], unreadable: [], ...facts, blocker: lateBlocker }; + return scan.granted.length === 0 && peersJoined(status, after) + ? ("changed" as const) + : { ...scan, ...facts, tagged: after.selfTags.length > 0 }; +}); + +/** Whether this tagged host has an owner, from `t3 up --owner` or this shell's environment. */ +const hasOwner = Effect.fn("cli.tailscale.hasOwner")(function* ( + baseDir: string, + configured: string | undefined, +) { + if (configured !== undefined) return true; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const file = path.join(baseDir, SERVICE_ENV_FILE); + return yield* fs.readFileString(file).pipe( + Effect.map((text) => NodeUtil.parseEnv(text).T3CODE_TAILSCALE_OWNER !== undefined), + Effect.orElseSucceed(() => false), + ); +}); + +const requireGrantCommand = Command.make("require-grant", { + ...projectLocationFlags, + state: Argument.Literals("state", ["on", "off"]).pipe( + Argument.withDescription("Turn the requirement on or off. Without it, show the current state."), + Argument.optional, + ), +}).pipe( + Command.withDescription( + `Show or change whether only devices your tailnet policy grants ${TAILNET_APP_CAPABILITY} sign in here.`, + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* resolveCliAuthConfig(flags, yield* GlobalFlag.LogLevel); + const file = path.join(config.stateDir, REQUIRE_GRANT_FILE); + if (Option.contains(flags.state, "on")) { + yield* turnOnRequireGrant(config.stateDir, "turned on with t3 tailscale require-grant"); + } else if (Option.contains(flags.state, "off")) { + yield* fs.remove(file, { force: true }); + } + const since = (yield* fs.exists(file)) ? (yield* fs.readFileString(file)).trim() : null; + const read = yield* readPolicyGrantedPeers; + const scan = read === "changed" ? null : read; + // A tagged host with no owner set lets in only granted devices, policy or not. + const ownerless = + scan !== null && + scan.tagged && + !(yield* hasOwner(config.baseDir, config.tailscaleOwnerUserId)); + const granted = scan?.granted ?? []; + const lines = + scan !== null && scan.blocker !== null + ? [ + // This shell may not read Tailscale as the server does, so it says only what it saw. + scan.readFailed === true ? scan.blocker : `No device signs in here. ${scan.blocker}`, + since === null + ? "Grant required: not turned on." + : `Grant required: yes, turned on ${since}.`, + ] + : since !== null + ? [ + `Grant required: yes, turned on ${since}.`, + "Turn off with: t3 tailscale require-grant off", + ] + : granted.length > 0 + ? [ + `Grant required: yes, because your tailnet policy grants ${TAILNET_APP_CAPABILITY} here to: ${granted.join(", ")}.`, + ownerless + ? "This tagged host has no owner, so only granted devices get in either way." + : scan?.noNode === true + ? "Tailscale names no node for this host, so only granted devices get in either way." + : "Remove those grants to let your own untagged devices in without one.", + ] + : read === "changed" + ? [ + "Grant required: unknown. This host's Tailscale account or its devices", + "changed during the check.", + "Run this again.", + ] + : read.noNode + ? [ + "Grant required: yes, because Tailscale names no node for this host.", + "Run `tailscale up` to sign in.", + ] + : read.unreadable.length > 0 + ? [ + `Grant required: probably. Tailscale could not read ${read.unreadable.join(", ")},`, + "so T3 Code cannot tell whether your policy grants it here. It keeps the answer", + "from its last full check for up to five minutes, then requires a grant.", + ] + : ownerless + ? [ + "Grant required: no, but this tagged host has no owner, so only granted", + "devices get in. Name its owner with: t3 up --owner ", + ] + : [ + "Grant required: no. Your tailnet policy grants T3 Code here to no device.", + "If Tailscale sign-in is on, your own untagged devices sign in without one.", + `Once the policy grants ${TAILNET_APP_CAPABILITY} here, only granted devices get in.`, + "Require a grant now with: t3 tailscale require-grant on", + ]; + yield* Console.log( + [ + ...lines, + ...(scan !== null && scan.blocker === null + ? [ + "Devices with a grant get the scopes it names: read, standard, admin, or single scopes.", + // No owner access turns off or back on there, so no delay applies. + ...(since === null && !ownerless && !scan.noNode + ? [ + "This is this shell's scan. The server notices a first grant within a minute", + "(five if it cannot read that device). After you remove the last grant, your", + "own untagged devices can sign in again within five minutes, once the server", + "can read every device.", + ] + : []), + ] + : []), + ].join("\n"), + ); + }), + ), +); + +export const tailscaleCommand = Command.make("tailscale").pipe( + Command.withDescription("Check Tailscale on this machine, and who can sign in through it."), + Command.withSubcommands([tailscaleStatusCommand, requireGrantCommand]), +); diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index 9fbe515944a83..203419d1df4e3 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -12,45 +12,83 @@ import * as Queue from "effect/Queue"; import * as Terminal from "effect/Terminal"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/cli"; +import * as Tailscale from "@t3tools/tailscale"; import { afterEach, vi } from "vite-plus/test"; +// The tests must not depend on Tailscale running on the machine that runs them. +vi.mock("@t3tools/tailscale", async (importOriginal) => ({ + ...(await importOriginal()), + readTailscaleStatus: () => Effect.fail("Tailscale is not running in tests"), + readTailscaleProfiles: () => Effect.fail("Tailscale is not running in tests"), +})); + import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ServerOwnership from "../serverOwnership.ts"; import { upsertServiceEnv } from "./service.ts"; -import { connectEnabled, planRoutes, upCommand } from "./up.ts"; +import { connectEnabled, planRoutes, resolveOwner, upCommand, upPortCheck } from "./up.ts"; + +const TAGGED_HOST: Tailscale.TailscaleStatus = { + version: "1.90.0", + magicDnsName: "box.example.ts.net", + tailnetIpv4Addresses: ["100.64.0.2"], + httpsServeEnabled: null, + backendState: "Running", + selfNodeId: "n1", + selfUserId: null, + selfTags: ["tag:t3-box"], + keyExpiry: null, + keyExpired: false, + health: [], + magicDnsEnabled: true, + tailnetName: null, + certDomains: [], + userspaceNetworking: false, + peers: [], + unidentifiedPeers: [], + users: [{ userId: "123", loginName: "me@example.com" }], +}; const none = Option.none(); +const off = { tailscale: false, connect: false, lan: false }; +const both = { tailscale: true, connect: true }; +const noFlags = { tailscale: none, connect: none, lan: none }; it.each([ { - label: "a new machine starts from T3 Connect and asks", - setup: { installed: false, connect: false, lan: false }, - flags: { connect: none, lan: none }, - expected: { routes: { connect: true, lan: false }, ask: ["connect", "lan"] }, + label: "a new machine with Tailscale starts from it and asks", + setup: { installed: false, current: off, available: both }, + flags: noFlags, + expected: { routes: { ...off, tailscale: true }, ask: ["tailscale", "connect", "lan"] }, }, { - label: "a build without T3 Connect never offers it", - setup: { installed: false, connect: null, lan: false }, - flags: { connect: none, lan: none }, - expected: { routes: { connect: false, lan: false }, ask: ["lan"] }, + label: "a new machine without Tailscale starts from T3 Connect", + setup: { installed: false, current: off, available: { tailscale: false, connect: true } }, + flags: noFlags, + expected: { routes: { ...off, connect: true }, ask: ["connect", "lan"] }, }, { - label: "a rerun starts from the current setup", - setup: { installed: true, connect: false, lan: true }, - flags: { connect: none, lan: none }, - expected: { routes: { connect: false, lan: true }, ask: ["connect", "lan"] }, + label: "a rerun starts from the current setup and can turn a route off", + setup: { + installed: true, + current: { ...off, tailscale: true, lan: true }, + available: { tailscale: false, connect: false }, + }, + flags: noFlags, + expected: { routes: { ...off, tailscale: true, lan: true }, ask: ["tailscale", "lan"] }, }, { - label: "a route flag on a new machine turns on only that route", - setup: { installed: false, connect: false, lan: false }, - flags: { connect: none, lan: Option.some(true) }, - expected: { routes: { connect: false, lan: true }, ask: [] }, + label: "another setup flag asks nothing and keeps every route, even on a new machine", + setup: { installed: false, current: off, available: both }, + flags: { ...noFlags, other: true }, + expected: { routes: off, ask: [] }, }, { - label: "a route flag asks nothing and keeps the other route", - setup: { installed: true, connect: true, lan: true }, - flags: { connect: none, lan: Option.some(false) }, - expected: { routes: { connect: true, lan: false }, ask: [] }, + label: "a route flag asks nothing and keeps the routes not named, even on a new machine", + setup: { installed: false, current: off, available: both }, + flags: { ...noFlags, lan: Option.some(true) }, + expected: { routes: { ...off, lan: true }, ask: [] }, }, ])("$label", ({ setup, flags, expected }) => { expect(planRoutes(setup, flags)).toEqual(expected); @@ -160,6 +198,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => readonly status?: Partial; readonly installFails?: boolean; readonly restartFails?: boolean; + /** What Tailscale reports here. Without it, Tailscale does not answer. */ + readonly tailscale?: Tailscale.TailscaleStatus; + /** A server started by hand runs for this T3 home. */ + readonly handStarted?: boolean; + /** A stopped server left this Serve mapping record. */ + readonly serveRecord?: string; + /** A server holds the T3 home's lock. */ + readonly serverHoldsLock?: boolean; + /** `t3 up` exits with an error. */ + readonly fails?: boolean; /** Runs as if in a terminal, answering each question with this text. */ readonly answer?: string; readonly prints?: string; @@ -218,7 +266,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => { label: "a failed update keeps service.env matching the old service", // The old service still runs, so no foreground server is offered. - prints: "Fix that, then run `t3 up` again.", + prints: "The service kept its old settings. Fix that, then run `t3 up` again.", + fails: true, installed: true, status: { current: false }, installFails: true, @@ -251,6 +300,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => }, { label: "no service manager still records the choice", + // Every choice is explicit, so the shell's environment cannot change it. + prints: "--lan --no-tailscale-sign-in --no-tailscale-serve", installed: false, status: { supported: false }, env: "", @@ -260,19 +311,215 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => envAfter: "T3CODE_LAN=true\n", }, { - label: "the old default is kept", + label: "Tailscale down here keeps its settings while another route changes", + installed: true, + env: "T3CODE_LAN=true\nT3CODE_TAILSCALE_SIGN_IN=true\n", + args: ["--no-lan"], + installs: 0, + restarts: 1, + envAfter: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=true\n", + }, + { + label: "turning Tailscale off stops Serve too, written as false", + installed: true, + env: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=true\nT3CODE_TAILSCALE_SERVE=true\n", + args: ["--no-tailscale"], + installs: 0, + restarts: 1, + envAfter: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + // Only turning Serve on implies Tailscale. Off is written, since it may be inherited. + label: "--no-tailscale-serve leaves Tailscale off and pins Serve off", + installed: true, + env: "T3CODE_LAN=false\n", + args: ["--no-tailscale-serve"], + installs: 0, + restarts: 1, + envAfter: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + label: "--no-tailscale pins sign-in off even when the file says nothing", + installed: true, + env: "T3CODE_LAN=false\n", + args: ["--no-tailscale"], + installs: 0, + restarts: 1, + envAfter: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + label: "--no-tailscale-serve turns Serve off while Tailscale is down here", + installed: true, + env: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=true\nT3CODE_TAILSCALE_SERVE=true\n", + args: ["--no-tailscale-serve"], + installs: 0, + restarts: 1, + envAfter: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=true\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + label: "--no-tailscale pins Serve off when sign-in was already off", + installed: true, + env: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=false\n", + args: ["--no-tailscale"], + installs: 0, + restarts: 1, + envAfter: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + label: "--no-tailscale writes its offs with no service to run", + installed: false, + env: "", + args: ["--no-tailscale"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + label: "--no-tailscale-serve writes Serve off with no service to run", + installed: false, + env: "T3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=true\n", + args: ["--no-tailscale-serve"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + // Only the background service reads service.env. + label: "a server started by hand is named, not called off", + prints: "T3 Code is running here outside the background service", + installed: false, + handStarted: true, + env: "", + args: ["--no-tailscale"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + label: "with nothing running, a Serve mapping a stopped server left is removed", + prints: "Removed a Tailscale Serve mapping an earlier run left.", + installed: false, + tailscale: TAGGED_HOST, + serveRecord: `{"n1":{"localPort":3773,"localHost":"127.0.0.1","servePort":443,"label":"n1"}}`, + env: "", + args: ["--no-tailscale"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + label: "a Serve record on another Tailscale account names it and the way out", + prints: "may remain on another Tailscale account: me@work.example on work.example. Switch", + fails: true, + installed: false, + tailscale: TAGGED_HOST, + serveRecord: `{"work.example/2":{"localPort":3773,"localHost":"127.0.0.1","servePort":443,"label":"me@work.example on work.example"}}`, + env: "", + args: ["--no-tailscale"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + // The record names a profile, but Tailscale did not list its profiles to tell whose. + label: "a Serve record whose profile Tailscale cannot list says so", + prints: "Tailscale did not list its accounts", + fails: true, + installed: false, + tailscale: TAGGED_HOST, + serveRecord: `{"prof1":{"localPort":3773,"localHost":"127.0.0.1","servePort":443,"label":"me@example.com on example.com","profileId":"prof1"}}`, + env: "", + args: ["--no-tailscale"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + label: "an unreadable Serve record is reported, not called off", + prints: "could not be read", + fails: true, + installed: false, + serveRecord: "{", + env: "", + args: ["--no-tailscale"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + // The server owns its Serve mapping, so cleanup waits for it to stop. + label: "a server that started after the check keeps its Serve record", + prints: "T3 Code is running here outside the background service", + installed: false, + serverHoldsLock: true, + serveRecord: `{"n1":{"localPort":3773,"localHost":"127.0.0.1","servePort":443,"label":"n1"}}`, + env: "", + args: ["--no-tailscale"], + installs: 0, + restarts: 0, + envAfter: "T3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + // The service may inherit sign-in, so leaving it unchecked writes the off. + label: "leaving Tailscale unchecked pins it off", + installed: true, + tailscale: { ...TAGGED_HOST, selfTags: [], selfUserId: "123" }, + answer: "enter", + env: "T3CODE_LAN=false\n", + args: [], + installs: 0, + restarts: 1, + envAfter: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + // With no saved owner and no terminal, choosing Tailscale would fail with "Pass --owner". + label: "--no-tailscale-serve on a tagged host asks for no owner", + installed: true, + tailscale: TAGGED_HOST, + env: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=true\nT3CODE_TAILSCALE_SERVE=true\n", + args: ["--no-tailscale-serve"], + installs: 0, + restarts: 1, + envAfter: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=true\nT3CODE_TAILSCALE_SERVE=false\n", + }, + { + // Not written as a choice, so status keeps calling it the older install's. + label: "the old default is kept, still marked as the old default", installed: true, env: "", args: ["--yes"], installs: 0, restarts: 0, - envAfter: "T3CODE_LAN=true\n", + envAfter: "", }, ])("restarts the service only when it must: $label", (input) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-up-test-" }); yield* fs.writeFileString(`${baseDir}/service.env`, input.env); + if (input.serveRecord !== undefined) { + yield* fs.makeDirectory(`${baseDir}/userdata`, { recursive: true }); + yield* fs.writeFileString( + `${baseDir}/userdata/tailscale-serve-claim.json`, + input.serveRecord, + ); + vi.spyOn(Tailscale, "releaseTailscaleServe").mockReturnValue(Effect.succeed(true)); + } + if (input.serverHoldsLock === true) { + yield* ServerOwnership.acquireServerOwnership( + `${baseDir}/userdata/server-runtime.json`, + ).pipe(Effect.provide(ProcessRunner.layer)); + } + if (input.handStarted === true) { + yield* fs.makeDirectory(`${baseDir}/userdata`, { recursive: true }); + yield* fs.writeFileString( + `${baseDir}/userdata/server-runtime.json`, + `{"version":1,"pid":${process.pid},"port":3773,"origin":"http://127.0.0.1:3773","startedAt":"2026-10-31T00:00:00.000Z"}`, + ); + } + if (input.tailscale !== undefined) { + vi.spyOn(Tailscale, "readTailscaleStatus").mockReturnValue(Effect.succeed(input.tailscale)); + } let installs = 0; let restarts = 0; const status = { @@ -335,9 +582,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => ), ); if (input.prints !== undefined) { - expect(lines.join("\n")).toContain(input.prints); + const failure = exit._tag === "Failure" ? Cause.pretty(exit.cause) : ""; + expect(`${lines.join("\n")}\n${failure}`).toContain(input.prints); } - expect(exit._tag).toBe(input.restartFails === true ? "Failure" : "Success"); + expect(exit._tag).toBe( + input.restartFails === true || input.fails === true ? "Failure" : "Success", + ); expect({ installs, restarts }).toEqual({ installs: input.installs, restarts: input.restarts, @@ -354,3 +604,33 @@ it("rewrites only the named service.env keys and keeps the user's lines", () => ); expect(upsertServiceEnv("", { T3CODE_LAN: "false" })).toBe("T3CODE_LAN=false\n"); }); + +const RUNNING_SERVICE = { port: 3773, serviceManaged: true }; +const MANUAL = { port: 3773, serviceManaged: false }; + +it.each([ + { configuredPort: undefined, free: true, running: null, expected: "ok" }, + { configuredPort: undefined, free: false, running: RUNNING_SERVICE, expected: "ok" }, + { configuredPort: undefined, free: false, running: null, expected: "taken" }, + { configuredPort: undefined, free: false, running: MANUAL, expected: "manual" }, + // t3 up writes T3CODE_PORT=3773, so a later run still checks the port. + { configuredPort: "3773", free: false, running: null, expected: "taken" }, + { configuredPort: "3773", free: false, running: RUNNING_SERVICE, expected: "ok" }, + { configuredPort: "4000", free: true, running: null, expected: "custom" }, + { configuredPort: "4000", free: true, running: { ...MANUAL, port: 4000 }, expected: "manual" }, +] as const)( + "port check with T3CODE_PORT=$configuredPort, free=$free, running=$running is $expected", + ({ configuredPort, free, running, expected }) => { + expect(upPortCheck({ configuredPort, portFree: free, running })).toBe(expected); + }, +); + +it.effect("a rerun on a tagged host keeps the owner saved before, with no prompt", () => + Effect.gen(function* () { + // Not interactive, as with --yes, so asking would fail with "Pass --owner". + expect(yield* resolveOwner(TAGGED_HOST, Option.none(), "123", false)).toEqual({ + userId: "123", + login: "me@example.com", + }); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index b04b6fea7d331..f87fcca637b21 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -1,15 +1,25 @@ +import { TAILNET_APP_CAPABILITY, tailnetGrantSnippet } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as Config from "effect/Config"; -import * as ConfigProvider from "effect/ConfigProvider"; +import * as NetService from "@t3tools/shared/Net"; +import { + isTailscaleIpv4Address, + readTailscaleStatus, + readTailscaleWhois, + type TailscaleStatus, +} from "@t3tools/tailscale"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Path from "effect/Path"; import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import * as CliError from "effect/cli/CliError"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { REQUIRE_GRANT_FILE } from "../auth/TailnetSignIn.ts"; import * as BootService from "../cloud/bootService.ts"; import * as CliState from "../cloud/CliState.ts"; import { CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINK_REVOKED } from "../cloud/config.ts"; @@ -18,6 +28,11 @@ import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; import * as ServerConfig from "../config.ts"; import * as ManagedLimits from "../policy/managedLimits.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { renderTerminalQrCode } from "../startupAccess.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ServerOwnership from "../serverOwnership.ts"; +import * as TailscaleServe from "../tailscaleServe.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; import { connectSubcommands, @@ -30,9 +45,42 @@ import { } from "./connect.ts"; import { resolveHostCliCommand } from "./invocation.ts"; import * as CliService from "./service.ts"; +import { readFailure, signInBlocker, turnOnRequireGrant } from "./tailscale.ts"; -type Route = "connect" | "lan"; +type Route = "tailscale" | "connect" | "lan"; type Routes = Readonly>; +type RouteFlags = Readonly>>; + +const upError = (reason: string) => new CliError.UserError({ cause: reason, userMessage: reason }); + +// The values the server's Config.Boolean accepts. +const parseBoolean = (value: string | undefined) => + value === undefined + ? undefined + : ["true", "yes", "on", "1", "y"].includes(value) + ? true + : ["false", "no", "off", "0", "n"].includes(value) + ? false + : undefined; + +/** What a running service read from `service.env` when it started. */ +const serviceSettings = (entries: Readonly>, installed: boolean) => ({ + // An installed service with no T3CODE_LAN keeps the old default: on. + lan: parseBoolean(entries.T3CODE_LAN) ?? installed, + tailscale: parseBoolean(entries.T3CODE_TAILSCALE_SIGN_IN) ?? false, + owner: entries.T3CODE_TAILSCALE_OWNER ?? null, + port: entries.T3CODE_PORT ?? String(ServerConfig.DEFAULT_PORT), + tailscaleServe: parseBoolean(entries.T3CODE_TAILSCALE_SERVE) ?? false, +}); + +type ServiceSettings = ReturnType; + +const sameSettings = (a: ServiceSettings, b: ServiceSettings) => + a.lan === b.lan && + a.tailscale === b.tailscale && + a.owner === b.owner && + a.port === b.port && + a.tailscaleServe === b.tailscaleServe; /** How other devices reach this machine now. */ interface Setup { @@ -41,9 +89,10 @@ interface Setup { readonly connect: boolean | null; /** T3 Connect revoked the link; `t3 connect` links again. */ readonly connectRevoked: boolean; - readonly lan: boolean; - /** An installed service with no T3CODE_LAN keeps the old default: on. */ + readonly entries: Readonly>; + readonly settings: ServiceSettings; readonly lanFromOldDefault: boolean; + readonly requireGrant: boolean; readonly host: string | undefined; } @@ -61,13 +110,11 @@ export const connectEnabled = (link: { !link.revoked && (link.cliDesired ? link.cliMode === "managed" : link.managedTunnel); const readSetup = Effect.fn("cli.up.read_setup")(function* () { - const { baseDir } = yield* ServerConfig.ServerConfig; + const { baseDir, stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const service = yield* (yield* BootService.BootService).status; const { entries } = yield* CliService.readServiceEnv(baseDir); - // Parsed as the server parses it. - const lan = yield* Config.Boolean("T3CODE_LAN") - .pipe(Config.option) - .parse(ConfigProvider.fromEnv({ env: entries })); const secrets = yield* ServerSecretStore.ServerSecretStore; const connectRevoked = hasCloudPublicConfig && Option.isSome(yield* secrets.get(CLOUD_LINK_REVOKED)); @@ -83,48 +130,67 @@ const readSetup = Effect.fn("cli.up.read_setup")(function* () { service, connect, connectRevoked, - lan: Option.getOrElse(lan, () => service.installed), - lanFromOldDefault: Option.isNone(lan) && service.installed, + entries, + settings: serviceSettings(entries, service.installed), + lanFromOldDefault: entries.T3CODE_LAN === undefined && service.installed, + requireGrant: yield* fs.exists(path.join(stateDir, REQUIRE_GRANT_FILE)), host: entries.T3CODE_HOST, } satisfies Setup; }); /** * The routes `t3 up` starts from. With no route flag it asks about every - * route, starting a machine with no service from T3 Connect. Any route flag - * means it asks nothing and keeps the routes not named as they are. + * route it can offer, starting a machine with no service from Tailscale when + * it runs here, else T3 Connect. Any route flag means it asks nothing and + * keeps the routes not named as they are. */ export const planRoutes = ( - setup: { readonly installed: boolean; readonly connect: boolean | null; readonly lan: boolean }, - flags: { readonly connect: Option.Option; readonly lan: Option.Option }, + setup: { + readonly installed: boolean; + readonly current: Routes; + /** False where this machine cannot offer the route. */ + readonly available: Readonly>; + }, + flags: RouteFlags & { + /** Another setup flag was given, such as `--no-tailscale-serve`: nothing is asked. */ + readonly other?: boolean; + }, ) => { - const ask = Option.isNone(flags.connect) && Option.isNone(flags.lan); - const start = { - connect: setup.connect === true || (ask && !setup.installed && setup.connect !== null), - lan: setup.lan, - }; + const ask = [flags.tailscale, flags.connect, flags.lan].every(Option.isNone) && !flags.other; + const start: Routes = + ask && !setup.installed + ? { + tailscale: setup.available.tailscale, + connect: setup.available.connect && !setup.available.tailscale, + lan: false, + } + : setup.current; return { routes: { + tailscale: Option.getOrElse(flags.tailscale, () => start.tailscale), connect: Option.getOrElse(flags.connect, () => start.connect), lan: Option.getOrElse(flags.lan, () => start.lan), } satisfies Routes, ask: ask - ? (["connect", "lan"] as const).filter((r) => r !== "connect" || setup.connect !== null) + ? (["tailscale", "connect", "lan"] as const).filter( + // A route that is on can always be turned off. + (route) => route === "lan" || setup.available[route] || setup.current[route], + ) : [], }; }; -/** A running service reads T3CODE_LAN and its T3 Connect link only when it starts. */ -const needsRestart = (before: Routes, after: Routes) => - after.lan !== before.lan || (after.connect && !before.connect); - const CHOICES = { + tailscale: { + title: "Tailscale", + description: "Devices your policy grants, else your untagged devices. No pairing code.", + }, connect: { title: "T3 Connect", description: "Creates a tunnel to control your PC." }, lan: { title: "Local network", description: "LAN and tailnet. Pairing code required." }, } satisfies Record; const formatSetup = (setup: Setup) => { - const { service } = setup; + const { service, settings } = setup; const rows: ReadonlyArray = [ [ "Service", @@ -134,6 +200,14 @@ const formatSetup = (setup: Setup) => { ? `installed · t3@${service.installedVersion ?? packageJson.version}` : "not installed", ], + [ + "Tailscale", + !settings.tailscale + ? "off" + : setup.requireGrant + ? "on · policy grants required" + : "on · policy grants, else your untagged devices", + ], ...(setup.connect === null ? [] : [ @@ -152,17 +226,22 @@ const formatSetup = (setup: Setup) => { ? `overridden · T3CODE_HOST=${setup.host}` : setup.lanFromOldDefault ? "on · kept from an older install" - : setup.lan + : settings.lan ? "on · pairing code only" : "off", ], ]; const notes = [ - ...(setup.host === undefined && setup.lan - ? ["Anyone on your LAN or tailnet can reach the port. A pairing code is the only lock."] + ...(setup.host === undefined && settings.lan + ? [ + settings.tailscale + ? "Anyone on your LAN or tailnet can reach the port. A LAN device needs a pairing code." + : "Anyone on your LAN or tailnet can reach the port. A pairing code is the only lock.", + "Turn it off: `t3 up --no-lan`.", + ] : []), - ...(setup.lanFromOldDefault - ? ["Older installs keep LAN access on. Turn it off: `t3 up --no-lan`."] + ...(settings.tailscale && setup.requireGrant + ? ["No device can sign in through Tailscale until your tailnet policy grants it."] : []), ]; return [ @@ -177,7 +256,7 @@ const formatNextSteps = (routes: Routes, serviceInstalled: boolean) => ? ["On your other device, sign in to T3 Connect with the same account."] : []), ...(routes.lan ? ["Run `t3 pair`. Use the code on your other device."] : []), - ...(routes.connect || routes.lan + ...(routes.connect || routes.lan || routes.tailscale ? [] : [ serviceInstalled @@ -186,36 +265,312 @@ const formatNextSteps = (routes: Routes, serviceInstalled: boolean) => ]), ].join("\n"); +// Serve only runs with Tailscale on in `t3 up`, so turning Tailscale off stops it too. +// Written as false, not removed, so a value the service inherits cannot turn them back on. +const TAILSCALE_OFF = { T3CODE_TAILSCALE_SIGN_IN: "false", T3CODE_TAILSCALE_SERVE: "false" }; + const interactiveTerminal = () => Boolean(process.stdin.isTTY && process.stdout.isTTY); -const setUp = Effect.fn("cli.up")(function* (flags: { - readonly baseDir: Option.Option; - readonly connect: Option.Option; - readonly lan: Option.Option; - readonly yes: boolean; - readonly headless: boolean; -}) { - const { baseDir } = yield* ServerConfig.ServerConfig; +interface Owner { + readonly userId: string; + readonly login: string | null; +} + +const loginOf = (status: TailscaleStatus, userId: string | null) => + status.users.find((user) => user.userId === userId)?.loginName ?? null; + +const ownerForLogin = (status: TailscaleStatus, login: string) => { + const wanted = login.trim().toLowerCase(); + const user = status.users.find((candidate) => candidate.loginName?.toLowerCase() === wanted); + return user === undefined + ? Effect.fail( + upError( + `No Tailscale user with the login ${login.trim()} is visible from this host. Check the spelling, or sign in to Tailscale on one of that user's devices first.`, + ), + ) + : Effect.succeed({ userId: user.userId, login: user.loginName } satisfies Owner); +}; + +/** The SSH client's user, when it connects over the tailnet from one of its own devices. */ +const proposeSshOwner = Effect.fn("cli.up.proposeSshOwner")(function* () { + const env = yield* HostProcess.Environment; + const clientAddress = env.SSH_CONNECTION?.trim().split(/\s+/u)[0]; + if (clientAddress === undefined || !isTailscaleIpv4Address(clientAddress)) { + return Option.none(); + } + const node = yield* readTailscaleWhois(clientAddress).pipe(Effect.option); + return Option.filter( + node, + (value) => value.tags.length === 0 && value.sharerUserId === null, + ).pipe(Option.map((value): Owner => ({ userId: value.userId, login: value.loginName }))); +}); + +export const resolveOwner = Effect.fn("cli.up.resolveOwner")(function* ( + status: TailscaleStatus, + ownerLogin: Option.Option, + /** The owner a previous `t3 up` saved, kept unless `--owner` names another. */ + savedUserId: string | undefined, + /** False with `--yes` or no terminal: fail with the flag to pass instead. */ + interactive: boolean, +) { + if (status.selfTags.length === 0) { + if (status.selfUserId === null) { + return yield* upError("Tailscale did not report this host's user."); + } + const own: Owner = { userId: status.selfUserId, login: loginOf(status, status.selfUserId) }; + if (Option.isSome(ownerLogin)) { + const requested = yield* ownerForLogin(status, ownerLogin.value); + if (requested.userId !== own.userId) { + return yield* upError( + `This host is not tagged, so its owner is its own Tailscale user (${own.login ?? own.userId}). Drop --owner, or tag the host first.`, + ); + } + } + return own; + } + if (Option.isSome(ownerLogin)) return yield* ownerForLogin(status, ownerLogin.value); + if (savedUserId !== undefined) + return { userId: savedUserId, login: loginOf(status, savedUserId) }; + if (!interactive) { + return yield* upError( + "This machine is tagged. Set its Tailscale owner with `--owner `, or use `--require-grant`.", + ); + } + const proposed = yield* proposeSshOwner(); + if (Option.isSome(proposed)) { + const accepted = yield* Prompt.run( + Prompt.Confirm({ + message: `Use ${proposed.value.login ?? proposed.value.userId} as this machine's Tailscale owner?`, + initial: true, + }), + ); + if (accepted) return proposed.value; + } + const login = yield* Prompt.run(Prompt.String({ message: "Tailscale owner login:" })); + return yield* ownerForLogin(status, login); +}); + +/** Tailscale's state here, or why Tailscale sign-in cannot work on this machine. */ +const checkTailscale = Effect.fn("cli.up.checkTailscale")(function* () { + const platform = yield* HostProcess.Platform; + const read = yield* Effect.result(readTailscaleStatus("active")); + if (Result.isFailure(read)) { + return { ok: false, reason: readFailure(read.failure, platform) } as const; + } + const value = read.success; + const blocker = signInBlocker(value, platform); + // signInBlocker already refuses a host with no IPv4 address. + const address = value.tailnetIpv4Addresses[0]; + if (blocker !== null || address === undefined) { + return { ok: false, reason: blocker ?? "Tailscale reported no IPv4 address." } as const; + } + return { ok: true, status: value, address } as const; +}); + +const indent = (text: string) => + text + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + +/** + * Discovery probes only the default port, so `t3 up` pins it rather than let + * the server fall back to the next free one. A port the user set is kept. + */ +export const upPortCheck = (input: { + readonly configuredPort: string | undefined; + /** Whether the port the service will use, configured or default, is free. */ + readonly portFree: boolean; + /** The live server this T3 home already runs, if any. */ + readonly running: { readonly port: number; readonly serviceManaged: boolean } | null; +}): "ok" | "taken" | "custom" | "manual" => { + // The service cannot take this home over from a server started by hand. + if (input.running !== null && !input.running.serviceManaged) return "manual"; + const port = + input.configuredPort === undefined ? ServerConfig.DEFAULT_PORT : Number(input.configuredPort); + if (!input.portFree && input.running?.port !== port) return "taken"; + return port === ServerConfig.DEFAULT_PORT ? "ok" : "custom"; +}; + +/** The `service.env` entries Tailscale sign-in needs, after checking the port and owner. */ +const tailscaleEntries = Effect.fn("cli.up.tailscale_entries")(function* ( + tailscale: { readonly status: TailscaleStatus }, + flags: { + readonly owner: Option.Option; + readonly tailscaleServe: Option.Option; + readonly yes: boolean; + }, + requireGrant: boolean, +) { + const config = yield* ServerConfig.ServerConfig; + const { entries, path: serviceEnvPath } = yield* CliService.readServiceEnv(config.baseDir); + const tagged = tailscale.status.selfTags.length > 0; + // With grants required, the policy names who gets in, so the box needs no owner. + // A requested owner is saved even when grants decide who gets in. + const owner = + requireGrant && Option.isNone(flags.owner) + ? null + : yield* resolveOwner( + tailscale.status, + flags.owner, + entries.T3CODE_TAILSCALE_OWNER, + !flags.yes && interactiveTerminal(), + ); + const configuredPort = entries.T3CODE_PORT; + const port = Number(configuredPort ?? ServerConfig.DEFAULT_PORT); + const net = yield* NetService.NetService; + const runtime = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + const running = Option.filter(runtime, (state) => isProcessAlive(state.pid)); + const portCheck = upPortCheck({ + configuredPort, + portFree: yield* net.canListenOnHost(port, "0.0.0.0"), + running: Option.match(running, { + onNone: () => null, + onSome: (state) => ({ port: state.port, serviceManaged: state.serviceManaged === true }), + }), + }); + if (portCheck === "manual") { + return yield* upError( + `T3 Code is already running outside the service for ${config.baseDir}. Stop it, then run \`t3 up\`.`, + ); + } + if (portCheck === "taken") { + return yield* upError( + `Port ${port} is in use. Free it, or set T3CODE_PORT in ${serviceEnvPath} and add this machine by name and port.`, + ); + } + if (portCheck === "custom") { + yield* Console.warn( + `Custom port ${configuredPort} from ${serviceEnvPath}. Other devices find machines only on port ${ServerConfig.DEFAULT_PORT}, so add this one by name and port.`, + ); + } + const saved = entries.T3CODE_TAILSCALE_OWNER; + return { + // A saved owner still names the box in the printed grant when grants are required. + owner: + owner ?? + (saved === undefined || !tagged + ? null + : { userId: saved, login: loginOf(tailscale.status, saved) }), + port, + entries: { + T3CODE_TAILSCALE_SIGN_IN: "true", + T3CODE_PORT: configuredPort ?? String(ServerConfig.DEFAULT_PORT), + // The user ID, never the login: logins can be renamed. + ...(owner === null ? {} : { T3CODE_TAILSCALE_OWNER: tagged ? owner.userId : undefined }), + ...Option.match(flags.tailscaleServe, { + onNone: () => ({}), + onSome: (on) => ({ T3CODE_TAILSCALE_SERVE: on ? "true" : "false" }), + }), + }, + }; +}); + +const formatTailscale = ( + tailscale: { readonly status: TailscaleStatus; readonly address: string }, + owner: Owner | null, + port: number, + requireGrant: boolean, +) => { + const { status, address } = tailscale; + const tagged = status.selfTags.length > 0; + const directUrl = `http://${address}:${port}`; + const dst = status.selfTags[0] ?? address; + // The owner, or the host's own user on an untagged box. A tagged box's user is the + // shared "tagged-devices" user, which no policy can name, so it gets a placeholder. + const src = + owner?.login ?? + (tagged || status.selfUserId === null ? null : loginOf(status, status.selfUserId)) ?? + "you@example.com"; + return [ + `Tailscale: ${status.magicDnsName ?? address}`, + ...(owner === null ? [] : [`Owner: ${owner.login ?? owner.userId}`]), + `Add this machine: ${directUrl}`, + // A QR code is noise in a log. + ...(process.stdout.isTTY ? [renderTerminalQrCode(directUrl)] : []), + // Other devices look for machines only on the default port. + ...(port === ServerConfig.DEFAULT_PORT + ? [ + // Discovery shows a tagged host to everyone, an untagged one only to its user. + tagged + ? "Desktops on this tailnet find this machine automatically; a phone adds it by the address above." + : "Your own desktops on this tailnet find this machine automatically; a phone or another user's device adds it by the address above.", + ] + : []), + "", + ...(requireGrant + ? [ + "Add this grant to your tailnet policy:", + indent(tailnetGrantSnippet(src, dst, port)), + // A tagged host with no owner lets in only granted devices either way. + ...(tagged && owner === null + ? [] + : [ + "Let your untagged devices in without a grant while your policy grants no device here: `t3 tailscale require-grant off`.", + ]), + ] + : [ + "While your tailnet policy grants no device here, your untagged devices can sign in, agents on them included.", + "Require policy grants: `t3 tailscale require-grant on`.", + ]), + ].join("\n"); +}; + +const setUp = Effect.fn("cli.up")(function* ( + flags: RouteFlags & { + readonly baseDir: Option.Option; + readonly yes: boolean; + readonly headless: boolean; + readonly owner: Option.Option; + readonly requireGrant: boolean; + readonly tailscaleServe: Option.Option; + }, +) { + // The service runs under `systemctl --user`, so root would set up a separate root install. + if ((yield* HostProcess.UserId) === 0) { + const env = yield* HostProcess.Environment; + return yield* upError( + `Run t3 up as the user who should own T3 Code, not as root:\n sudo -iu ${env.SUDO_USER ?? ""} npx t3 up`, + ); + } + const config = yield* ServerConfig.ServerConfig; const service = yield* BootService.BootService; const before = yield* readSetup(); if (before.connect === null && Option.getOrElse(flags.connect, () => false)) { - return yield* new CliError.UserError({ - cause: "connect", - userMessage: "T3 Connect is unavailable in this build.", - }); + return yield* upError("T3 Connect is unavailable in this build."); } - - const current = { connect: before.connect === true, lan: before.lan }; + const current: Routes = { + tailscale: before.settings.tailscale, + connect: before.connect === true, + lan: before.settings.lan, + }; // A publish-only link is not "on", but a rollback must put it back. const priorPublishOnly = hasCloudPublicConfig && (yield* CliState.readCliDesiredCloudLink) && (yield* CliState.readCliDesiredLinkMode) === "publish_only"; - const plan = planRoutes({ ...before, installed: before.service.installed }, flags); + const other = Option.isSome(flags.tailscaleServe); + const asks = [flags.tailscale, flags.connect, flags.lan].every(Option.isNone) && !other; + const tailscale = + asks || Option.getOrElse(flags.tailscale, () => current.tailscale) + ? yield* checkTailscale() + : ({ ok: false, reason: "Tailscale was not checked." } as const); + const plan = planRoutes( + { + installed: before.service.installed, + current, + available: { tailscale: tailscale.ok, connect: before.connect !== null }, + }, + { ...flags, other }, + ); const interactive = !flags.yes && interactiveTerminal(); let routes = plan.routes; + // An older install's LAN access stays marked as such until someone chooses it. + let lanChosen = Option.isSome(flags.lan) || !before.lanFromOldDefault; if (plan.ask.length > 0 && interactive) { + lanChosen = true; if (before.service.installed) yield* Console.log(`Current setup\n${formatSetup(before)}\n`); + if (!tailscale.ok) yield* Console.log(`${tailscale.reason}\n`); const picked = yield* Prompt.run( Prompt.MultiSelect({ message: "Allow connections through:", @@ -224,49 +579,175 @@ const setUp = Effect.fn("cli.up")(function* (flags: { inverseSelection: "Invert selection", choices: plan.ask.map((route) => ({ ...CHOICES[route], + ...(route === "tailscale" && before.requireGrant + ? { description: "Devices your policy grants. No pairing code." } + : {}), value: route, selected: plan.routes[route], })), }), ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(null) })); if (picked === null) return yield* Console.log("Nothing changed."); - routes = { connect: picked.includes("connect"), lan: picked.includes("lan") }; + routes = { + tailscale: picked.includes("tailscale"), + connect: picked.includes("connect"), + lan: picked.includes("lan"), + }; } else if (plan.ask.length > 0 && !flags.yes && !before.service.installed) { - return yield* new CliError.UserError({ - cause: "routes", - userMessage: `No terminal. Choose access with ${ - before.connect === null ? "--lan" : "--connect or --lan" - }, or use --yes for the default. See \`t3 up --help\`.`, - }); + const offered = [ + ...(tailscale.ok ? ["--tailscale"] : []), + ...(before.connect === null ? [] : ["--connect"]), + "--lan", + ]; + return yield* upError( + `No terminal. Choose access with ${offered.join(", ")}, or use --yes for the default. See \`t3 up --help\`.`, + ); } if (routes.connect && !current.connect) yield* requireConnectAllowed; - if (routes.lan && !current.lan) { + if ((routes.lan && !current.lan) || (routes.tailscale && !current.tailscale)) { const policy = yield* ManagedPolicy.ManagedPolicy; if (!ManagedLimits.allowsRemoteClients(policy)) { return yield* ManagedLimits.refuseRemoteClients(policy); } } + // An explicit off is written even when the file says nothing, since the service may + // inherit the setting, and a restart applies it. Unchecking Tailscale is one too. + const tailscaleOffChosen = + Option.getOrUndefined(flags.tailscale) === false || + (interactive && plan.ask.includes("tailscale") && !routes.tailscale); + const serveOffChosen = Option.getOrUndefined(flags.tailscaleServe) === false; // Nothing to run in the background, but a route left on still gets turned off. - if (!before.service.installed && !routes.connect && !routes.lan) { + if (!before.service.installed && !routes.connect && !routes.lan && !routes.tailscale) { if (current.connect || before.connectRevoked) { yield* disconnectCloud({ clearAuthorization: false }); } - if (current.lan) yield* CliService.writeServiceEnv(baseDir, { T3CODE_LAN: "false" }); + yield* CliService.writeServiceEnv(config.baseDir, { + ...(current.lan ? { T3CODE_LAN: "false" } : {}), + ...(current.tailscale || tailscaleOffChosen ? TAILSCALE_OFF : {}), + ...(serveOffChosen ? { T3CODE_TAILSCALE_SERVE: "false" } : {}), + }); + // service.env is read only by the background service, so a server started by hand + // keeps the access it started with. + const runtime = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + const handStarted = Option.exists( + runtime, + (state) => state.serviceManaged !== true && isProcessAlive(state.pid), + ); + const runningElsewhere = + "T3 Code is running here outside the background service, with the access it started with. In the desktop app, turn access off in Settings → Sharing & access. Otherwise, stop the server."; + if (handStarted) return yield* Console.log(runningElsewhere); + // Nothing runs here to remove a Serve mapping a stopped server left, so do it now, + // holding the T3 home's lock so no server starts meanwhile. + if (yield* TailscaleServe.hasRecord(config.stateDir)) { + const record = yield* TailscaleServe.recordPath(config.stateDir); + const orDelete = `or delete ${record} if you no longer use Tailscale here.`; + const leftover = yield* ServerOwnership.acquireServerOwnership( + config.serverRuntimeStatePath, + ).pipe( + Effect.provide(ProcessRunner.layer), + Effect.andThen(TailscaleServe.releaseRecorded(config.stateDir)), + Effect.scoped, + Effect.catchTags({ ServerAlreadyRunningError: () => Effect.succeed(null) }), + Effect.catch((error) => { + switch (error._tag) { + case "ServeRecordError": + return upError( + error.reason === "unreadable" + ? error.message + : error.reason === "switched" + ? `${error.message} Run this again.` + : error.reason === "unlisted" + ? `${error.message} Run this again, ${orDelete}` + : `${error.message} Then run this again, ${orDelete}`, + ); + case "PlatformError": + return upError(`Could not update the Tailscale Serve record: ${error.message}`); + case "ServerOwnershipError": + case "OlderBootServiceError": + return upError(error.message); + default: + return upError( + `Tailscale did not answer, so a Serve mapping an earlier run left may remain. Run this again once Tailscale runs, ${orDelete}`, + ); + } + }), + ); + if (leftover === null) return yield* Console.log(runningElsewhere); + if (leftover.released) { + yield* Console.log("Removed a Tailscale Serve mapping an earlier run left."); + } + if (leftover.otherAccounts.length > 0) { + yield* Console.log(formatNextSteps(routes, false)); + // A Windows sign-in again makes a new profile, so the "other" account can be this one. + if (leftover.otherAccounts.every((label) => label === leftover.currentLabel)) { + const windows = yield* HostProcess.isWindows; + return yield* upError( + `A Tailscale Serve mapping may remain from an earlier sign-in to ${leftover.currentLabel}${windows ? ", or from another Windows user signed in to it" : ""}; Tailscale keeps each sign-in's Serve settings apart. Delete ${record} if that sign-in is gone.`, + ); + } + const one = leftover.otherAccounts.length === 1; + return yield* upError( + `A Tailscale Serve mapping an earlier run left may remain on ${one ? "another Tailscale account" : "other Tailscale accounts"}: ${leftover.otherAccounts.join(", ")}. Switch Tailscale to ${one ? "it" : "each"} and run this again, or delete ${record} if ${one ? "that account is" : "they are"} gone.`, + ); + } + } return yield* Console.log(formatNextSteps(routes, false)); } + // Tailscale left on while it is down here keeps its settings; turning it on needs it. + const tailscaleKept = routes.tailscale && current.tailscale && Option.isNone(flags.tailscale); + if (routes.tailscale && !tailscale.ok && !tailscaleKept) { + return yield* upError(`${tailscale.reason} Then run t3 up again.`); + } + if (tailscaleKept && !tailscale.ok && !(plan.ask.length > 0 && interactive)) { + yield* Console.log( + `${tailscale.reason} ${serveOffChosen ? "Serve turns off. Other Tailscale settings" : "Tailscale settings"} stay as they are.`, + ); + } + + const requireGrant = flags.requireGrant || before.requireGrant; + // Its settings, owner included, are looked up again only when Tailscale is turned on or + // chosen, so a run that changes something else never asks for an owner. + const tailscaleChosen = + !current.tailscale || Option.isSome(flags.tailscale) || (plan.ask.length > 0 && interactive); + const signIn = + routes.tailscale && tailscale.ok && tailscaleChosen + ? yield* tailscaleEntries(tailscale, flags, requireGrant) + : null; + const pinsOff = + (tailscaleOffChosen && + (before.entries.T3CODE_TAILSCALE_SIGN_IN !== "false" || + before.entries.T3CODE_TAILSCALE_SERVE !== "false")) || + (serveOffChosen && before.entries.T3CODE_TAILSCALE_SERVE !== "false"); + const writes: Readonly> = { + ...(lanChosen ? { T3CODE_LAN: String(routes.lan) } : {}), + ...(routes.tailscale + ? (signIn?.entries ?? {}) + : current.tailscale || tailscaleOffChosen + ? TAILSCALE_OFF + : {}), + ...(serveOffChosen ? { T3CODE_TAILSCALE_SERVE: "false" } : {}), + }; + const planned = Object.fromEntries( + Object.entries({ ...before.entries, ...writes }).flatMap(([key, value]) => + value === undefined ? [] : [[key, value] as const], + ), + ); const { installedVersion } = before.service; const newer = installedVersion !== undefined && compareExactServiceVersions(installedVersion, packageJson.version) > 0; - // Asked before anything changes, so a declined restart leaves service.env - // matching what the running service read when it started. + // A running service reads these settings only at start. Asked before anything + // changes, so a declined restart leaves service.env matching what it runs. // A stopped service of this version is not current, so reconcile starts it; // a newer one is left alone and needs this restart to start. const stopped = before.service.problems?.includes("service-stopped") === true; + const changed = + !sameSettings(before.settings, serviceSettings(planned, true)) || + (routes.connect && !current.connect); const restart = before.service.installed && (before.service.current || newer) && - (needsRestart(current, routes) || stopped); + (changed || pinsOff || stopped); // An older or broken service is reinstalled, which restarts it too. const update = before.service.installed && !before.service.current && !newer && !stopped; if ((restart || update) && interactive && !stopped) { @@ -274,13 +755,19 @@ const setUp = Effect.fn("cli.up")(function* (flags: { Prompt.Confirm({ message: update ? "Update and restart T3 Code? Running agent turns will stop." - : "Apply changes and restart? Running agent turns will stop.", + : changed + ? "Apply changes and restart? Running agent turns will stop." + : `Write Tailscale ${tailscaleOffChosen ? "" : "Serve "}off, so the service cannot inherit it, and restart? Running agent turns will stop.`, initial: true, }), ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) })); if (!confirmed) return yield* Console.log("Nothing changed."); } + // Once on, it stays on: rerunning without the flag must never widen access. + if (routes.tailscale && flags.requireGrant) { + yield* turnOnRequireGrant(config.stateDir, "turned on with t3 up --require-grant"); + } if (routes.connect && !current.connect) { const linked = yield* linkEnvironmentForConnect({ headless: flags.headless, @@ -296,15 +783,17 @@ const setUp = Effect.fn("cli.up")(function* (flags: { !routes.connect && (current.connect || before.connectRevoked) ? (yield* disconnectCloud({ clearAuthorization: false })).status === "failed" : false; - const previousLan = (yield* CliService.readServiceEnv(baseDir)).entries.T3CODE_LAN; - // Also with no service manager: the file records the choice that `t3 status` shows. - yield* CliService.writeServiceEnv(baseDir, { T3CODE_LAN: String(routes.lan) }); - // When the service step fails, an installed service still runs with the old - // value, so the file goes back to match it. - // A new T3 Connect link is undone too, with any tunnel the failed start saved, so a - // rerun sees it as a change and restarts. The sign-in stays. + // Settings land before the service starts, so it never runs with sign-in half set up. + // Also with no service manager: the file records the choices that `t3 status` shows. + yield* CliService.writeServiceEnv(config.baseDir, writes); + // When the service step fails, an installed service still runs with the old values, + // so the file goes back to match it. A new T3 Connect link is undone too, with any + // tunnel the failed start saved, so a rerun sees it as a change. The sign-in stays. const restoreServiceEnv = before.service.installed - ? CliService.writeServiceEnv(baseDir, { T3CODE_LAN: previousLan }).pipe( + ? CliService.writeServiceEnv( + config.baseDir, + Object.fromEntries(Object.keys(writes).map((key) => [key, before.entries[key]])), + ).pipe( Effect.andThen( !routes.connect || current.connect ? Effect.void @@ -321,18 +810,27 @@ const setUp = Effect.fn("cli.up")(function* (flags: { : Effect.void; if (before.host !== undefined) { yield* Console.warn( - `T3CODE_HOST=${before.host} in service.env overrides the network choice. Remove it to apply it.`, + `T3CODE_HOST=${before.host} in service.env overrides the network choices. Remove it to apply them.`, ); } const printServeCommand = Effect.fn(function* (reason: string) { + // The same settings the service would read from service.env. const { command } = yield* resolveHostCliCommand("serve", [ ...Option.match(flags.baseDir, { onNone: () => [], onSome: (dir) => ["--base-dir", dir] }), - // Explicit either way, so a T3CODE_LAN in the shell cannot override the choice. + // Explicit either way, so settings in the shell cannot override the choices. routes.lan ? "--lan" : "--no-lan", + routes.tailscale ? "--tailscale-sign-in" : "--no-tailscale-sign-in", + ...(planned.T3CODE_PORT === undefined ? [] : ["--port", planned.T3CODE_PORT]), + ...(routes.tailscale && planned.T3CODE_TAILSCALE_OWNER !== undefined + ? ["--tailscale-owner", planned.T3CODE_TAILSCALE_OWNER] + : []), + routes.tailscale && parseBoolean(planned.T3CODE_TAILSCALE_SERVE) === true + ? "--tailscale-serve" + : "--no-tailscale-serve", ]); yield* Console.log(`${reason} Keep this command running:\n ${command}`); - // A host wins over --lan and --no-lan, as it does in service.env. + // A host wins over these flags, as it does in service.env. const shellHost = (yield* HostProcess.Environment).T3CODE_HOST; if (shellHost !== undefined) { yield* Console.warn( @@ -362,8 +860,9 @@ const setUp = Effect.fn("cli.up")(function* (flags: { if ("failed" in result && before.service.installed) { // The old service still runs, so a foreground server would clash with it. yield* restoreServiceEnv; - yield* Console.log( - `Service setup failed: ${result.failed} Fix that, then run \`t3 up\` again.`, + // A failure, so a script sees that the running service kept its old access. + return yield* upError( + `Service setup failed: ${result.failed} The service kept its old settings. Fix that, then run \`t3 up\` again.`, ); } else if ("failed" in result) { yield* printServeCommand(`Service setup failed: ${result.failed}`); @@ -403,32 +902,58 @@ const setUp = Effect.fn("cli.up")(function* (flags: { if (started && routes.connect && !current.connect) yield* waitForReachability(); const after = yield* readSetup(); - yield* Console.log( - // From what took effect: a failed service step can undo a choice. - `\nThis machine\n${formatSetup(after)}\n\n${formatNextSteps( - { connect: after.connect === true, lan: after.lan }, - after.service.installed, - )}`, + yield* Console.log(`\nThis machine\n${formatSetup(after)}\n`); + if (signIn !== null && tailscale.ok && after.settings.tailscale) { + yield* Console.log(formatTailscale(tailscale, signIn.owner, signIn.port, requireGrant)); + } + // From what took effect: a failed service step can undo a choice. + const nextSteps = formatNextSteps( + { + tailscale: after.settings.tailscale, + connect: after.connect === true, + lan: after.settings.lan, + }, + after.service.installed, ); + if (nextSteps !== "") yield* Console.log(nextSteps); }); +const routeFlag = (name: Route, description: string) => + Flag.Boolean(name).pipe(Flag.withDescription(description), Flag.optional); + export const upCommand = Command.make("up", { ...projectLocationFlags, - connect: Flag.Boolean("connect").pipe( + tailscale: routeFlag( + "tailscale", + "Allow Tailscale sign-in with no pairing code. Policy grants decide access; with none, your untagged devices can sign in. Turn off with --no-tailscale.", + ), + connect: routeFlag( + "connect", + "Allow access through T3 Connect with your T3 account. Turn off with --no-connect.", + ), + lan: routeFlag( + "lan", + "Allow LAN and tailnet connections. Anyone there can reach the port; a pairing code is the only lock, apart from Tailscale sign-in for tailnet devices. Turn off with --no-lan.", + ), + owner: Flag.String("owner").pipe( + Flag.withDescription("Tailscale owner login for a tagged machine. Implies --tailscale."), + Flag.optional, + ), + requireGrant: Flag.Boolean("require-grant").pipe( Flag.withDescription( - "Allow access through T3 Connect with your T3 account. Turn off with --no-connect.", + `Require a ${TAILNET_APP_CAPABILITY} policy grant for Tailscale sign-in. Saved for later runs. Implies --tailscale.`, ), - Flag.optional, + Flag.withDefault(false), ), - lan: Flag.Boolean("lan").pipe( + tailscaleServe: Flag.Boolean("tailscale-serve").pipe( Flag.withDescription( - "Allow LAN and tailnet connections. Anyone there can reach the port; a pairing code is the only lock. Turn off with --no-lan.", + "Serve HTTPS through Tailscale Serve. Implies --tailscale. --no-tailscale-serve turns it off and leaves sign-in as it is.", ), Flag.optional, ), yes: Flag.Boolean("yes").pipe( Flag.withDescription( - "Ask nothing. Keep the access settings; a new machine gets T3 Connect. A restart stops running agent turns.", + "Ask nothing. Keep the access settings; a new machine gets Tailscale if it runs here, else T3 Connect. A restart stops running agent turns.", ), Flag.withDefault(false), ), @@ -440,13 +965,40 @@ export const upCommand = Command.make("up", { ), Command.withExamples([ { command: "t3 up", description: "Choose remote access." }, + { command: "t3 up --tailscale", description: "Turn on Tailscale. Keep other settings." }, { command: "t3 up --connect", description: "Turn on T3 Connect. Keep other settings." }, { command: "t3 up --lan", description: "Allow LAN access with pairing codes." }, { command: "t3 up --no-lan", description: "Stop LAN access." }, + { + command: "t3 up --tailscale --no-connect --no-lan --require-grant", + description: "Require a grant for Tailscale sign-in. Pairing codes still work.", + }, + { + command: "t3 up --tailscale --owner you@example.com", + description: "Set the owner of a tagged machine.", + }, { command: "t3 up --yes", description: "Take the defaults with no questions." }, { command: "t3 status --json", description: "Read the settings from a script." }, ]), - Command.withHandler((flags) => runCloudCommand(flags, setUp(flags), { quietLogs: true })), + Command.withHandler((flags) => { + const tailscaleOnly = [ + ...(Option.isSome(flags.owner) ? ["--owner"] : []), + ...(flags.requireGrant ? ["--require-grant"] : []), + ...(Option.getOrUndefined(flags.tailscaleServe) === true ? ["--tailscale-serve"] : []), + ]; + if (Option.getOrUndefined(flags.tailscale) === false && tailscaleOnly.length > 0) { + return Effect.fail(upError(`--no-tailscale conflicts with ${tailscaleOnly.join(", ")}.`)); + } + return runCloudCommand( + flags, + setUp({ + ...flags, + // The Tailscale settings only mean something with Tailscale on. + tailscale: tailscaleOnly.length > 0 ? Option.some(true) : flags.tailscale, + }), + { quietLogs: true }, + ); + }), ); export const connectCommand = Command.make("connect", { @@ -457,7 +1009,16 @@ export const connectCommand = Command.make("connect", { Command.withHandler((flags) => runCloudCommand( flags, - setUp({ ...flags, connect: Option.some(true), lan: Option.none(), yes: false }), + setUp({ + ...flags, + connect: Option.some(true), + tailscale: Option.none(), + lan: Option.none(), + yes: false, + owner: Option.none(), + requireGrant: false, + tailscaleServe: Option.none(), + }), { quietLogs: true }, ), ), @@ -473,10 +1034,11 @@ const setupJson = (setup: Setup) => installed: setup.service.installed, installedVersion: setup.service.installedVersion ?? null, }, + tailscale: { enabled: setup.settings.tailscale, requireGrant: setup.requireGrant }, connect: setup.connect === null ? null : { enabled: setup.connect, revoked: setup.connectRevoked }, lan: { - enabled: setup.lan, + enabled: setup.settings.lan, inheritedDefault: setup.lanFromOldDefault, hostOverride: setup.host ?? null, }, @@ -513,7 +1075,7 @@ export const statusCommand = Command.make("status", { yield* Console.log( flags.json ? setupJson(setup) - : `This machine\n${formatSetup(setup)}\n\nChange access: \`t3 up\`.`, + : `This machine\n${formatSetup(setup)}\n\nChange access: \`t3 up\`. Tailscale details: \`t3 tailscale status\`.`, ); }), ), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 468cc89ad01e6..56a7b4a9e93f0 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -106,6 +106,12 @@ export class ServerConfig extends Context.Service< readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + /** Tailscale devices may sign in with no pairing code: by grant, else the owner's own devices. */ + readonly tailscaleSignInEnabled?: boolean | undefined; + /** The owner's Tailscale user ID on a tagged host, which has no user of its own. */ + readonly tailscaleOwnerUserId?: string | undefined; + /** Accept only loopback and tailnet peers, and advertise no LAN routes. */ + readonly tailscaleOnly?: boolean | undefined; } >()("t3/config/ServerConfig") { static readonly layerTest = ( diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index f4ab607fe0aeb..ff52e57020ff4 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId } from "@t3tools/contracts"; import { it as effectIt } from "@effect/vitest"; import type * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; @@ -13,6 +14,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as DirectEndpoints from "./DirectEndpoints.ts"; import { resolveBoundEndpoints } from "./DirectEndpoints.ts"; +import * as ServerEnvironment from "./ServerEnvironment.ts"; const INTERFACES: ReturnType = { lo0: [ @@ -198,17 +200,20 @@ describe("resolveBoundEndpoints", () => { ).toEqual([]); }); - it("reports only the tailnet address on a cloud VM, whose private network no client shares", () => { - expect( - resolveBoundEndpoints({ - platform: "linux", - host: "0.0.0.0", - port: 3773, - interfaces: INTERFACES, - onCloudVm: true, - }), - ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }]); - }); + it.each([{ onCloudVm: true }, { tailscaleOnly: true }])( + "reports only the tailnet address with %o, where no LAN client can connect", + (mode) => { + expect( + resolveBoundEndpoints({ + platform: "linux", + host: "0.0.0.0", + port: 3773, + interfaces: INTERFACES, + ...mode, + }), + ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }]); + }, + ); it("never reports an IPv4-mapped host, which can name the client's own loopback", () => { expect( @@ -247,14 +252,23 @@ const layerTailscaleUp = Layer.succeed( ), ); -const layerServeProbe = (status: number) => +const layerServeProbe = (status: number, servedEnvironmentId: string) => Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))), + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ environmentId: servedEnvironmentId }), { status }), + ), + ), ), ); +const layerThisServer = Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("this-server")), +}); + const layerServeConfig = Layer.effect( ServerConfig.ServerConfig, Effect.map(ServerConfig.ServerConfig, (config) => ({ @@ -268,12 +282,17 @@ const layerServeConfig = Layer.effect( Layer.provide(NodeServices.layer), ); -const resolveWithServe = (probeStatus: number) => +const resolveWithServe = (probeStatus: number, servedEnvironmentId = "this-server") => Effect.flatMap(DirectEndpoints.DirectEndpoints, (service) => service.resolve()).pipe( Effect.provide( DirectEndpoints.layer.pipe( Layer.provide( - Layer.mergeAll(layerServeConfig, layerTailscaleUp, layerServeProbe(probeStatus)), + Layer.mergeAll( + layerServeConfig, + layerTailscaleUp, + layerServeProbe(probeStatus, servedEnvironmentId), + layerThisServer, + ), ), ), ), @@ -288,9 +307,10 @@ describe("DirectEndpoints Tailscale Serve", () => { }), ); - effectIt.effect("leaves the tailnet name out when Serve is not forwarding", () => + effectIt.effect("leaves the tailnet name out when Serve does not reach this server", () => Effect.gen(function* () { expect(yield* resolveWithServe(502)).toEqual([]); + expect(yield* resolveWithServe(200, "other-server")).toEqual([]); }), ); }); diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index dc8b843d98aea..aae73287f6f20 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -15,13 +15,14 @@ import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { isTailnetHost } from "@t3tools/shared/hostClassification"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "./ServerEnvironment.ts"; import { formatHostForUrl, isLoopbackHost, isPrivateNetworkAddress, + isTailscaleInterface, isWildcardHost, VIRTUAL_INTERFACE, } from "../startupAccess.ts"; @@ -42,14 +43,6 @@ export class DirectEndpoints extends Context.Service< const isAdvertisableAddress = (address: string): boolean => NodeNet.isIPv4(address) && !address.startsWith("127.") && isPrivateNetworkAddress(address); -// macOS utun names need Tailscale's IPv6 range to identify them; WARP shares its IPv4 range. -const isTailscaleInterface = ( - name: string, - entries: ReadonlyArray, -): boolean => - /^tailscale/i.test(name) || - entries.some((entry) => entry.family === "IPv6" && isTailnetHost(entry.address)); - // Windows adapter names are mutable; Linux bridges can inherit these MACs. const WINDOWS_HOST_ADAPTER_MAC = /^(0a:00:27|00:50:56:c0:00):/i; @@ -60,6 +53,8 @@ export function resolveBoundEndpoints(input: { readonly interfaces: NetworkInterfacesMap; /** A cloud VM's private network is its provider's, which no client shares. */ readonly onCloudVm?: boolean; + /** The listener refuses LAN peers, so a LAN route would never answer. */ + readonly tailscaleOnly?: boolean; }): ReadonlyArray { if (isLoopbackHost(input.host)) return []; const tailscaleAddresses = new Set( @@ -80,7 +75,9 @@ export function resolveBoundEndpoints(input: { .map((entry) => entry.address) : [input.host!].filter(isAdvertisableAddress); return [...new Set(addresses)] - .filter((address) => !input.onCloudVm || tailscaleAddresses.has(address)) + .filter( + (address) => !(input.onCloudVm || input.tailscaleOnly) || tailscaleAddresses.has(address), + ) .map((address) => ({ kind: tailscaleAddresses.has(address) ? "tailnet" : "lan", httpBaseUrl: `http://${formatHostForUrl(address)}:${input.port}/`, @@ -120,6 +117,7 @@ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const platform = yield* HostProcess.Platform; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; // Only the service default listens on every interface without being asked, // so only it checks once whether it runs in a cloud. The check starts now so // the first client does not wait for its timeout. @@ -136,11 +134,12 @@ export const make = Effect.gen(function* () { port: config.port, interfaces: NodeOS.networkInterfaces(), onCloudVm: yield* onCloudVm, + tailscaleOnly: config.tailscaleOnly === true, }), ]; // Serve setup can fail without stopping startup; advertise only after it answers as this server. if (config.tailscaleServeEnabled) { - const servedUrl = yield* readTailscaleStatus.pipe( + const servedUrl = yield* readTailscaleStatus("active").pipe( Effect.map((status) => status.magicDnsName), Effect.flatMap((magicDnsName) => { if (magicDnsName === null) return Effect.succeed(null); @@ -148,7 +147,10 @@ export const make = Effect.gen(function* () { magicDnsName, servePort: config.tailscaleServePort, }); - return probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe( + return serverEnvironment.getEnvironmentId.pipe( + Effect.flatMap((environmentId) => + probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl, environmentId }), + ), Effect.map((reachable) => (reachable ? httpBaseUrl : null)), ); }), diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index fff8a8168437b..2bdeccbb4830f 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -33,7 +33,7 @@ export const make = Effect.gen(function* () { ); const username = yield* HostProcess.Username; - const magicDnsName = yield* readTailscaleStatus.pipe( + const magicDnsName = yield* readTailscaleStatus("passive").pipe( Effect.map((status) => status.magicDnsName), Effect.orElseSucceed(() => null), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7c..3bf8d1d07206e 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -10,6 +10,7 @@ import * as Layer from "effect/Layer"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import { parseTailscaleStatus } from "@t3tools/tailscale"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { @@ -18,11 +19,31 @@ import { RELAY_URL_SECRET, } from "../cloud/config.ts"; import * as ServerConfig from "../config.ts"; +import * as TailscaleHost from "../tailscale/TailscaleHost.ts"; import * as ServerEnvironment from "./ServerEnvironment.ts"; +const layerTailscaleHost = (statusJson: string) => + Layer.succeed( + TailscaleHost.TailscaleHost, + TailscaleHost.TailscaleHost.of({ + inUse: Effect.succeed(true), + status: parseTailscaleStatus(statusJson), + freshStatus: parseTailscaleStatus(statusJson), + whois: () => Effect.die("whois is not read by the descriptor"), + }), + ); + +const RUNNING_STATUS = JSON.stringify({ + Version: "1.102.2", + BackendState: "Running", + TUN: true, + Self: { ID: "nBox", UserID: 7, TailscaleIPs: ["100.88.1.2"] }, +}); + const layerServerEnvironment = (baseDir: string) => ServerEnvironment.layer.pipe( Layer.provide(ServerSecretStore.layer), + Layer.provide(layerTailscaleHost(RUNNING_STATUS)), Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)), ); @@ -98,6 +119,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { Effect.provide( ServerEnvironment.layer.pipe( Layer.provide(layerEmptySecretStore), + Layer.provide(layerTailscaleHost(RUNNING_STATUS)), Layer.provide(ServerConfig.layer({ ...config, mode })), ), ), @@ -235,7 +257,10 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { prefix: "t3-server-environment-publish-test-", }); const layerTest = Layer.mergeAll( - ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)), + ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(layerTailscaleHost(RUNNING_STATUS)), + ), ServerSecretStore.layer, ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); @@ -266,4 +291,62 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }).pipe(Effect.provide(layerTest)); }), ); + it.effect.each([ + { name: "sign-in off", signIn: false, status: {}, expected: undefined }, + { name: "untagged and running", signIn: true, status: {}, expected: true }, + { + name: "signed out", + signIn: true, + status: { BackendState: "NeedsLogin" }, + expected: undefined, + }, + { + name: "userspace networking", + signIn: true, + status: { TUN: false }, + expected: undefined, + }, + { + // Tailnet grants still let devices in without an owner. + name: "tagged with no owner", + signIn: true, + status: { Self: { ID: "nBox", Tags: ["tag:server"], TailscaleIPs: ["100.88.1.2"] } }, + expected: true, + }, + { + name: "Tailscale is older than sign-in supports", + signIn: true, + status: { Version: "1.78.1" }, + expected: undefined, + }, + ])("reports Tailscale sign-in when $name", ({ signIn, status, expected }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-tailnet-test-", + }); + const config = yield* makeServerConfig(baseDir); + yield* fileSystem.makeDirectory(config.stateDir, { recursive: true }); + const descriptor = yield* Effect.gen(function* () { + const environment = yield* ServerEnvironment.ServerEnvironment; + return yield* environment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(layerEmptySecretStore), + Layer.provide( + layerTailscaleHost(JSON.stringify({ ...JSON.parse(RUNNING_STATUS), ...status })), + ), + Layer.provide( + ServerConfig.layer({ + ...config, + tailscaleSignInEnabled: signIn, + }), + ), + ), + ), + ); + expect(descriptor.capabilities.tailnetSignIn).toBe(expected); + }), + ); }); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index d542b4ee55bb4..b1e07b38d9090 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -3,6 +3,7 @@ import { ORCHESTRATION_PROTOCOL_VERSION, PROVIDER_SEND_TURN_MAX_FILE_BYTES, type ExecutionEnvironmentDescriptor, + tailscaleStateFromBackend, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; @@ -22,8 +23,10 @@ import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; import * as ServerConfig from "../config.ts"; import { databaseMigrationHead } from "../persistence/databaseMigrationHead.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as TailscaleHost from "../tailscale/TailscaleHost.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts"; +import { signInIssues } from "./TailscaleHealth.ts"; export class ServerEnvironmentIdPersistenceError extends Schema.TaggedError()( "ServerEnvironmentIdPersistenceError", @@ -186,6 +189,7 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; const secrets = yield* ServerSecretStore.ServerSecretStore; + const tailscaleHost = yield* TailscaleHost.TailscaleHost; const identity = yield* ServerEnvironmentIdentity; const hostPlatform = yield* HostProcess.Platform; const hostArchitecture = yield* HostProcess.Architecture; @@ -282,13 +286,29 @@ export const make = Effect.gen(function* () { }, }; + // Tailscale can stop or log out while the server runs; the status read is cached briefly. + const tailnetSignIn = serverConfig.tailscaleSignInEnabled + ? tailscaleHost.status.pipe( + Effect.map( + (status) => + tailscaleStateFromBackend(status.backendState) === "running" && + signInIssues(status).length === 0, + ), + Effect.orElseSucceed(() => false), + ) + : Effect.succeed(false); + return ServerEnvironment.of({ getEnvironmentId: Effect.succeed(environmentId), // Publishing opt-in and relay links change at runtime; read the capability per request. - getDescriptor: readAgentActivityPublishingActive(secrets).pipe( - Effect.map((agentActivityPublishing) => ({ + getDescriptor: Effect.all([readAgentActivityPublishingActive(secrets), tailnetSignIn]).pipe( + Effect.map(([agentActivityPublishing, tailnetSignInAvailable]) => ({ ...descriptor, - capabilities: { ...descriptor.capabilities, agentActivityPublishing }, + capabilities: { + ...descriptor.capabilities, + agentActivityPublishing, + ...(tailnetSignInAvailable ? { tailnetSignIn: true } : {}), + }, })), ), }); diff --git a/apps/server/src/environment/TailnetPeers.test.ts b/apps/server/src/environment/TailnetPeers.test.ts new file mode 100644 index 0000000000000..9e746e6ffc40f --- /dev/null +++ b/apps/server/src/environment/TailnetPeers.test.ts @@ -0,0 +1,106 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { parseTailscaleStatus, TailscaleStatusParseError } from "@t3tools/tailscale"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +import * as ServerConfig from "../config.ts"; +import * as TailscaleHost from "../tailscale/TailscaleHost.ts"; +import * as TailnetPeers from "./TailnetPeers.ts"; + +const peer = (id: string, address: string, fields: Record = {}) => ({ + ID: id, + HostName: `${id}-host`, + DNSName: `${id}.tail.ts.net.`, + UserID: 7, + TailscaleIPs: [address], + Online: true, + ...fields, +}); + +const STATUS = JSON.stringify({ + BackendState: "Running", + Self: { ID: "nBox", UserID: 7, TailscaleIPs: ["100.100.1.1"] }, + Peer: { + a: peer("studio", "100.88.1.2"), + b: peer("ci", "100.88.1.3", { UserID: 99, Tags: ["tag:server"] }), + c: peer("friend", "100.88.1.4", { UserID: 99 }), + d: peer("shared", "100.88.1.5", { AltSharerUserID: 55 }), + e: peer("asleep", "100.88.1.6", { Online: false }), + f: peer("printer", "100.88.1.7"), + }, +}); + +const layerTest = ( + probed: Array, + status: TailscaleHost.TailscaleHost["Service"]["status"] = parseTailscaleStatus(STATUS), +) => + Layer.mergeAll( + Layer.succeed( + TailscaleHost.TailscaleHost, + TailscaleHost.TailscaleHost.of({ + inUse: Effect.succeed(true), + status, + freshStatus: status, + whois: () => Effect.die("whois is not used for discovery"), + }), + ), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + const url = new URL(request.url); + probed.push(url.host); + const body = + url.hostname === "100.88.1.7" + ? new Response("not found", { status: 404 }) + : Response.json({ environmentId: `env-${url.hostname}` }); + return HttpClientResponse.fromWeb(request, body); + }), + ), + ), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-tailnet-peers-test-" }), + ); + +it.layer(NodeServices.layer)("TailnetPeers", (it) => { + it.effect("probes online peers the owner owns or that are tagged, and returns only names", () => { + const probed: Array = []; + return Effect.gen(function* () { + const peers = yield* TailnetPeers.TailnetPeers; + expect(yield* peers.list).toEqual([ + { + name: "studio", + magicDnsName: "studio.tail.ts.net", + tailnetIpv4: "100.88.1.2", + environmentId: "env-100.88.1.2", + }, + { + name: "ci", + magicDnsName: "ci.tail.ts.net", + tailnetIpv4: "100.88.1.3", + environmentId: "env-100.88.1.3", + }, + ]); + expect(probed.toSorted()).toEqual(["100.88.1.2:3773", "100.88.1.3:3773", "100.88.1.7:3773"]); + // Served from the cache, with no new probes. + yield* peers.list; + expect(probed).toHaveLength(3); + }).pipe(Effect.provide(TailnetPeers.layer.pipe(Layer.provide(layerTest(probed))))); + }); + + it.effect("asks Tailscale again after a failed read instead of keeping an empty tailnet", () => { + let reads = 0; + const status = Effect.suspend(() => { + reads += 1; + return reads === 1 + ? Effect.fail(new TailscaleStatusParseError({ cause: "tailscaled restarting" })) + : parseTailscaleStatus(STATUS); + }); + return Effect.gen(function* () { + const peers = yield* TailnetPeers.TailnetPeers; + expect(yield* peers.list).toEqual([]); + expect((yield* peers.list).map((peer) => peer.name)).toEqual(["studio", "ci"]); + }).pipe(Effect.provide(TailnetPeers.layer.pipe(Layer.provide(layerTest([], status))))); + }); +}); diff --git a/apps/server/src/environment/TailnetPeers.ts b/apps/server/src/environment/TailnetPeers.ts new file mode 100644 index 0000000000000..7234acabf9fd9 --- /dev/null +++ b/apps/server/src/environment/TailnetPeers.ts @@ -0,0 +1,88 @@ +import { EnvironmentId, type TailnetPeerEnvironment } from "@t3tools/contracts"; +import { readT3EnvironmentId } from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/http"; + +import * as ServerConfig from "../config.ts"; +import * as TailscaleHost from "../tailscale/TailscaleHost.ts"; + +/** Every box `t3 up` sets up listens here, and the desktop app does when sign-in is on. */ +const PROBE_PORT = ServerConfig.DEFAULT_PORT; +const PROBE_TIMEOUT = Duration.millis(1_500); +const PROBE_CONCURRENCY = 4; +const RESULT_TTL = Duration.minutes(3); + +const isEnvironmentId = Schema.is(EnvironmentId); + +/** T3 Code environments among this host's tailnet peers, for clients that cannot list peers. */ +export class TailnetPeers extends Context.Service< + TailnetPeers, + { + /** Probes online peers the owner owns or that are tagged, a few at a time; cached for minutes. */ + readonly list: Effect.Effect>; + } +>()("t3/environment/TailnetPeers") {} + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const host = yield* TailscaleHost.TailscaleHost; + const httpClient = yield* HttpClient.HttpClient; + + const discover = Effect.gen(function* () { + const status = yield* host.status; + if (status.backendState !== "Running") return []; + const owner = TailscaleHost.tailnetOwnerUserId(status, config.tailscaleOwnerUserId); + // Nodes shared in from other tailnets belong to someone else. + const candidates = status.peers.flatMap((peer) => { + const address = peer.tailnetIpv4Addresses[0]; + const owned = peer.tags.length > 0 || (owner !== null && peer.userId === owner); + return peer.online && !peer.sharedIn && owned && address !== undefined + ? [{ peer, address }] + : []; + }); + const found = yield* Effect.forEach( + candidates, + ({ peer, address }) => + readT3EnvironmentId({ + baseUrl: `http://${address}:${PROBE_PORT}`, + timeout: PROBE_TIMEOUT, + }).pipe( + Effect.map((environmentId) => + Option.filter(environmentId, isEnvironmentId).pipe( + Option.map((id): TailnetPeerEnvironment => ({ + name: peer.magicDnsName?.split(".")[0] || peer.hostName || address, + magicDnsName: peer.magicDnsName, + tailnetIpv4: address, + environmentId: id, + })), + ), + ), + ), + { concurrency: PROBE_CONCURRENCY }, + ); + return found.flatMap(Option.toArray); + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("TailnetPeers.discover"), + ); + // A failed read is not kept, so the next request asks Tailscale again. + const cached = yield* Effect.cachedWithTTL(discover, (exit) => + Exit.isSuccess(exit) ? RESULT_TTL : Duration.zero, + ); + + return TailnetPeers.of({ + list: host.inUse.pipe( + Effect.flatMap((inUse) => (inUse ? cached : Effect.succeed([]))), + // Tailscale being down is an empty tailnet, not a failure for the phone. + Effect.orElseSucceed((): ReadonlyArray => []), + ), + }); +}); + +export const layer = Layer.effect(TailnetPeers, make); diff --git a/apps/server/src/environment/TailscaleHealth.test.ts b/apps/server/src/environment/TailscaleHealth.test.ts new file mode 100644 index 0000000000000..be6c5c795c71b --- /dev/null +++ b/apps/server/src/environment/TailscaleHealth.test.ts @@ -0,0 +1,192 @@ +import { + TailscaleCommandExitError, + TailscaleCommandSpawnError, + TailscaleCommandTimeoutError, + type TailscaleStatus, +} from "@t3tools/tailscale"; +import { expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import { describe } from "vite-plus/test"; + +import * as TailscaleHost from "../tailscale/TailscaleHost.ts"; +import * as TailscaleHealth from "./TailscaleHealth.ts"; +import { classifyTailscaleHealth, type TailscaleStatusRead } from "./TailscaleHealth.ts"; + +const NOW = Date.parse("2026-10-28T12:00:00.000Z"); +const DAY = 24 * 60 * 60 * 1000; +const iso = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)); + +const running: TailscaleStatus = { + magicDnsName: "studio.tail1234.ts.net", + tailnetIpv4Addresses: ["100.64.0.1"], + httpsServeEnabled: true, + backendState: "Running", + version: "1.102.2", + users: [], + selfNodeId: "n1", + selfUserId: "1", + selfTags: [], + keyExpiry: iso(NOW + 90 * DAY), + keyExpired: false, + health: [], + magicDnsEnabled: true, + tailnetName: null, + certDomains: ["studio.tail1234.ts.net"], + userspaceNetworking: false, + peers: [], + unidentifiedPeers: [], +}; + +const context = { executable: "tailscale", subcommand: "status", argumentCount: 2 } as const; +const exitError = (stderrDiagnostic?: "not-logged-in" | "permission-denied" | "unknown") => + new TailscaleCommandExitError({ + ...context, + exitCode: 1, + stderrLength: 40, + ...(stderrDiagnostic ? { stderrDiagnostic } : {}), + }); + +const classify = (read: TailscaleStatusRead) => + classifyTailscaleHealth(read, { + checkedAt: iso(NOW), + now: NOW, + platform: "linux", + }); + +describe("classifyTailscaleHealth", () => { + it.each<[string, TailscaleStatusRead, string, ReadonlyArray]>([ + ["healthy", Result.succeed(running), "running", []], + [ + "CLI not found", + Result.fail(new TailscaleCommandSpawnError({ ...context, cause: new Error("ENOENT") })), + "not_installed", + ["not_installed"], + ], + ["daemon not running", Result.fail(exitError("unknown")), "not_running", ["not_running"]], + [ + "CLI says logged out", + Result.fail(exitError("not-logged-in")), + "needs_login", + ["needs_login"], + ], + [ + "socket permission", + Result.fail(exitError("permission-denied")), + "unreadable", + ["unreadable"], + ], + [ + "CLI hangs", + Result.fail(new TailscaleCommandTimeoutError({ ...context, timeoutMs: 3500, cause: null })), + "unreadable", + ["unreadable"], + ], + [ + "signed out, with health text that repeats it", + Result.succeed({ ...running, backendState: "NeedsLogin", health: ["You are logged out."] }), + "needs_login", + ["needs_login"], + ], + [ + "waiting for admin approval", + Result.succeed({ ...running, backendState: "NeedsMachineAuth" }), + "needs_machine_approval", + ["needs_machine_approval"], + ], + ["stopped", Result.succeed({ ...running, backendState: "Stopped" }), "stopped", ["stopped"]], + [ + "expired key", + Result.succeed({ ...running, backendState: "NeedsLogin", keyExpired: true }), + "needs_login", + ["needs_login", "key_expired"], + ], + [ + "key past its expiry before the daemon notices", + Result.succeed({ ...running, keyExpiry: iso(NOW - 1000) }), + "running", + ["key_expired"], + ], + [ + "key expires within 7 days", + Result.succeed({ ...running, keyExpiry: iso(NOW + 3 * DAY) }), + "running", + ["key_expiring"], + ], + ["key expiry turned off", Result.succeed({ ...running, keyExpiry: null }), "running", []], + [ + "health, HTTPS, MagicDNS, userspace", + Result.succeed({ + ...running, + health: ["Tailscale could not connect to the DERP server"], + httpsServeEnabled: false, + certDomains: [], + magicDnsEnabled: false, + userspaceNetworking: true, + }), + "running", + ["userspace_networking", "health_warning", "https_off", "magic_dns_off"], + ], + [ + "too old for sign-in", + Result.succeed({ ...running, version: "1.62.0" }), + "running", + ["outdated"], + ], + [ + "no IPv4 address", + Result.succeed({ ...running, tailnetIpv4Addresses: [] }), + "running", + ["no_ipv4"], + ], + [ + "too old, ahead of a health warning", + Result.succeed({ ...running, version: "1.62.0", health: ["Some peers are unreachable"] }), + "running", + ["outdated", "health_warning"], + ], + [ + "an older CLI that reports no capabilities or TUN", + Result.succeed({ + ...running, + httpsServeEnabled: null, + certDomains: [], + magicDnsEnabled: null, + userspaceNetworking: null, + }), + "running", + [], + ], + ])("%s", (_name, read, state, causes) => { + const result = classify(read); + expect(result.state).toBe(state); + expect(result.issues.map((issue) => issue.cause)).toEqual(causes); + }); +}); + +describe("TailscaleHealth", () => { + it.effect("reports nothing and runs no CLI while this machine shows no sign of Tailscale", () => + Effect.gen(function* () { + const health = yield* TailscaleHealth.TailscaleHealth; + expect(yield* health.check).toBeUndefined(); + }).pipe( + Effect.provide( + TailscaleHealth.layer.pipe( + Layer.provide( + Layer.succeed( + TailscaleHost.TailscaleHost, + TailscaleHost.TailscaleHost.of({ + inUse: Effect.succeed(false), + status: Effect.die("must not read Tailscale"), + freshStatus: Effect.die("must not read Tailscale"), + whois: () => Effect.die("must not read Tailscale"), + }), + ), + ), + ), + ), + ), + ); +}); diff --git a/apps/server/src/environment/TailscaleHealth.ts b/apps/server/src/environment/TailscaleHealth.ts new file mode 100644 index 0000000000000..970b4115723d7 --- /dev/null +++ b/apps/server/src/environment/TailscaleHealth.ts @@ -0,0 +1,212 @@ +import { + type ServerTailscaleIssue, + type ServerTailscaleStatus, + tailscaleIssueHeadline, + tailscaleStateFromBackend, + type TailscaleState, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { + readTailscaleStatus, + type TailscaleCommandError, + type TailscaleStatus, + type TailscaleStatusParseError, + tailscaleStateOfReadError, +} from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import * as TailscaleHost from "../tailscale/TailscaleHost.ts"; + +const CACHE_TTL = Duration.seconds(30); +const REFRESH_INTERVAL = Duration.minutes(1); +const KEY_EXPIRY_WARNING_MS = Duration.toMillis(Duration.days(7)); +const ADMIN_MACHINES_URL = "https://login.tailscale.com/admin/machines"; +const ADMIN_DNS_URL = "https://login.tailscale.com/admin/dns"; + +export type TailscaleStatusRead = Result.Result< + TailscaleStatus, + TailscaleCommandError | TailscaleStatusParseError +>; + +/** What stops Tailscale sign-in on a running Tailscale, most urgent first. */ +export function signInIssues(status: TailscaleStatus): Array { + const issues: Array = []; + if (!TailscaleHost.isSupportedTailscaleVersion(status.version)) { + issues.push({ + cause: "outdated", + fix: `Update Tailscale to ${TailscaleHost.MINIMUM_TAILSCALE_VERSION} or newer.`, + }); + } + if (status.tailnetIpv4Addresses.length === 0) { + issues.push({ + cause: "no_ipv4", + fix: "Allow IPv4 for this machine in your tailnet policy. Tailscale sign-in needs it.", + }); + } + if (status.userspaceNetworking === true) { + issues.push({ + cause: "userspace_networking", + fix: "Run tailscaled with a TUN device (in a container, give it /dev/net/tun), so T3 Code can see which device connects.", + }); + } + return issues; +} + +/** Linux and other headless hosts use the CLI; macOS and Windows users usually have the app. */ +const usesApp = (platform: NodeJS.Platform) => platform === "darwin" || platform === "win32"; + +export function stateFix( + state: Exclude, + platform: NodeJS.Platform, +): string { + const app = usesApp(platform); + switch (state) { + case "not_installed": + return "Install Tailscale from https://tailscale.com/download, or put its CLI on PATH."; + case "not_running": + return app ? "Open the Tailscale app." : "Run `sudo systemctl enable --now tailscaled`."; + case "needs_login": + return app ? "Open the Tailscale app and log in." : "Run `sudo tailscale up` and log in."; + case "needs_machine_approval": + return `Approve this machine at ${ADMIN_MACHINES_URL}.`; + case "stopped": + return app ? "Turn on Tailscale in the Tailscale app." : "Run `sudo tailscale up`."; + case "starting": + return "Wait a few seconds. If it stays, restart Tailscale."; + case "unreadable": + return "Run `tailscale status` to see the error. Update Tailscale if it is old."; + } +} + +export function readErrorFacts( + error: TailscaleCommandError | TailscaleStatusParseError, + platform: NodeJS.Platform, +): ServerTailscaleIssue & { readonly cause: Exclude } { + const cause = tailscaleStateOfReadError(error); + if (error._tag === "TailscaleCommandTimeoutError") { + return { cause, fix: "`tailscale status` did not answer. Restart Tailscale." }; + } + if ( + error._tag === "TailscaleCommandExitError" && + error.stderrDiagnostic === "permission-denied" + ) { + return { cause, fix: "Let the user that runs T3 Code run `tailscale status`." }; + } + return { cause, fix: stateFix(cause, platform) }; +} + +/** Turns one `tailscale status` read into the facts the CLI prints and clients show. */ +export function classifyTailscaleHealth( + read: TailscaleStatusRead, + input: { readonly checkedAt: string; readonly now: number; readonly platform: NodeJS.Platform }, +): ServerTailscaleStatus { + const { checkedAt, now, platform } = input; + if (Result.isFailure(read)) { + const issue = readErrorFacts(read.failure, platform); + return { state: issue.cause, checkedAt, magicDnsName: null, keyExpiry: null, issues: [issue] }; + } + const status = read.success; + const state = tailscaleStateFromBackend(status.backendState); + const issues: Array = []; + if (state !== "running") issues.push({ cause: state, fix: stateFix(state, platform) }); + // Each stops Tailscale sign-in, so they come before the warnings below. + if (state === "running") issues.push(...signInIssues(status)); + + // NaN when the key never expires, which fails both comparisons. + const expiresAt = status.keyExpiry === null ? Number.NaN : Date.parse(status.keyExpiry); + const disableExpiry = `turn off key expiry for this machine at ${ADMIN_MACHINES_URL}`; + if (status.keyExpired || expiresAt <= now) { + issues.push({ + cause: "key_expired", + fix: usesApp(platform) + ? `Log in again in the Tailscale app, or ${disableExpiry}.` + : `Run \`sudo tailscale up --force-reauth\`, or ${disableExpiry}.`, + }); + } else if (expiresAt - now <= KEY_EXPIRY_WARNING_MS) { + issues.push({ + cause: "key_expiring", + fix: `Log in again before it expires, or ${disableExpiry}.`, + }); + } + + // Health repeats the state while Tailscale is not running. + if (state === "running") { + for (const message of status.health) { + issues.push({ cause: "health_warning", fix: "Run `tailscale status` for details.", message }); + } + if (status.httpsServeEnabled === false && status.certDomains.length === 0) { + issues.push({ + cause: "https_off", + fix: `Turn on HTTPS Certificates at ${ADMIN_DNS_URL}. Tailscale HTTPS needs them.`, + }); + } + if (status.magicDnsEnabled === false) { + issues.push({ cause: "magic_dns_off", fix: `Turn on MagicDNS at ${ADMIN_DNS_URL}.` }); + } + } + return { + state, + checkedAt, + magicDnsName: status.magicDnsName, + keyExpiry: status.keyExpiry, + issues, + }; +} + +/** What `t3 tailscale status` prints. */ +export function formatTailscaleHealth(status: ServerTailscaleStatus): string { + const now = Date.parse(status.checkedAt); + const state = status.state.replaceAll("_", " "); + return [ + `Tailscale: ${state}${status.magicDnsName ? ` (${status.magicDnsName})` : ""}`, + ...(status.issues.length === 0 ? ["✓ Nothing to fix"] : []), + ...status.issues.flatMap((issue) => [ + "", + `✕ ${tailscaleIssueHeadline("this machine", issue, status.keyExpiry, now)}`, + ` Fix: ${issue.fix}`, + ]), + ].join("\n"); +} + +const readTailscaleHealthWith = Effect.fn("TailscaleHealth.read")(function* ( + status: Effect.Effect, +) { + const read = yield* Effect.result(status); + const now = yield* DateTime.now; + return classifyTailscaleHealth(read, { + checkedAt: DateTime.formatIso(now), + now: DateTime.toEpochMillis(now), + platform: yield* HostProcess.Platform, + }); +}); + +/** Reads and classifies now, for the CLI. The server goes through the cache in {@link TailscaleHealth}. */ +export const readTailscaleHealth = readTailscaleHealthWith(readTailscaleStatus("active")); + +export class TailscaleHealth extends Context.Service< + TailscaleHealth, + { + /** + * At most 30 s old. Concurrent callers share one read. Undefined, with no + * read, while this machine shows no sign of using Tailscale. + */ + readonly check: Effect.Effect; + /** `check` now and once a minute, for a client that stays connected. */ + readonly reads: Stream.Stream; + } +>()("t3/environment/TailscaleHealth") {} + +const make = Effect.gen(function* () { + const host = yield* TailscaleHost.TailscaleHost; + const read = yield* Effect.cachedWithTTL(readTailscaleHealthWith(host.status), CACHE_TTL); + const check = host.inUse.pipe(Effect.flatMap((inUse) => (inUse ? read : Effect.undefined))); + const reads = Stream.tick(REFRESH_INTERVAL).pipe(Stream.mapEffect(() => check)); + return TailscaleHealth.of({ check, reads }); +}); + +export const layer = Layer.effect(TailscaleHealth, make); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index e792a233c6f06..384c490523c01 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -307,7 +307,7 @@ const authenticateRawRouteWithScope = ), ); if (!session.scopes.includes(scope)) { - return yield* failEnvironmentScopeRequired(scope); + return yield* failEnvironmentScopeRequired(scope, session.subject); } }); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 7bb8bf8a34c18..7471c6553dda9 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -63,6 +63,10 @@ const RPC_AGGREGATES = { [WS_METHODS.peerLinksLink]: "peerLinks", [WS_METHODS.peerLinksUnlink]: "peerLinks", [WS_METHODS.peerLinksAnswerRequest]: "peerLinks", + [WS_METHODS.tailnetDevicesList]: "auth", + [WS_METHODS.tailnetDevicesBlock]: "auth", + [WS_METHODS.tailnetDevicesUnblock]: "auth", + [WS_METHODS.tailnetPeersList]: "environment", [WS_METHODS.serverUpdateSettings]: "server", [WS_METHODS.serverSearchAcpRegistry]: "server", [WS_METHODS.serverPrepareAcpRegistryAgent]: "server", diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index 8754bdbd2edcd..7e92d590dbc99 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as SqlSchema from "effect/sql/SqlSchema"; -import { AuthEnvironmentScopes } from "@t3tools/contracts"; +import { AuthEnvironmentScopes, TAILNET_SUBJECT_PREFIX } from "@t3tools/contracts"; import { type AuthPairingLinkRepositoryError, @@ -65,6 +65,12 @@ export const RevokeAuthPairingLinkInput = Schema.Struct({ }); export type RevokeAuthPairingLinkInput = typeof RevokeAuthPairingLinkInput.Type; +export const RevokeAuthPairingLinksBySubjectInput = Schema.Struct({ + subject: Schema.String, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthPairingLinksBySubjectInput = typeof RevokeAuthPairingLinksBySubjectInput.Type; + export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ credential: Schema.String, }); @@ -101,6 +107,10 @@ export class AuthPairingLinkRepository extends Context.Service< readonly revoke: ( input: RevokeAuthPairingLinkInput, ) => Effect.Effect; + /** Revokes the subject's unused links. Returns how many. */ + readonly revokeUnusedBySubject: ( + input: RevokeAuthPairingLinksBySubjectInput, + ) => Effect.Effect; readonly getByCredential: ( input: GetAuthPairingLinkByCredentialInput, ) => Effect.Effect, AuthPairingLinkRepositoryError>; @@ -195,6 +205,11 @@ export const make = Effect.gen(function* () { SELECT value FROM json_each(auth_pairing_links.scopes) ) ) + AND NOT EXISTS ( + SELECT 1 + FROM auth_tailnet_device_blocks AS blocks + WHERE auth_pairing_links.subject = ${TAILNET_SUBJECT_PREFIX} || blocks.stable_node_id + ) RETURNING id AS "id", credential AS "credential", @@ -249,6 +264,20 @@ export const make = Effect.gen(function* () { `, }); + const revokeUnusedPairingLinkRowsBySubject = SqlSchema.findAll({ + Request: RevokeAuthPairingLinksBySubjectInput, + Result: Schema.Struct({ id: Schema.String }), + execute: ({ subject, revokedAt }) => + sql` + UPDATE auth_pairing_links + SET revoked_at = ${revokedAt} + WHERE subject = ${subject} + AND revoked_at IS NULL + AND consumed_at IS NULL + RETURNING id AS "id" + `, + }); + const getPairingLinkRowByCredential = SqlSchema.findOneOption({ Request: GetAuthPairingLinkByCredentialInput, Result: AuthPairingLinkRawDbRow, @@ -344,6 +373,19 @@ export const make = Effect.gen(function* () { Effect.map((rows) => rows.length > 0), ); + const revokeUnusedBySubject: AuthPairingLinkRepository["Service"]["revokeUnusedBySubject"] = ( + input, + ) => + revokeUnusedPairingLinkRowsBySubject(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.revokeUnusedBySubject:query", + "AuthPairingLinkRepository.revokeUnusedBySubject:decodeRows", + ), + ), + Effect.map((rows) => rows.length), + ); + const getByCredential: AuthPairingLinkRepository["Service"]["getByCredential"] = (input) => getPairingLinkRowByCredential(input).pipe( NodeSqliteClient.readOnly, @@ -376,6 +418,7 @@ export const make = Effect.gen(function* () { consumeAvailable, listActive, revoke, + revokeUnusedBySubject, getByCredential, } satisfies AuthPairingLinkRepository["Service"]; }); diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 7b82b012e674f..c49e32587a55f 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -86,6 +86,12 @@ export const RevokeOtherAuthSessionsInput = Schema.Struct({ }); export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; +export const RevokeAuthSessionsBySubjectInput = Schema.Struct({ + subject: Schema.String, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthSessionsBySubjectInput = typeof RevokeAuthSessionsBySubjectInput.Type; + export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ sessionId: AuthSessionId, lastConnectedAt: Schema.DateTimeUtcFromString, @@ -116,6 +122,9 @@ export class AuthSessionRepository extends Context.Service< readonly revokeAllExcept: ( input: RevokeOtherAuthSessionsInput, ) => Effect.Effect, AuthSessionRepositoryError>; + readonly revokeBySubject: ( + input: RevokeAuthSessionsBySubjectInput, + ) => Effect.Effect, AuthSessionRepositoryError>; readonly setLastConnectedAt: ( input: SetAuthSessionLastConnectedAtInput, ) => Effect.Effect; @@ -347,6 +356,19 @@ export const make = Effect.gen(function* () { `, }); + const revokeSubjectSessionRows = SqlSchema.findAll({ + Request: RevokeAuthSessionsBySubjectInput, + Result: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ subject, revokedAt }) => + sql` + UPDATE auth_sessions + SET revoked_at = ${revokedAt} + WHERE subject = ${subject} + AND revoked_at IS NULL + RETURNING session_id AS "sessionId" + `, + }); + const create: AuthSessionRepository["Service"]["create"] = (input) => createSessionRow(input).pipe( Effect.mapError( @@ -469,6 +491,17 @@ export const make = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => row.sessionId)), ); + const revokeBySubject: AuthSessionRepository["Service"]["revokeBySubject"] = (input) => + revokeSubjectSessionRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.revokeBySubject:query", + "AuthSessionRepository.revokeBySubject:decodeRows", + ), + ), + Effect.map((rows) => rows.map((row) => row.sessionId)), + ); + const setLastConnectedAt: AuthSessionRepository["Service"]["setLastConnectedAt"] = (input) => setLastConnectedAtRow(input).pipe( Effect.mapError( @@ -488,6 +521,7 @@ export const make = Effect.gen(function* () { listActive, revoke, revokeAllExcept, + revokeBySubject, setLastConnectedAt, } satisfies AuthSessionRepository["Service"]; }); diff --git a/apps/server/src/persistence/AuthTailnetDeviceBlocks.ts b/apps/server/src/persistence/AuthTailnetDeviceBlocks.ts new file mode 100644 index 0000000000000..f5e92cbc8de7c --- /dev/null +++ b/apps/server/src/persistence/AuthTailnetDeviceBlocks.ts @@ -0,0 +1,123 @@ +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as SqlSchema from "effect/sql/SqlSchema"; + +import { PersistenceDecodeError, PersistenceSqlError } from "./Errors.ts"; + +export const AuthTailnetDeviceBlockRecord = Schema.Struct({ + stableNodeId: Schema.String, + label: Schema.NullOr(Schema.String), + blockedAt: Schema.DateTimeUtcFromString, +}); +export type AuthTailnetDeviceBlockRecord = typeof AuthTailnetDeviceBlockRecord.Type; + +const StableNodeIdInput = Schema.Struct({ stableNodeId: Schema.String }); + +export type AuthTailnetDeviceBlockRepositoryError = PersistenceSqlError | PersistenceDecodeError; + +/** Devices refused for Tailscale sign-in. Pairing grant consumption reads this table too. */ +export class AuthTailnetDeviceBlockRepository extends Context.Service< + AuthTailnetDeviceBlockRepository, + { + /** Keeps the first block time when the device is already blocked. */ + readonly block: ( + input: AuthTailnetDeviceBlockRecord, + ) => Effect.Effect; + readonly unblock: (input: { + readonly stableNodeId: string; + }) => Effect.Effect; + readonly isBlocked: (input: { + readonly stableNodeId: string; + }) => Effect.Effect; + readonly list: () => Effect.Effect< + ReadonlyArray, + AuthTailnetDeviceBlockRepositoryError + >; + } +>()("t3/persistence/AuthTailnetDeviceBlocks/AuthTailnetDeviceBlockRepository") {} + +const toRepositoryError = + (operation: string) => + (cause: unknown): AuthTailnetDeviceBlockRepositoryError => + Schema.isSchemaError(cause) + ? PersistenceDecodeError.fromSchemaError(operation, cause) + : new PersistenceSqlError({ operation, cause }); + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const insertBlock = SqlSchema.void({ + Request: AuthTailnetDeviceBlockRecord, + execute: (input) => sql` + INSERT INTO auth_tailnet_device_blocks (stable_node_id, label, blocked_at) + VALUES (${input.stableNodeId}, ${input.label}, ${input.blockedAt}) + ON CONFLICT(stable_node_id) DO NOTHING + `, + }); + + const deleteBlock = SqlSchema.findAll({ + Request: StableNodeIdInput, + Result: StableNodeIdInput, + execute: ({ stableNodeId }) => sql` + DELETE FROM auth_tailnet_device_blocks + WHERE stable_node_id = ${stableNodeId} + RETURNING stable_node_id AS "stableNodeId" + `, + }); + + const findBlock = SqlSchema.findOneOption({ + Request: StableNodeIdInput, + Result: StableNodeIdInput, + execute: ({ stableNodeId }) => sql` + SELECT stable_node_id AS "stableNodeId" + FROM auth_tailnet_device_blocks + WHERE stable_node_id = ${stableNodeId} + `, + }); + + const listBlocks = SqlSchema.findAll({ + Request: Schema.Void, + Result: AuthTailnetDeviceBlockRecord, + execute: () => sql` + SELECT + stable_node_id AS "stableNodeId", + label AS "label", + blocked_at AS "blockedAt" + FROM auth_tailnet_device_blocks + ORDER BY blocked_at DESC, stable_node_id + `, + }); + + const block: AuthTailnetDeviceBlockRepository["Service"]["block"] = (input) => + insertBlock(input).pipe( + Effect.mapError(toRepositoryError("AuthTailnetDeviceBlockRepository.block")), + ); + + const unblock: AuthTailnetDeviceBlockRepository["Service"]["unblock"] = (input) => + deleteBlock(input).pipe( + Effect.map((rows) => rows.length > 0), + Effect.mapError(toRepositoryError("AuthTailnetDeviceBlockRepository.unblock")), + ); + + const isBlocked: AuthTailnetDeviceBlockRepository["Service"]["isBlocked"] = (input) => + findBlock(input).pipe( + NodeSqliteClient.readOnly, + Effect.map(Option.isSome), + Effect.mapError(toRepositoryError("AuthTailnetDeviceBlockRepository.isBlocked")), + ); + + const list: AuthTailnetDeviceBlockRepository["Service"]["list"] = () => + listBlocks(undefined).pipe( + NodeSqliteClient.readOnly, + Effect.mapError(toRepositoryError("AuthTailnetDeviceBlockRepository.list")), + ); + + return AuthTailnetDeviceBlockRepository.of({ block, unblock, isBlocked, list }); +}); + +export const layer = Layer.effect(AuthTailnetDeviceBlockRepository, make); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index feab4ef823f00..fb42aa1dd2a62 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -80,6 +80,7 @@ import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSea import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; import Migration0076 from "./Migrations/076_ProjectionProjectsPinArchive.ts"; import Migration0077 from "./Migrations/077_RepairBooleanSettledOverride.ts"; +import Migration0078 from "./Migrations/078_AuthTailnetDeviceBlocks.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -160,6 +161,7 @@ const migrationEntries = [ [75, "SubagentDeliveryState", Migration0075], [76, "ProjectionProjectsPinArchive", Migration0076], [77, "RepairBooleanSettledOverride", Migration0077], + [78, "AuthTailnetDeviceBlocks", Migration0078], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/078_AuthTailnetDeviceBlocks.ts b/apps/server/src/persistence/Migrations/078_AuthTailnetDeviceBlocks.ts new file mode 100644 index 0000000000000..7e9ba8a390342 --- /dev/null +++ b/apps/server/src/persistence/Migrations/078_AuthTailnetDeviceBlocks.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Devices this host refuses for Tailscale sign-in, by StableID. A device + // that rejoins the tailnet gets a new StableID and is not blocked. + yield* sql` + CREATE TABLE IF NOT EXISTS auth_tailnet_device_blocks ( + stable_node_id TEXT PRIMARY KEY, + label TEXT, + blocked_at TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 4083978758ce0..2bf473fd168ba 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -57,6 +57,7 @@ describe("V2 preview upgrade", () => { [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], [77, "RepairBooleanSettledOverride"], + [78, "AuthTailnetDeviceBlocks"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -157,6 +158,7 @@ describe("V2 preview upgrade", () => { [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], [77, "RepairBooleanSettledOverride"], + [78, "AuthTailnetDeviceBlocks"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/policy/managedLimits.test.ts b/apps/server/src/policy/managedLimits.test.ts index a09f11909abfc..4b7d3fd9e738f 100644 --- a/apps/server/src/policy/managedLimits.test.ts +++ b/apps/server/src/policy/managedLimits.test.ts @@ -74,7 +74,7 @@ it.effect("binds only the loopback address it approved when remote clients are n }), ); -it.effect("refuses Tailscale Serve when remote clients are not allowed", () => +it.effect("refuses Tailscale Serve and Tailscale sign-in when remote clients are not allowed", () => Effect.gen(function* () { const error = yield* ManagedLimits.checkServerReachability(desktopAndLocal, { authPolicy: "loopback-browser", @@ -82,6 +82,16 @@ it.effect("refuses Tailscale Serve when remote clients are not allowed", () => tailscaleServeEnabled: true, }).pipe(Effect.flip); assert.include(error.message, "Tailscale Serve"); + const signIn = yield* ManagedLimits.checkServerReachability(desktopAndLocal, { + authPolicy: "remote-reachable", + host: "0.0.0.0", + tailscaleServeEnabled: false, + tailscaleSignInEnabled: true, + }).pipe(Effect.flip); + assert.equal( + signIn.message, + "Example Corp's policy does not allow remote clients, so Tailscale sign-in cannot be turned on.", + ); }), ); diff --git a/apps/server/src/policy/managedLimits.ts b/apps/server/src/policy/managedLimits.ts index 88df7d63c27c2..0039aded09ef5 100644 --- a/apps/server/src/policy/managedLimits.ts +++ b/apps/server/src/policy/managedLimits.ts @@ -145,7 +145,7 @@ export const closeTailscaleServeTo = Effect.fn("managedLimits.closeTailscaleServ if (found.length === 0) return; // `off` acts on the machine's current name, so a web route under a former name stays. const currentName = found.some((route) => route.mount !== undefined && route.owner === undefined) - ? yield* readTailscaleStatus.pipe( + ? yield* readTailscaleStatus("passive").pipe( Effect.map((status) => status.magicDnsName?.toLowerCase() ?? null), Effect.orElseSucceed(() => null), ) @@ -214,6 +214,7 @@ export const checkServerReachability = Effect.fn("managedLimits.checkServerReach readonly authPolicy: ServerAuthPolicy; readonly host: string | undefined; readonly tailscaleServeEnabled: boolean; + readonly tailscaleSignInEnabled?: boolean | undefined; /** The background service chose `host` to reach private networks; the user did not. */ readonly privateNetworkOnly?: boolean | undefined; }, @@ -225,6 +226,9 @@ export const checkServerReachability = Effect.fn("managedLimits.checkServerReach ? "so the background service cannot listen on private networks; set T3CODE_HOST=127.0.0.1 in its service.env" : `so the server cannot listen on ${input.host === "" ? "every interface" : (input.host ?? "this host")}`, ); + if (input.tailscaleSignInEnabled === true && !allowsRemoteClients(policy)) { + return yield* refuseRemoteClients(policy, "so Tailscale sign-in cannot be turned on"); + } if (!allowsAuthPolicy(policy, input.authPolicy)) { return yield* input.authPolicy === "remote-reachable" ? cannotListen() diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 67418010e38ec..b7db73b1fd838 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -14,6 +14,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentHttpApi, type RepositoryIdentity } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -133,6 +134,7 @@ import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; +import * as TailscaleHealth from "./environment/TailscaleHealth.ts"; import * as AuthHttp from "./auth/http.ts"; import * as ReplayMarkers from "./auth/replayMarkers.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -153,6 +155,9 @@ import { } from "./cloud/config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as EnvironmentAuthPolicy from "./auth/EnvironmentAuthPolicy.ts"; +import * as TailnetSignIn from "./auth/TailnetSignIn.ts"; +import * as TailnetPeers from "./environment/TailnetPeers.ts"; +import * as TailscaleHost from "./tailscale/TailscaleHost.ts"; import * as CloudHttp from "./cloud/http.ts"; import * as GuestHttp from "./guest/http.ts"; import * as CloudLink from "./cloud/CloudLink.ts"; @@ -201,17 +206,16 @@ import * as EventStoreV2 from "./orchestration-v2/EventStore.ts"; import * as ProjectionMaintenance from "./orchestration-v2/ProjectionMaintenance.ts"; import * as ProjectionStoreV2 from "./orchestration-v2/ProjectionStore.ts"; import { makePersistedServerRuntimeState } from "./serverRuntimeState.ts"; -import { acceptsPrivateNetworkConnection, loopbackUrlHostForAddress } from "./startupAccess.ts"; +import { + acceptsPrivateNetworkConnection, + makeTailnetConnectionFilter, + loopbackUrlHostForAddress, +} from "./startupAccess.ts"; +import * as TailscaleServe from "./tailscaleServe.ts"; import * as OrchestrationHttp from "./orchestration-v2/http.ts"; import * as ProjectHttp from "./project/http.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { - disableTailscaleServe, - ensureTailscaleServe, - readTailscaleServeRoutesTo, - readTailscaleStatus, -} from "@t3tools/tailscale"; import * as ServerActivation from "./serverActivation.ts"; // Escaped MCP handoff provenance can exceed find-my-way's 100-character segment default. @@ -341,17 +345,24 @@ const layerHttpServer = Layer.unwrap( authPolicy: EnvironmentAuthPolicy.resolveServerAuthPolicy(config), host: config.host, tailscaleServeEnabled: config.tailscaleServeEnabled, + tailscaleSignInEnabled: config.tailscaleSignInEnabled === true, privateNetworkOnly: config.privateNetworkOnly, }); yield* ManagedLimits.closeTailscaleServeTo(policy, { host: bindHost, port: config.port }); + const clock = yield* Clock.Clock; return NodeHttpServer.layer( () => { const server = rejectRequestsUntilServing( guardHttpResponseWriteErrors(NodeHttp.createServer()), ); - if (config.privateNetworkOnly) { + const acceptsConnection = config.tailscaleOnly + ? makeTailnetConnectionFilter({ now: () => clock.currentTimeMillisUnsafe() }) + : config.privateNetworkOnly + ? acceptsPrivateNetworkConnection + : undefined; + if (acceptsConnection !== undefined) { server.on("connection", (socket) => { - if (!acceptsPrivateNetworkConnection(socket)) { + if (!acceptsConnection(socket)) { socket.destroy(); } }); @@ -518,7 +529,10 @@ const layerProjectFaviconResolver = ProjectFaviconResolver.layer.pipe( Layer.provide(T3ProjectFileLoader.layer), ); -const layerServerEnvironment = ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)); +const layerServerEnvironment = ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(TailscaleHost.layer), +); const layerAuth = EnvironmentAuth.layer.pipe( Layer.provideMerge(layerPersistence), @@ -744,7 +758,8 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provideMerge(AnalyticsService.layer.pipe(Layer.provide(ManagedPolicy.layer))), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(RemoteOpenTargets.layer), - Layer.provideMerge(DirectEndpoints.layer), + Layer.provideMerge(DirectEndpoints.layer.pipe(Layer.provide(layerServerEnvironment))), + Layer.provideMerge(TailscaleHealth.layer.pipe(Layer.provide(TailscaleHost.layer))), Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), ); @@ -761,6 +776,7 @@ const layerMakeRoutes = Layer.mergeAll( Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( Layer.provide(AuthHttp.layer), + Layer.provide(AuthHttp.layerTailnet), Layer.provide(McpOAuthHttp.layer.pipe(Layer.provide(McpOAuth.layer))), Layer.provide(CloudHttp.layer), Layer.provide(OrchestrationHttp.layer), @@ -812,6 +828,12 @@ const layerMakeRoutes = Layer.mergeAll( ), ), Layer.provide(PreviewAutomationBroker.layer), + // Tailscale sign-in (HTTP) and its device list and peer list (WebSocket). + Layer.provide( + Layer.mergeAll(TailnetSignIn.layer, TailnetPeers.layer).pipe( + Layer.provide(TailscaleHost.layer), + ), + ), Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(layerDesktopAppUpdate))), Layer.provide(layerCommandReadiness), Layer.provide(ServerHttp.layerBrowserApiCors), @@ -828,6 +850,7 @@ const layerMakeServer = Layer.unwrap( const layerActivation = Layer.succeed(ServerActivation.ServerActivation, awaitActivation); const runtimeStateParked = yield* Deferred.make(); const tailscaleParked = yield* Deferred.make(); + const tailscaleSettled = yield* Deferred.make(); const cloudLinkParked = yield* Deferred.make(); const routesReady = yield* Deferred.make(); const layerLauncher = ServiceLauncherClient.layer; @@ -868,96 +891,14 @@ const layerMakeServer = Layer.unwrap( }), ); const layerTailscaleServe = config.tailscaleServeEnabled - ? Layer.effectDiscard( - Effect.acquireRelease( - Effect.gen(function* () { - yield* Deferred.succeed(tailscaleParked, undefined).pipe(Effect.orDie); - yield* awaitActivation; - const server = yield* HttpServer.HttpServer; - const address = server.address; - if (typeof address === "string" || !("port" in address)) { - return null; - } - - const localPort = address.port; - const localHost = loopbackUrlHostForAddress(address); - return yield* ensureTailscaleServe({ - localPort, - servePort: config.tailscaleServePort, - localHost, - }).pipe( - Effect.as({ - localPort, - localHost: localHost.replace(/^\[|\]$/gu, ""), - servePort: config.tailscaleServePort, - }), - Effect.tap(() => - Effect.logInfo("Tailscale Serve configured", { - localPort, - servePort: config.tailscaleServePort, - }), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to configure Tailscale Serve", { - cause, - localPort, - servePort: config.tailscaleServePort, - }).pipe(Effect.as(null)), - ), - ); - }), - (configured) => - configured - ? Effect.all( - [ - readTailscaleServeRoutesTo({ - host: configured.localHost, - port: configured.localPort, - }), - readTailscaleStatus, - ], - { concurrency: 2 }, - ).pipe( - // Another service may hold the root route by now, or it may sit under a - // former name, where `off` would act on the current one; only ours goes. - Effect.map(([routes, status]) => - routes.some( - (route) => - route.servePort === configured.servePort && - route.mount === "/" && - route.owner === undefined && - route.ambiguous === undefined && - route.host?.toLowerCase() === status.magicDnsName?.toLowerCase(), - ), - ), - Effect.orElseSucceed(() => false), - Effect.flatMap((ours) => - ours - ? disableTailscaleServe({ servePort: configured.servePort }).pipe( - Effect.tap(() => - Effect.logInfo("Tailscale Serve disabled", { - servePort: configured.servePort, - }), - ), - ) - : Effect.logInfo( - "Tailscale Serve left on; its root route is not known to be ours", - { - servePort: configured.servePort, - }, - ), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to disable Tailscale Serve", { - cause, - servePort: configured.servePort, - }), - ), - ) - : Effect.void, - ), - ) - : Layer.empty; + ? TailscaleServe.layer({ + stateDir: config.stateDir, + servePort: config.tailscaleServePort, + parked: tailscaleParked, + settled: tailscaleSettled, + awaitActivation, + }) + : TailscaleServe.layerReleaseLeftover({ stateDir: config.stateDir }); const layerCloudDesiredLinkReconcile = Layer.effectDiscard( Effect.gen(function* () { const cloudLink = yield* CloudLink.CloudLink; @@ -1156,6 +1097,9 @@ const layerMakeServer = Layer.unwrap( ], { concurrency: "unbounded" }, ).pipe(Effect.asVoid), + ...(config.tailscaleServeEnabled + ? { awaitTailscaleServe: Deferred.await(tailscaleSettled) } + : {}), }).pipe(Layer.provideMerge(layerRuntimeDependencies), Layer.provide(layerLauncher)); const layerRoutes = HttpRouter.serve(layerMakeRoutes.pipe(Layer.provide(layerLauncher)), { @@ -1177,11 +1121,7 @@ const layerMakeServer = Layer.unwrap( return layerServerApplication.pipe( Layer.provide(CloudLink.layer.pipe(Layer.provideMerge(ConnectReachability.layer))), Layer.provideMerge(layerRuntimeServices), - Layer.provideMerge( - McpSessionRegistry.layer.pipe( - Layer.provide(ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer))), - ), - ), + Layer.provideMerge(McpSessionRegistry.layer.pipe(Layer.provide(layerServerEnvironment))), Layer.provide(layerActivation), Layer.provideMerge( RelayTracing.layerServerRelayBroker.pipe(Layer.provide(ManagedPolicy.layer)), diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index 549aae79e8be5..4bbb6d7b6c6ff 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -24,6 +24,7 @@ import * as ProviderRuntimeRecovery from "./orchestration-v2/ProviderRuntimeReco import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunch from "./orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagement from "./orchestration-v2/ThreadManagementService.ts"; +import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ProjectService from "./project/ProjectService.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; @@ -165,6 +166,7 @@ it.effect("parks automatic pull until activation without delaying command readin NodeCrypto.layer, Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), Layer.mock(ExternalLauncher.ExternalLauncher)({}), + Layer.mock(DirectEndpoints.DirectEndpoints)({}), Layer.mock(HttpServer.HttpServer)({ address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 3773), }), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index bef61bbca56fd..a97c9e3441128 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -38,6 +38,7 @@ import * as ProviderRuntimeRecovery from "./orchestration-v2/ProviderRuntimeReco import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import * as ThreadLaunch from "./orchestration-v2/ThreadLaunchService.ts"; import * as ThreadManagement from "./orchestration-v2/ThreadManagementService.ts"; +import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ProjectService from "./project/ProjectService.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; @@ -211,6 +212,7 @@ it.effect.each([ Layer.mock(ServerSecretStore.ServerSecretStore)({ get: () => Effect.succeedNone }), Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), Layer.mock(ExternalLauncher.ExternalLauncher)({}), + Layer.mock(DirectEndpoints.DirectEndpoints)({}), Layer.mock(HttpServer.HttpServer)({ address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 3773), }), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 54fef56e311d8..b95b7dcca8f52 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -381,6 +381,8 @@ const runStartupPhase = (phase: string, effect: Effect.Effect) interface StartupOptions { readonly activate?: Effect.Effect; readonly awaitAuxiliaryParked?: Effect.Effect; + /** Settles after the first Tailscale Serve attempt, so headless output can show its URL. */ + readonly awaitTailscaleServe?: Effect.Effect; readonly abort?: (error: ServerRuntimeStartupError) => Effect.Effect; } @@ -614,6 +616,7 @@ const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: Startup ); if (serverConfig.startupPresentation === "headless") { yield* Effect.logDebug("startup phase: headless access info"); + yield* options?.awaitTailscaleServe ?? Effect.void; const accessInfo = yield* issueHeadlessServeAccessInfo(); yield* runStartupPhase( "headless.output", diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 49d9a48f991bc..5d7444db4fbc2 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -15,6 +15,7 @@ export const PersistedServerRuntimeState = Schema.Struct({ ownerId: Schema.optional(Schema.String), host: Schema.optional(Schema.String), privateNetworkOnly: Schema.optional(Schema.Boolean), + tailscaleOnly: Schema.optional(Schema.Boolean), port: Schema.Int, origin: Schema.String, // Dev is single-origin: browsers must pair through this URL, not `origin`. @@ -64,7 +65,7 @@ const runtimeOriginForConfig = ( export const makePersistedServerRuntimeState = (input: { readonly config: Pick< ServerConfig.ServerConfig["Service"], - "host" | "privateNetworkOnly" | "devUrl" + "host" | "privateNetworkOnly" | "tailscaleOnly" | "devUrl" >; readonly port: number; readonly serviceManaged?: boolean; @@ -75,6 +76,7 @@ export const makePersistedServerRuntimeState = (input: { pid: process.pid, ...(input.config.host ? { host: input.config.host } : {}), ...(input.config.privateNetworkOnly ? { privateNetworkOnly: true } : {}), + ...(input.config.tailscaleOnly ? { tailscaleOnly: true } : {}), port: input.port, origin: runtimeOriginForConfig(input.config, input.port), ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index fd56b4a4c86f3..aa1c36922a651 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -1,9 +1,20 @@ import type * as NodeOS from "node:os"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpServer } from "effect/http"; +import * as NetAddress from "effect/net/NetAddress"; +import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as ServerConfig from "./config.ts"; +import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import { acceptsPrivateNetworkConnection, + issueHeadlessServeAccessInfo, + makeTailnetConnectionFilter, resolveHeadlessConnectionString, } from "./startupAccess.ts"; @@ -55,6 +66,18 @@ it("prefers a LAN address over a tailnet one for a private-network pairing URL", ).toBe("http://192.168.1.42:3773"); }); +it("prints the Tailscale address for a Tailscale-only listener, not another VPN's", () => { + expect( + resolveHeadlessConnectionString("0.0.0.0", 3773, { + interfaces: { + CloudflareWARP: [ipv4("100.96.0.2")], + tailscale0: [ipv4("100.101.102.103")], + }, + tailscaleOnly: true, + }), + ).toBe("http://100.101.102.103:3773"); +}); + describe("acceptsPrivateNetworkConnection", () => { const accepts = (localAddress: string | undefined, remoteAddress: string | undefined) => acceptsPrivateNetworkConnection({ localAddress, remoteAddress }); @@ -73,3 +96,117 @@ describe("acceptsPrivateNetworkConnection", () => { expect(accepts(undefined, "192.168.1.31")).toBe(false); }); }); + +describe("makeTailnetConnectionFilter", () => { + const accepts = makeTailnetConnectionFilter({ + now: () => 0, + readInterfaces: () => ({ + lo0: [ipv4("127.0.0.1", true)], + // macOS names it utunN; Tailscale's IPv6 range identifies it. + utun4: [ + ipv4("100.101.102.103"), + { + address: "fd7a:115c:a1e0::1", + netmask: "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + family: "IPv6", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "fd7a:115c:a1e0::1/128", + scopeid: 0, + }, + ], + CloudflareWARP: [ipv4("100.96.0.2")], + en0: [ipv4("192.168.1.20")], + }), + }); + + it.each([ + ["loopback", "127.0.0.1", "127.0.0.1", true], + ["a tailnet peer", "100.101.102.103", "100.90.1.2", true], + ["a mapped tailnet peer", "::ffff:100.101.102.103", "::ffff:100.90.1.2", true], + ["a peer on another VPN's 100.64/10 address", "100.96.0.2", "100.90.1.2", false], + ["a LAN peer", "192.168.1.20", "192.168.1.31", false], + ["a LAN peer on the tailnet address", "100.101.102.103", "192.168.1.31", false], + ["loopback reaching a LAN address", "192.168.1.20", "127.0.0.1", false], + ["an unknown address", undefined, "100.90.1.2", false], + ] as const)("serves %s: %s", (_name, localAddress, remoteAddress, expected) => { + expect(accepts({ localAddress, remoteAddress })).toBe(expected); + }); + + it("follows Tailscale's addresses as it comes up and goes down", () => { + let now = 0; + const interfaces: Record> = { + en0: [ipv4("192.168.1.20")], + }; + const acceptsLater = makeTailnetConnectionFilter({ + now: () => now, + readInterfaces: () => interfaces, + }); + const peer = { localAddress: "100.101.102.103", remoteAddress: "100.90.1.2" }; + interfaces.tailscale0 = [ipv4("100.101.102.103")]; + expect(acceptsLater(peer)).toBe(false); + now = 5_000; + expect(acceptsLater(peer)).toBe(true); + // Tailscale goes down, and another VPN takes its address. + delete interfaces.tailscale0; + interfaces.wg0 = [ipv4("100.101.102.103")]; + now = 10_000; + expect(acceptsLater(peer)).toBe(false); + }); +}); + +// regression: #25348 - the startup pairing link pointed at localhost after Serve published the server. +it.effect("advertises the Tailscale Serve HTTPS endpoint in headless pairing output", () => + Effect.gen(function* () { + const baseConfig = yield* ServerConfig.ServerConfig; + const servedUrl = "https://devbox.tail1234.ts.net"; + + const accessInfo = yield* issueHeadlessServeAccessInfo().pipe( + Effect.provideService(ServerConfig.ServerConfig, { + ...baseConfig, + port: 24783, + host: undefined, + startupPresentation: "headless", + tailscaleServeEnabled: true, + tailscaleServePort: 443, + }), + Effect.provideService( + HttpServer.HttpServer, + HttpServer.HttpServer.of({ + address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 24783), + serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], + }), + ), + Effect.provideService( + EnvironmentAuth.EnvironmentAuth, + EnvironmentAuth.EnvironmentAuth.of({ + issueStartupPairingCredential: () => + Effect.succeed({ + id: "startup", + credential: "startup-token", + expiresAt: DateTime.makeUnsafe(0), + }), + } as unknown as EnvironmentAuth.EnvironmentAuth["Service"]), + ), + Effect.provideService( + DirectEndpoints.DirectEndpoints, + DirectEndpoints.DirectEndpoints.of({ + resolve: () => + Effect.succeed([ + { kind: "lan", httpBaseUrl: "http://192.168.1.42:24783/" }, + { kind: "tailnet", httpBaseUrl: `${servedUrl}/` }, + ]), + }), + ), + ); + + expect(accessInfo.connectionString).toBe(servedUrl); + expect(accessInfo.pairingUrl).toBe(`${servedUrl}/pair#token=startup-token`); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-startup-access-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + ), +); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 910584d110b31..701eceb1fa2e8 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,15 +1,21 @@ import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; -import { isPrivateNetworkHost, isSharedAddressSpaceHost } from "@t3tools/shared/hostClassification"; +import { + isPrivateNetworkHost, + isSharedAddressSpaceHost, + isTailnetHost, +} from "@t3tools/shared/hostClassification"; import { QrCode } from "@t3tools/shared/qrCode"; import { buildPairingAppLink } from "@t3tools/shared/remote"; +import { isTailscaleIpv4Address } from "@t3tools/tailscale"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/http"; import * as NetAddress from "effect/net/NetAddress"; import * as ServerConfig from "./config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; export interface HeadlessServeAccessInfo { readonly connectionString: string; @@ -71,6 +77,86 @@ export const acceptsPrivateNetworkConnection = (socket: { (address) => address !== undefined && isPrivateNetworkAddress(address), ); +const isIpv4Family = (family: string | number): boolean => family === "IPv4" || family === 4; + +const isIpv6Family = (family: string | number): boolean => family === "IPv6" || family === 6; + +/** The IPv4 address of a plain or IPv4-mapped IPv6 address, or null. */ +const plainIpv4 = (address: string): string | null => { + const ipv4 = address.replace(/^::ffff:/i, ""); + return NodeNet.isIPv4(ipv4) ? ipv4 : null; +}; + +const isTailnetIpv4Address = (address: string): boolean => { + const ipv4 = plainIpv4(address); + return ipv4 !== null && isTailscaleIpv4Address(ipv4); +}; + +const isLoopbackAddress = (address: string): boolean => + address === "::1" || plainIpv4(address)?.startsWith("127.") === true; + +// macOS utun names need Tailscale's IPv6 range to identify them; WARP shares its IPv4 range. +export const isTailscaleInterface = ( + name: string, + entries: ReadonlyArray<{ readonly family: string | number; readonly address: string }>, +): boolean => + /^tailscale/i.test(name) || + entries.some((entry) => isIpv6Family(entry.family) && isTailnetHost(entry.address)); + +const tailscaleInterfaceIpv4Addresses = (interfaces: NetworkInterfacesMap): ReadonlySet => + new Set( + Object.entries(interfaces).flatMap(([name, entries = []]) => + isTailscaleInterface(name, entries) + ? entries + .filter((entry) => isIpv4Family(entry.family) && isTailscaleIpv4Address(entry.address)) + .map((entry) => entry.address) + : [], + ), + ); + +/** + * A Tailscale-only listener serves loopback and tailnet peers and nothing + * else, so LAN devices cannot reach it even on a wildcard bind. The local end + * must be one of `tailscaleAddresses`, since other VPNs share 100.64/10. + */ +export const acceptsTailnetConnection = ( + socket: { + readonly localAddress?: string | undefined; + readonly remoteAddress?: string | undefined; + }, + tailscaleAddresses: ReadonlySet, +): boolean => { + const { localAddress, remoteAddress } = socket; + if (localAddress === undefined || remoteAddress === undefined) return false; + if (isLoopbackAddress(remoteAddress)) return isLoopbackAddress(localAddress); + const local = plainIpv4(localAddress); + return isTailnetIpv4Address(remoteAddress) && local !== null && tailscaleAddresses.has(local); +}; + +const TAILSCALE_ADDRESS_REFRESH_MS = 5_000; + +/** + * {@link acceptsTailnetConnection} against this machine's Tailscale interface + * addresses, read again before a connection once they are a few seconds old, so + * Tailscale coming up is picked up, and an address it gave up (another VPN can + * take it) stops matching. + */ +export const makeTailnetConnectionFilter = (options: { + readonly now: () => number; + readonly readInterfaces?: () => NetworkInterfacesMap; +}) => { + const readInterfaces = options.readInterfaces ?? NodeOS.networkInterfaces; + let addresses = tailscaleInterfaceIpv4Addresses(readInterfaces()); + let readAt = options.now(); + return (socket: Parameters[0]): boolean => { + if (options.now() - readAt >= TAILSCALE_ADDRESS_REFRESH_MS) { + addresses = tailscaleInterfaceIpv4Addresses(readInterfaces()); + readAt = options.now(); + } + return acceptsTailnetConnection(socket, addresses); + }; +}; + export const loopbackUrlHostForAddress = (address: NetAddress.InetAddress): string => address._tag === "InetAddressV6" && NetAddress.isLoopback(address.address) ? "[::1]" @@ -79,14 +165,11 @@ export const loopbackUrlHostForAddress = (address: NetAddress.InetAddress): stri const normalizeHost = (host: string): string => host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; -const isIpv4Family = (family: string | number): boolean => family === "IPv4" || family === 4; - -const isIpv6Family = (family: string | number): boolean => family === "IPv6" || family === 6; - const resolveHeadlessConnectionHost = ( host: string | undefined, interfaces: NetworkInterfacesMap, privateNetworkOnly: boolean, + tailscaleOnly: boolean, ): string => { if (!host) { return "localhost"; @@ -96,6 +179,10 @@ const resolveHeadlessConnectionHost = ( return normalizeHost(host); } + if (tailscaleOnly) { + const [tailnet] = tailscaleInterfaceIpv4Addresses(interfaces); + return tailnet ?? "localhost"; + } const interfaceEntries = Object.entries(interfaces).flatMap(([name, entries]) => privateNetworkOnly && VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? []), ); @@ -129,12 +216,14 @@ export const resolveHeadlessConnectionString = ( options: { readonly interfaces?: NetworkInterfacesMap; readonly privateNetworkOnly?: boolean | undefined; + readonly tailscaleOnly?: boolean | undefined; } = {}, ): string => { const connectionHost = resolveHeadlessConnectionHost( host, options.interfaces ?? NodeOS.networkInterfaces(), options.privateNetworkOnly === true, + options.tailscaleOnly === true, ); return `http://${formatHostForUrl(connectionHost)}:${port}`; }; @@ -185,7 +274,7 @@ export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): [ "T3 Code server is ready.", `Connection string: ${accessInfo.connectionString}`, - `Token: ${accessInfo.token}`, + `Pairing code: ${accessInfo.token}`, `Pairing URL: ${accessInfo.pairingUrl}`, `App link: ${buildPairingAppLink(accessInfo.pairingUrl)}`, "", @@ -197,11 +286,22 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI const serverConfig = yield* ServerConfig.ServerConfig; const httpServer = yield* HttpServer.HttpServer; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const connectionString = resolveHeadlessConnectionString( - serverConfig.host, - resolveListeningPort(httpServer.address, serverConfig.port), - { privateNetworkOnly: serverConfig.privateNetworkOnly }, - ); + // Direct endpoints list the Serve URL only once it answers as this server. + const servedEndpoint = serverConfig.tailscaleServeEnabled + ? (yield* (yield* DirectEndpoints.DirectEndpoints).resolve()).find((endpoint) => + endpoint.httpBaseUrl.startsWith("https://"), + ) + : undefined; + const connectionString = servedEndpoint + ? new URL(servedEndpoint.httpBaseUrl).origin + : resolveHeadlessConnectionString( + serverConfig.host, + resolveListeningPort(httpServer.address, serverConfig.port), + { + privateNetworkOnly: serverConfig.privateNetworkOnly, + tailscaleOnly: serverConfig.tailscaleOnly, + }, + ); const issued = yield* serverAuth.issueStartupPairingCredential(); return { diff --git a/apps/server/src/tailscale/TailscaleHost.test.ts b/apps/server/src/tailscale/TailscaleHost.test.ts new file mode 100644 index 0000000000000..c50ea2ae11812 --- /dev/null +++ b/apps/server/src/tailscale/TailscaleHost.test.ts @@ -0,0 +1,117 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/process"; +import { TestClock } from "effect/testing"; + +import * as ServerConfig from "../config.ts"; +import * as TailscaleHost from "./TailscaleHost.ts"; + +const WHOIS_JSON = JSON.stringify({ + Node: { StableID: "nStudio", User: 7, Addresses: ["100.88.1.2/32"], AllowedIPs: [] }, +}); + +interface Spawns { + count: number; + /** Status reads so far; each names the account `n`. */ + statusReads?: number; + /** Holds the first status read until it completes, and says when it started. */ + firstStatus?: { + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; +} + +const layerTest = (whoisSpawns: Spawns) => + TailscaleHost.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + whoisSpawns.count += 1; + const { args } = command as unknown as { readonly args: ReadonlyArray }; + const isStatus = args[0] === "status"; + const read = isStatus + ? (whoisSpawns.statusReads = (whoisSpawns.statusReads ?? 0) + 1) + : 0; + const gate = isStatus && read === 1 ? whoisSpawns.firstStatus : undefined; + const stdout = isStatus + ? JSON.stringify({ BackendState: "Running", Self: { ID: `n${read}` } }) + : WHOIS_JSON; + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: + gate === undefined + ? Effect.succeed(ChildProcessSpawner.ExitCode(0)) + : Deferred.succeed(gate.started, undefined).pipe( + Effect.andThen(Deferred.await(gate.release)), + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ), + Layer.succeed(HostProcess.Platform, "linux"), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-tailscale-host-test-" }), + ), + ), + ); + +it.layer(NodeServices.layer)("TailscaleHost", (it) => { + it.effect("a fresh status read never joins a kept read already running", () => { + const spawns: Spawns = { + count: 0, + firstStatus: { started: Deferred.makeUnsafe(), release: Deferred.makeUnsafe() }, + }; + return Effect.gen(function* () { + const host = yield* TailscaleHost.TailscaleHost; + const kept = yield* host.status.pipe(Effect.forkChild); + yield* Deferred.await(spawns.firstStatus!.started); + // The kept read began before, say, an account switch; the fresh one must not share it. + expect((yield* host.freshStatus).selfNodeId).toBe("n2"); + yield* Deferred.succeed(spawns.firstStatus!.release, undefined); + expect((yield* Fiber.join(kept)).selfNodeId).toBe("n1"); + }).pipe(Effect.provide(layerTest(spawns))); + }); + + it.effect("asks Tailscale on every whois and allows 10 lookups per address a minute", () => { + const whoisSpawns = { count: 0 }; + return Effect.gen(function* () { + const host = yield* TailscaleHost.TailscaleHost; + const address = "100.88.1.2"; + + for (let lookup = 1; lookup <= 10; lookup += 1) { + yield* host.whois(address); + } + expect(whoisSpawns.count).toBe(10); + + const refused = yield* host.whois(address).pipe(Effect.flip); + expect(refused._tag).toBe("TailscaleWhoisRateLimitedError"); + expect(whoisSpawns.count).toBe(10); + // The limit is per address. + yield* host.whois("100.88.1.3"); + expect(whoisSpawns.count).toBe(11); + + yield* TestClock.adjust("60 seconds"); + expect((yield* host.whois(address)).stableNodeId).toBe("nStudio"); + expect(whoisSpawns.count).toBe(12); + }).pipe(Effect.provide(layerTest(whoisSpawns))); + }); +}); diff --git a/apps/server/src/tailscale/TailscaleHost.ts b/apps/server/src/tailscale/TailscaleHost.ts new file mode 100644 index 0000000000000..3e909ee59b99e --- /dev/null +++ b/apps/server/src/tailscale/TailscaleHost.ts @@ -0,0 +1,150 @@ +import * as NodeOS from "node:os"; + +import { + isTailscaleIpv4Address, + readTailscaleStatus, + readTailscaleWhois, + type TailscaleCommandError, + type TailscaleStatus, + type TailscaleStatusParseError, + type TailscaleWhois, + type TailscaleWhoisParseError, +} from "@t3tools/tailscale"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ServerConfig from "../config.ts"; + +const STATUS_TTL = Duration.seconds(5); +// Lookups per address from sign-in requests. Each one spawns the CLI. +const WHOIS_LOOKUPS_PER_WINDOW = 10; +const WHOIS_WINDOW_MS = 60_000; + +export class TailscaleWhoisRateLimitedError extends Schema.TaggedError()( + "TailscaleWhoisRateLimitedError", + {}, +) { + override get message(): string { + return "Too many Tailscale whois lookups for one address."; + } +} + +export type TailscaleStatusError = TailscaleCommandError | TailscaleStatusParseError; +export type TailscaleWhoisError = + | TailscaleCommandError + | TailscaleWhoisParseError + | TailscaleWhoisRateLimitedError; + +/** This host's Tailscale daemon, with reads shared between callers. */ +export class TailscaleHost extends Context.Service< + TailscaleHost, + { + /** + * A Tailscale feature is on, or an interface holds a 100.64/10 address. + * Reads nobody asked for, such as health facts, skip the CLI otherwise. + */ + readonly inUse: Effect.Effect; + /** `tailscale status`, single-flight and kept for a few seconds. */ + readonly status: Effect.Effect; + /** + * `tailscale status` read now. Sign-in decides with it, since a status kept from before + * a switch to another Tailscale account names the old tailnet's policy and owner. + */ + readonly freshStatus: Effect.Effect; + /** + * Who owns a tailnet address, read now and rate limited per address. Policy scans and + * rechecks (`fromServer`) skip the limit; their callers bound how often they run. + */ + readonly whois: ( + address: string, + options?: { readonly fromServer?: boolean }, + ) => Effect.Effect; + } +>()("t3/tailscale/TailscaleHost") {} + +/** + * Sign-in trusts that whois JSON omits only empty fields, so a missing `Tags` + * or `Sharer` reads as none. That was checked on 1.102.2. Releases before + * 1.80 (early 2025) are untested and well past Tailscale's update cadence, so + * they are refused rather than trusted to report every field. + */ +export const MINIMUM_TAILSCALE_VERSION = "1.80.0"; + +export const isSupportedTailscaleVersion = (version: string | null): boolean => { + const parse = (value: string) => /^(\d+)\.(\d+)\.(\d+)/u.exec(value)?.slice(1, 4).map(Number); + const actual = version === null ? undefined : parse(version); + const minimum = parse(MINIMUM_TAILSCALE_VERSION)!; + if (actual === undefined) return false; + const index = actual.findIndex((part, position) => part !== minimum[position]); + return index === -1 || actual[index]! > minimum[index]!; +}; + +/** + * The Tailscale user that owns this host: its own user when untagged. A tagged + * node has no user identity, so it needs the owner `t3 up` stored. + */ +export const tailnetOwnerUserId = ( + status: Pick, + configuredOwnerUserId: string | undefined, +): string | null => + status.selfTags.length === 0 ? status.selfUserId : (configuredOwnerUserId ?? null); + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const featureOn = + config.tailscaleServeEnabled || + config.tailscaleSignInEnabled === true || + config.tailscaleOnly === true; + const inUse = featureOn + ? Effect.succeed(true) + : Effect.sync(() => + Object.values(NodeOS.networkInterfaces()).some((entries) => + entries?.some((entry) => !entry.internal && isTailscaleIpv4Address(entry.address)), + ), + ); + // Its own read every time: joining one already running could answer from before a switch. + const freshStatus = readTailscaleStatus(featureOn ? "active" : "passive").pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const status = yield* Effect.cachedWithTTL(freshStatus, STATUS_TTL); + + const lookupsByAddress = yield* Ref.make(new Map>()); + const takeLookup = Effect.fnUntraced(function* (address: string) { + const now = yield* Clock.currentTimeMillis; + return yield* Ref.modify(lookupsByAddress, (current) => { + const next = new Map>(); + for (const [key, times] of current) { + const recent = times.filter((time) => now - time < WHOIS_WINDOW_MS); + if (recent.length > 0) next.set(key, recent); + } + const recent = next.get(address) ?? []; + if (recent.length >= WHOIS_LOOKUPS_PER_WINDOW) return [false, next] as const; + next.set(address, [...recent, now]); + return [true, next] as const; + }); + }); + + // Not cached: after a switch to another Tailscale account, the same address names a + // device on another tailnet, and a cached answer would carry the old one's grant. + const whois: TailscaleHost["Service"]["whois"] = Effect.fn("TailscaleHost.whois")( + function* (address, options) { + if (options?.fromServer !== true && !(yield* takeLookup(address))) { + return yield* new TailscaleWhoisRateLimitedError({}); + } + return yield* readTailscaleWhois(address).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + }, + ); + + return TailscaleHost.of({ inUse, status, freshStatus, whois }); +}); + +export const layer = Layer.effect(TailscaleHost, make); diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts new file mode 100644 index 0000000000000..267099605fcdb --- /dev/null +++ b/apps/server/src/tailscaleServe.test.ts @@ -0,0 +1,820 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { HttpServer } from "effect/http"; +import * as NetAddress from "effect/net/NetAddress"; +import { ChildProcessSpawner } from "effect/process"; +import { TestClock } from "effect/testing"; + +import * as TailscaleServe from "./tailscaleServe.ts"; + +const encoder = new TextEncoder(); +const SERVE_PORT = 18443; +const OTHER_PORT = 18444; +const LOCAL_TARGET = "http://127.0.0.1:18638"; + +interface FakeOptions { + /** `serve --bg` is refused this many times (operator not granted, Tailscale still starting). */ + readonly refusedServeAttempts?: number; + /** The attempt after the refusals never answers. */ + readonly hangs?: boolean; + /** The daemon applies the mapping at once but the CLI exits only after this delay. */ + readonly ackDelay?: Duration.Input; + /** The Serve root starts out proxying to this other server. */ + readonly heldBy?: string; + /** `serve status` fails this many times, as while the daemon is down. */ + readonly statusFailures?: number; + /** `serve status` fails once right after the daemon takes the mapping. */ + readonly statusFailsAfterServe?: boolean; + /** The Serve root on OTHER_PORT, as a run on that port left it. */ + readonly heldOnOtherPort?: string; + /** The claim record a previous run left. */ + readonly recorded?: string; + /** Writing the claim record fails, as on a read-only state dir. */ + readonly recordFails?: boolean; + /** This machine's node ID on the live Tailscale account; null when signed out. */ + readonly account?: string | null; + /** Every profile `tailscale switch --list` names, instead of just `profileId`. */ + readonly profileList?: ReadonlyArray<{ readonly id: string; readonly selected: boolean }>; + readonly platform?: "linux" | "win32"; + /** The live account's tailnet and user, which name its login profile. */ + readonly profile?: { readonly tailnet: string; readonly userId: string }; + /** The login profile ID `tailscale switch --list` names. Without it, the CLI is too old. */ + readonly profileId?: string; + /** Tailscale switches to this account, whose Serve config is empty, on the next Serve read. */ + readonly switchOnServeRead?: string; +} + +// Stands in for the tailscale CLI and keeps the Serve root like the daemon does. +const makeFakeTailscale = (options: FakeOptions) => { + const state = { + serveAttempts: 0, + statusCalls: 0, + releases: 0, + root: options.heldBy ?? null, + otherRoot: options.heldOnOtherPort ?? null, + statusFailurePending: false, + account: options.account === undefined ? "n1" : options.account, + }; + // Completes when the daemon takes the mapping, before the CLI exits. + const mappingApplied = Deferred.makeUnsafe(); + const serveStatus = () => { + const roots = [ + [SERVE_PORT, state.root], + [OTHER_PORT, state.otherRoot], + ].filter(([, root]) => root !== null); + return roots.length === 0 + ? "null\n" + : JSON.stringify({ + TCP: Object.fromEntries(roots.map(([port]) => [port, { HTTPS: true }])), + Web: Object.fromEntries( + roots.map(([port, root]) => [ + `desktop.tail.ts.net:${port}`, + { Handlers: { "/": { Proxy: root } } }, + ]), + ), + }); + }; + const handle = ( + args: ReadonlyArray, + ): { code: number; stdout?: string; stderr?: string; ackDelay?: Duration.Input } | undefined => { + const command = args.join(" "); + if (command === "switch --list --json" && options.profileList !== undefined) { + return { code: 0, stdout: JSON.stringify(options.profileList) }; + } + if (command === "switch --list --json") { + return options.profileId === undefined + ? { code: 1, stderr: "flag provided but not defined: -json" } + : { + code: 0, + stdout: JSON.stringify([ + { + id: options.profileId, + tailnet: "example.com", + account: "me@example.com", + selected: true, + }, + ]), + }; + } + if (command === "status --json") { + return { + code: 0, + stdout: JSON.stringify({ + BackendState: "Running", + Self: { ID: state.account ?? undefined, UserID: options.profile?.userId }, + CurrentTailnet: { Name: options.profile?.tailnet }, + }), + }; + } + if (command === "serve status --json") { + if (options.switchOnServeRead !== undefined && state.account !== options.switchOnServeRead) { + state.account = options.switchOnServeRead; + state.root = null; + } + state.statusCalls += 1; + if (state.statusCalls <= (options.statusFailures ?? 0) || state.statusFailurePending) { + state.statusFailurePending = false; + return { code: 1, stderr: "failed to connect to local tailscaled" }; + } + return { code: 0, stdout: serveStatus() }; + } + if (args[0] === "serve" && args[1] === "--bg") { + state.serveAttempts += 1; + if (state.serveAttempts <= (options.refusedServeAttempts ?? 0)) { + return { code: 1, stderr: "Access denied: serve config denied" }; + } + if (options.hangs) { + return undefined; + } + state.root = args.at(-1) ?? null; + state.statusFailurePending = options.statusFailsAfterServe === true; + Deferred.doneUnsafe(mappingApplied, Effect.void); + return { code: 0, ...(options.ackDelay === undefined ? {} : { ackDelay: options.ackDelay }) }; + } + if (command === `serve --https=${OTHER_PORT} --set-path=/ off`) { + state.otherRoot = null; + return { code: 0 }; + } + if (command === `serve --https=${SERVE_PORT} --set-path=/ off`) { + state.releases += 1; + state.root = null; + return { code: 0 }; + } + return { code: 1, stderr: "unexpected command" }; + }; + const spawner = ChildProcessSpawner.make((command) => { + const { args } = command as unknown as { readonly args: ReadonlyArray }; + const result = handle(args); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: + result === undefined + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(result.code)).pipe( + Effect.delay(result.ackDelay ?? 0), + ), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result?.stdout ?? "")), + stderr: Stream.make(encoder.encode(result?.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + return { state, spawner, mappingApplied, platform: options.platform ?? "linux" }; +}; + +const STATE_DIR = "/state"; +const CLAIM_PATH = "/state/tailscale-serve-claim.json"; +// A record made on login profile `id`, as a CLI that lists profiles writes it. +const profileRecord = (id: string) => + JSON.stringify({ + [id]: { + localPort: 18638, + localHost: "127.0.0.1", + servePort: SERVE_PORT, + label: "me@example.com on example.com", + profileId: id, + }, + }); +// `keys` is set on records this server writes, and absent on ones a test seeds. +const record = ( + localPort: number, + servePort: number, + account = "n1", + keys?: ReadonlyArray, + nodeId?: string, +) => + JSON.stringify({ + [account]: { + localPort, + localHost: "127.0.0.1", + servePort, + label: account, + ...(keys === undefined ? {} : { keys }), + ...(nodeId === undefined ? {} : { nodeId }), + }, + }); + +// In memory, so writes land within the test clock's steps. +const memoryFileSystem = (files: Map, writeFails = false) => { + const missing = FileSystem.makeNoop({}); + return FileSystem.makeNoop({ + readFileString: (path) => { + const contents = files.get(path); + return contents === undefined ? missing.readFileString(path) : Effect.succeed(contents); + }, + writeFileString: (path, data) => + writeFails + ? missing.writeFileString(path, data) + : Effect.sync(() => void files.set(path, data)), + makeDirectory: () => Effect.void, + exists: (path) => Effect.succeed(files.has(path)), + rename: (from, to) => + Effect.sync(() => { + const contents = files.get(from); + if (contents !== undefined) files.set(to, contents); + files.delete(from); + }), + remove: (path) => Effect.sync(() => void files.delete(path)), + }); +}; + +const fakeLayers = ( + fake: ReturnType, + files: Map, + writeFails = false, +) => + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, fake.spawner), + Layer.succeed(HostProcess.Platform, fake.platform), + Layer.succeed(FileSystem.FileSystem, memoryFileSystem(files, writeFails)), + Path.layer, + ); + +const startTailscaleServe = Effect.fn(function* (options: FakeOptions = {}) { + const fake = makeFakeTailscale(options); + const files = new Map(options.recorded === undefined ? [] : [[CLAIM_PATH, options.recorded]]); + const parked = yield* Deferred.make(); + const settled = yield* Deferred.make(); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layer({ + stateDir: STATE_DIR, + servePort: SERVE_PORT, + parked, + settled, + awaitActivation: Effect.void, + }).pipe( + Layer.provide( + Layer.merge( + fakeLayers(fake, files, options.recordFails), + Layer.mock(HttpServer.HttpServer)({ + address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 18638), + }), + ), + ), + ), + scope, + ); + return { state: fake.state, mappingApplied: fake.mappingApplied, scope, settled, files }; +}); + +describe("TailscaleServe", () => { + // regression: #16238 - a refused first `tailscale serve` left Serve off + // until the server was restarted. + it.effect("configures Serve once a later attempt succeeds, without a restart", () => + Effect.gen(function* () { + const { state, settled } = yield* startTailscaleServe({ refusedServeAttempts: 1 }); + // Headless startup prints its pairing URL after the first attempt, not after every retry. + yield* Deferred.await(settled); + assert.equal(state.root, null); + + yield* TestClock.adjust("1 hour"); + + assert.equal(state.root, LOCAL_TARGET, "Serve was never configured after the first refusal"); + }), + ); + + it.effect("keeps retrying with backoff capped at 30 seconds", () => + Effect.gen(function* () { + // Attempts fall at 0, 1, 3, 7, 15 and 31 s, then every 30 s. + const { state } = yield* startTailscaleServe({ refusedServeAttempts: 8 }); + let elapsedMs = 0; + const attemptsAt = Effect.fn(function* (ms: number) { + yield* TestClock.adjust(ms - elapsedMs); + elapsedMs = ms; + return state.serveAttempts; + }); + + assert.equal(yield* attemptsAt(999), 1, "retried before the first backoff delay"); + assert.equal(yield* attemptsAt(1_000), 2); + assert.equal(yield* attemptsAt(30_999), 5); + assert.equal(yield* attemptsAt(31_000), 6); + assert.equal(yield* attemptsAt(60_999), 6, "backoff not capped at 30 seconds"); + assert.equal(yield* attemptsAt(61_000), 7); + assert.equal(yield* attemptsAt(91_000), 8); + assert.equal(state.root, null); + assert.equal(yield* attemptsAt(121_000), 9); + assert.equal(state.root, LOCAL_TARGET); + }), + ); + + it.effect("removes its Serve root on shutdown after a later attempt succeeded", () => + Effect.gen(function* () { + const { state, scope, files } = yield* startTailscaleServe({ refusedServeAttempts: 1 }); + yield* TestClock.adjust("1 hour"); + assert.isTrue(files.has(CLAIM_PATH)); + + yield* Scope.close(scope, Exit.void); + + assert.equal(state.releases, 1); + assert.equal(state.root, null); + assert.isFalse(files.has(CLAIM_PATH)); + }), + ); + + it.effect("removes the mapping a run on another Serve port left, then claims its own", () => + Effect.gen(function* () { + const { state, settled, files } = yield* startTailscaleServe({ + heldOnOtherPort: LOCAL_TARGET, + recorded: record(18638, OTHER_PORT), + }); + yield* Deferred.await(settled); + + assert.equal(state.otherRoot, null); + assert.equal(state.root, LOCAL_TARGET); + assert.include(files.get(CLAIM_PATH), `"servePort":${SERVE_PORT}`); + }), + ); + + it.effect("keeps the record of an older local port until its mapping is removed", () => + Effect.gen(function* () { + const older = record(18637, SERVE_PORT); + const { scope, settled, files } = yield* startTailscaleServe({ + heldBy: "http://127.0.0.1:18637", + recorded: older, + statusFailures: 1, + }); + yield* Deferred.await(settled); + + yield* Scope.close(scope, Exit.void); + + assert.equal(files.get(CLAIM_PATH), older); + }), + ); + + it.effect("does not start Serve until its cleanup record is saved", () => + Effect.gen(function* () { + const { state, settled } = yield* startTailscaleServe({ recordFails: true }); + yield* Deferred.await(settled); + yield* TestClock.adjust("1 minute"); + + assert.equal(state.serveAttempts, 0); + assert.equal(state.root, null); + }), + ); + + it.effect("with Serve off, removes a mapping a stopped run left, once Tailscale answers", () => + Effect.gen(function* () { + const fake = makeFakeTailscale({ heldBy: LOCAL_TARGET, statusFailures: 2 }); + const files = new Map([[CLAIM_PATH, record(18638, SERVE_PORT)]]); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( + Layer.provide(fakeLayers(fake, files)), + ), + scope, + ); + yield* TestClock.adjust("1 minute"); + + assert.equal(fake.state.releases, 1); + assert.equal(fake.state.root, null); + assert.isFalse(files.has(CLAIM_PATH)); + yield* Scope.close(scope, Exit.void); + }), + ); + + // Each Tailscale account keeps its own Serve config, so the mapping is only visible there. + it.effect("with Serve off, keeps another account's record until Tailscale switches to it", () => + Effect.gen(function* () { + const fake = makeFakeTailscale({ heldBy: LOCAL_TARGET, account: "work" }); + const files = new Map([[CLAIM_PATH, record(18638, SERVE_PORT, "personal")]]); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( + Layer.provide(fakeLayers(fake, files)), + ), + scope, + ); + yield* TestClock.adjust("1 minute"); + assert.equal(fake.state.releases, 0); + assert.isTrue(files.has(CLAIM_PATH)); + + fake.state.account = "personal"; + yield* TestClock.adjust("10 minutes"); + + assert.equal(fake.state.releases, 1); + assert.isFalse(files.has(CLAIM_PATH)); + yield* Scope.close(scope, Exit.void); + }), + ); + + it.effect("records the account a retry claims on, so shutdown removes that mapping", () => + Effect.gen(function* () { + const { state, scope, settled, files } = yield* startTailscaleServe({ + refusedServeAttempts: 1, + }); + yield* Deferred.await(settled); + state.account = "work"; + yield* TestClock.adjust("1 second"); + assert.equal(state.root, LOCAL_TARGET); + + yield* Scope.close(scope, Exit.void); + + assert.equal(state.releases, 1); + // The refused try on the first account may have left nothing; its record waits there. + assert.equal(files.get(CLAIM_PATH), record(18638, SERVE_PORT, "n1", ["n1"], "n1")); + }), + ); + + it.effect("waits on an unreadable record instead of replacing it", () => + Effect.gen(function* () { + const { state, settled, files } = yield* startTailscaleServe({ recorded: "{" }); + yield* Deferred.await(settled); + yield* TestClock.adjust("1 minute"); + + assert.equal(state.serveAttempts, 0); + assert.equal(files.get(CLAIM_PATH), "{"); + }), + ); + + it.effect("matches a record to its account after the device is re-added with a new node ID", () => + Effect.gen(function* () { + const fake = makeFakeTailscale({ + heldBy: LOCAL_TARGET, + account: "nReAdded", + profile: { tailnet: "example.com", userId: "7" }, + }); + const files = new Map([[CLAIM_PATH, record(18638, SERVE_PORT, "example.com/7")]]); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( + Layer.provide(fakeLayers(fake, files)), + ), + scope, + ); + yield* TestClock.adjust("1 second"); + + assert.equal(fake.state.releases, 1); + assert.isFalse(files.has(CLAIM_PATH)); + yield* Scope.close(scope, Exit.void); + }), + ); + + it.effect("matches a record to its login profile after the host is tagged", () => + Effect.gen(function* () { + // Tagging changes the host's user, and with it the tailnet-and-user key. + const fake = makeFakeTailscale({ + heldBy: LOCAL_TARGET, + profileId: "prof1", + profile: { tailnet: "example.com", userId: "tagged-devices" }, + }); + const files = new Map([[CLAIM_PATH, profileRecord("prof1")]]); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( + Layer.provide(fakeLayers(fake, files)), + ), + scope, + ); + yield* TestClock.adjust("1 second"); + + assert.equal(fake.state.releases, 1); + assert.isFalse(files.has(CLAIM_PATH)); + yield* Scope.close(scope, Exit.void); + }), + ); + + it.effect("matches a record an older Tailscale CLI keyed by tailnet and user", () => + Effect.gen(function* () { + const fake = makeFakeTailscale({ + heldBy: LOCAL_TARGET, + profileId: "prof1", + profile: { tailnet: "example.com", userId: "7" }, + }); + const files = new Map([ + [CLAIM_PATH, record(18638, SERVE_PORT, "example.com/7", undefined, "n1")], + ]); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( + Layer.provide(fakeLayers(fake, files)), + ), + scope, + ); + yield* TestClock.adjust("1 second"); + + assert.equal(fake.state.releases, 1); + assert.isFalse(files.has(CLAIM_PATH)); + yield* Scope.close(scope, Exit.void); + }), + ); + + it.effect("drops the record of a login profile that `tailscale logout` deleted", () => + Effect.gen(function* () { + const fake = makeFakeTailscale({ profileId: "prof2" }); + const files = new Map([[CLAIM_PATH, profileRecord("prof1")]]); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( + Layer.provide(fakeLayers(fake, files)), + ), + scope, + ); + yield* TestClock.adjust("1 second"); + + assert.isFalse(files.has(CLAIM_PATH)); + yield* Scope.close(scope, Exit.void); + }), + ); + + it.effect("matches a record by any key it was written with when a profile read fails", () => + Effect.gen(function* () { + // `switch --list` fails this time, so only the tailnet and user name the account. + const fake = makeFakeTailscale({ + heldBy: LOCAL_TARGET, + profile: { tailnet: "example.com", userId: "7" }, + }); + const files = new Map([ + [ + CLAIM_PATH, + JSON.stringify({ + prof1: { + localPort: 18638, + localHost: "127.0.0.1", + servePort: SERVE_PORT, + label: "me@example.com on example.com", + profileId: "prof1", + keys: ["prof1", "example.com/7", "n1"], + nodeId: "n1", + }, + }), + ], + ]); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( + Layer.provide(fakeLayers(fake, files)), + ), + scope, + ); + yield* TestClock.adjust("1 second"); + + assert.equal(fake.state.releases, 1); + assert.isFalse(files.has(CLAIM_PATH)); + yield* Scope.close(scope, Exit.void); + }), + ); + + it.effect("with profiles unlisted, keeps a record from another node on the same user", () => + Effect.gen(function* () { + // Another profile on the same tailnet and user shares every key but the profile and node. + const fake = makeFakeTailscale({ + heldBy: LOCAL_TARGET, + account: "nB", + profile: { tailnet: "example.com", userId: "7" }, + }); + const recorded = JSON.stringify({ + profA: { + localPort: 18638, + localHost: "127.0.0.1", + servePort: SERVE_PORT, + label: "me@example.com on example.com", + profileId: "profA", + keys: ["profA", "example.com/7", "nA"], + nodeId: "nA", + }, + // With no node ID to compare, ownership is uncertain, so it stays too. + profC: { + localPort: 18638, + localHost: "127.0.0.1", + servePort: SERVE_PORT, + label: "me@example.com on example.com", + profileId: "profC", + keys: ["profC", "example.com/7"], + }, + }); + const files = new Map([[CLAIM_PATH, recorded]]); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( + Layer.provide(fakeLayers(fake, files)), + ), + scope, + ); + yield* TestClock.adjust("1 second"); + + assert.equal(fake.state.releases, 0); + assert.equal(files.get(CLAIM_PATH), recorded); + yield* Scope.close(scope, Exit.void); + }), + ); + + it.effect("on Windows, keeps the record of a profile Tailscale does not list", () => + Effect.gen(function* () { + // Windows lists only the signed-in Windows user's profiles, so the profile may exist. + const fake = makeFakeTailscale({ profileId: "prof2", platform: "win32" }); + const files = new Map([[CLAIM_PATH, profileRecord("prof1")]]); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( + Layer.provide(fakeLayers(fake, files)), + ), + scope, + ); + yield* TestClock.adjust("1 second"); + + assert.equal(files.get(CLAIM_PATH), profileRecord("prof1")); + yield* Scope.close(scope, Exit.void); + }), + ); + + it.effect("a record from another profile on the same tailnet and user is not this one's", () => + Effect.gen(function* () { + // Two Windows users signed in to one Tailscale account each have their own profile. + const fake = makeFakeTailscale({ + heldBy: LOCAL_TARGET, + profileId: "profB", + profile: { tailnet: "example.com", userId: "7" }, + platform: "win32", + }); + const recorded = JSON.stringify({ + profA: { + localPort: 18638, + localHost: "127.0.0.1", + servePort: SERVE_PORT, + label: "me@example.com on example.com", + profileId: "profA", + keys: ["profA", "example.com/7", "nA"], + }, + }); + const files = new Map([[CLAIM_PATH, recorded]]); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( + Layer.provide(fakeLayers(fake, files)), + ), + scope, + ); + yield* TestClock.adjust("1 second"); + + assert.equal(fake.state.releases, 0); + assert.equal(files.get(CLAIM_PATH), recorded); + yield* Scope.close(scope, Exit.void); + }), + ); + + it.effect("signed out of the last profile, drops its record", () => + Effect.gen(function* () { + const fake = makeFakeTailscale({ account: null, profileList: [] }); + const files = new Map([[CLAIM_PATH, profileRecord("prof1")]]); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( + Layer.provide(fakeLayers(fake, files)), + ), + scope, + ); + yield* TestClock.adjust("1 second"); + + assert.isFalse(files.has(CLAIM_PATH)); + yield* Scope.close(scope, Exit.void); + }), + ); + + it.effect( + "keeps an older port's record when Tailscale switches accounts during its removal", + () => + Effect.gen(function* () { + const older = record(18638, OTHER_PORT); + const { settled, files } = yield* startTailscaleServe({ + heldOnOtherPort: LOCAL_TARGET, + recorded: older, + switchOnServeRead: "work", + }); + yield* Deferred.await(settled); + + assert.equal(files.get(CLAIM_PATH), older); + }), + ); + + it.effect("keeps the record when Tailscale switches accounts during its removal", () => + Effect.gen(function* () { + const fake = makeFakeTailscale({ heldBy: LOCAL_TARGET, switchOnServeRead: "work" }); + const files = new Map([[CLAIM_PATH, record(18638, SERVE_PORT)]]); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( + Layer.provide(fakeLayers(fake, files)), + ), + scope, + ); + yield* TestClock.adjust("1 second"); + + assert.equal(fake.state.releases, 0); + assert.equal(files.get(CLAIM_PATH), record(18638, SERVE_PORT)); + yield* Scope.close(scope, Exit.void); + }), + ); + + it.effect("keeps its record on shutdown after Tailscale switched to another account", () => + Effect.gen(function* () { + const { state, scope, settled, files } = yield* startTailscaleServe(); + yield* Deferred.await(settled); + state.account = "work"; + + yield* Scope.close(scope, Exit.void); + + assert.equal(state.releases, 0); + assert.equal(files.get(CLAIM_PATH), record(18638, SERVE_PORT, "n1", ["n1"], "n1")); + }), + ); + + it.effect("removes the Serve root on shutdown while the CLI has yet to acknowledge it", () => + Effect.gen(function* () { + const { state, mappingApplied, scope } = yield* startTailscaleServe({ + ackDelay: "2 seconds", + }); + yield* Deferred.await(mappingApplied); + assert.equal(state.serveAttempts, 1); + + const shutdown = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild); + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(shutdown); + + assert.equal(state.releases, 1, "a mapping the daemon applied outlived shutdown"); + }), + ); + + it.effect("removes the Serve root on shutdown when the claim landed but its check failed", () => + Effect.gen(function* () { + const { state, scope, settled } = yield* startTailscaleServe({ statusFailsAfterServe: true }); + yield* Deferred.await(settled); + assert.equal(state.root, LOCAL_TARGET); + + yield* Scope.close(scope, Exit.void); + + assert.equal(state.releases, 1, "a mapping the daemon applied outlived shutdown"); + }), + ); + + it.effect("stops retrying on shutdown and leaves Serve alone if no attempt succeeded", () => + Effect.gen(function* () { + const { state, scope } = yield* startTailscaleServe({ + refusedServeAttempts: Number.POSITIVE_INFINITY, + }); + yield* TestClock.adjust("1 minute"); + const attemptsBeforeShutdown = state.serveAttempts; + + yield* Scope.close(scope, Exit.void); + yield* TestClock.adjust("1 hour"); + + assert.isAbove(attemptsBeforeShutdown, 1); + assert.equal(state.serveAttempts, attemptsBeforeShutdown); + assert.equal(state.releases, 0); + }), + ); + + it.effect("waits on shutdown no longer than the Serve command's timeout", () => + Effect.gen(function* () { + const { state, scope } = yield* startTailscaleServe({ refusedServeAttempts: 1, hangs: true }); + yield* TestClock.adjust("1 second"); + assert.equal(state.serveAttempts, 2); + + const shutdown = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(shutdown); + + // No attempt succeeded, so the port is not ours to clear. + assert.equal(state.releases, 0); + }), + ); + + // regression: #10363 - a second server took the Serve URL from the running one. + it.effect("leaves another server's Serve root alone and takes over once it is gone", () => + Effect.gen(function* () { + const otherServer = "http://192.168.1.20:3773"; + const { state, scope } = yield* startTailscaleServe({ heldBy: otherServer }); + yield* TestClock.adjust("1 minute"); + assert.equal(state.serveAttempts, 0); + assert.equal(state.root, otherServer); + + state.root = null; + yield* TestClock.adjust("30 seconds"); + assert.equal(state.root, LOCAL_TARGET); + + // Another server took the port back, so shutdown must not remove its root. + state.root = otherServer; + yield* Scope.close(scope, Exit.void); + assert.equal(state.releases, 0); + assert.equal(state.root, otherServer); + }), + ); +}); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts new file mode 100644 index 0000000000000..d6bfb0e6d6ace --- /dev/null +++ b/apps/server/src/tailscaleServe.ts @@ -0,0 +1,459 @@ +import { + claimTailscaleServe, + readTailscaleProfiles, + readTailscaleStatus, + releaseTailscaleServe, +} from "@t3tools/tailscale"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import { HttpServer } from "effect/http"; + +import { loopbackUrlHostForAddress } from "./startupAccess.ts"; + +/** The Serve record cannot be used: it is unreadable, or Tailscale names no account here. */ +export class ServeRecordError extends Schema.TaggedError()("ServeRecordError", { + reason: Schema.Literals(["unreadable", "no_account", "other_account", "switched", "unlisted"]), + message: Schema.String, +}) {} + +type ClaimError = + | Effect.Error> + | Effect.Error> + | PlatformError.PlatformError + | ServeRecordError; + +// Serve is often refused only for now: Tailscale still starting at login, the +// operator or HTTPS certs not yet granted, or another server on the port. +// Nobody restarts a headless server once that is fixed, so keep retrying. +const retrySchedule = Schedule.min([ + Schedule.exponential("1 second"), + Schedule.spaced("30 seconds"), +]); +// A leftover waits on a Tailscale that may be gone for good, so it backs off further. +const leftoverSchedule = Schedule.min([ + Schedule.exponential("1 second"), + Schedule.spaced("10 minutes"), +]); + +const remedyFor = (cause: ClaimError, manualCommand: string) => { + if (cause._tag === "TailscaleServeOccupiedError") { + return "Another server holds this port. T3 Code leaves it alone and takes over once it stops."; + } + if (cause._tag === "ServeRecordError") { + return cause.reason === "switched" || cause.reason === "unlisted" + ? cause.message + : `${cause.message} Serve waits until then.`; + } + if (cause._tag === "PlatformError") { + return "Could not save the record that lets a later run remove the mapping, so Serve waits until it can."; + } + switch (cause._tag === "TailscaleCommandExitError" ? cause.stderrDiagnostic : undefined) { + case "permission-denied": + return "On Linux, run `sudo tailscale set --operator=$USER` to let this user configure Serve."; + case "not-logged-in": + return "Run `tailscale up` to sign in."; + default: + return `Run \`${manualCommand}\` by hand to see why.`; + } +}; + +/** + * The mapping this T3 home set up, per Tailscale account, since each account keeps its own + * Serve config. A run with Serve off removes the current account's, such as one left when + * Tailscale was down as the last run stopped. + */ +const RECORD_FILE = "tailscale-serve-claim.json"; +const ServeTarget = Schema.Struct({ + localPort: Schema.Number, + localHost: Schema.String, + servePort: Schema.Number, +}); +type ServeTarget = typeof ServeTarget.Type; +// Keyed by account; `label` names it for a person, such as `you@example.com on example.com`. +// `profileId` is the login profile it was made on, when Tailscale listed one. +const ServeRecord = Schema.Struct({ + ...ServeTarget.fields, + label: Schema.String, + profileId: Schema.optionalKey(Schema.String), + // Every key the account had when it was written, so a later read missing one still matches. + keys: Schema.optionalKey(Schema.Array(Schema.String)), + nodeId: Schema.optionalKey(Schema.String), +}); +type ServeRecord = typeof ServeRecord.Type; +const ServeRecords = Schema.fromJsonString(Schema.Record(Schema.String, ServeRecord)); +type ServeRecords = typeof ServeRecords.Type; +const NO_RECORDS: ServeRecords = {}; +const encodeRecords = Schema.encodeEffect(ServeRecords); +const decodeRecords = Schema.decodeUnknownEffect(ServeRecords); +const sameTarget = (a: ServeTarget, b: ServeTarget) => + a.localPort === b.localPort && a.localHost === b.localHost && a.servePort === b.servePort; + +/** Where an earlier run's Serve mappings are recorded. */ +export const recordPath = Effect.fn("TailscaleServe.recordPath")(function* (stateDir: string) { + return (yield* Path.Path).join(stateDir, RECORD_FILE); +}); + +const readRecords = Effect.fn("TailscaleServe.readRecords")(function* (stateDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* recordPath(stateDir); + if (!(yield* fs.exists(path))) return NO_RECORDS; + return yield* fs.readFileString(path).pipe( + Effect.flatMap(decodeRecords), + Effect.mapError( + () => + new ServeRecordError({ + reason: "unreadable", + message: `The Tailscale Serve record at ${path} could not be read. Check \`tailscale serve status\`, then delete the file.`, + }), + ), + ); +}); + +// Written to a temporary file and renamed, so a crash never leaves half a record. +const writeRecords = Effect.fn("TailscaleServe.writeRecords")(function* ( + stateDir: string, + records: ServeRecords, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* recordPath(stateDir); + if (Object.keys(records).length === 0) return yield* fs.remove(path, { force: true }); + const json = yield* encodeRecords(records).pipe(Effect.orDie); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString(`${path}.tmp`, json); + yield* fs.rename(`${path}.tmp`, path); +}); + +type Profiles = Effect.Success> | null; +const readProfiles = readTailscaleProfiles().pipe(Effect.orElseSucceed((): Profiles => null)); + +/** + * The Tailscale account whose Serve config is live now. Its `key` is the login profile ID, + * the key Tailscale stores Serve config under, which tagging a host does not change. `keys` + * also holds the keys earlier runs may have used for it: tailnet and user from a CLI too old + * to list profiles, which outlives a node ID when a device is re-added, and the node ID. + */ +const currentAccount = Effect.fn("TailscaleServe.currentAccount")(function* (listed?: Profiles) { + const profiles = listed === undefined ? yield* readProfiles : listed; + const profile = profiles?.selected ?? null; + const status = yield* readTailscaleStatus("active"); + const legacyKey = + status.tailnetName !== null && status.selfUserId !== null + ? `${status.tailnetName}/${status.selfUserId}` + : null; + const keys = [profile?.id ?? null, legacyKey, status.selfNodeId].filter((key) => key !== null); + const key = keys[0]; + if (key === undefined) { + return yield* new ServeRecordError({ + reason: "no_account", + message: "Tailscale is not signed in here. Run `tailscale up` to sign in.", + }); + } + const login = + profile?.account ?? + status.users.find((user) => user.userId === status.selfUserId)?.loginName ?? + null; + const tailnet = profile?.tailnet ?? status.tailnetName; + const label = [login, tailnet].filter((part) => part !== null).join(" on ") || key; + return { + key, + keys, + label, + profileId: profile?.id ?? null, + nodeId: status.selfNodeId, + profiles: profiles?.ids ?? null, + }; +}); + +/** An account as a record or a read names it: every key it had, and its profile if listed. */ +interface AccountKeys { + readonly keys: ReadonlyArray; + readonly profileId: string | null; + readonly nodeId: string | null; +} +// Two profiles can share a tailnet and user, as two Windows users signed in to one account. +// So known profile IDs decide alone, and with one side's profile unknown, node IDs do, or it +// is not a match; other keys match only when neither side has a profile, as from an old CLI. +const sameAccount = (a: AccountKeys, b: AccountKeys) => + a.profileId !== null && b.profileId !== null + ? a.profileId === b.profileId + : a.profileId !== null || b.profileId !== null + ? a.nodeId !== null && a.nodeId === b.nodeId + : a.keys.some((key) => b.keys.includes(key)); +const recordAccount = (key: string, record: ServeRecord): AccountKeys => ({ + keys: [key, ...(record.keys ?? [])], + profileId: record.profileId ?? null, + nodeId: record.nodeId ?? null, +}); + +/** + * Sorts records by whose they are. `ours` belong to `account`. A record made on a login + * profile Tailscale no longer lists is skipped: `tailscale logout` deleted its Serve config + * with it. Windows lists only the signed-in Windows user's profiles, so absence proves + * nothing there. `unlisted` were made on a profile, but Tailscale did not list profiles. + */ +const sortRecords = Effect.fn("TailscaleServe.sortRecords")(function* ( + records: ServeRecords, + account: AccountKeys | null, + profiles: Profiles, +) { + const proofOfAbsence = profiles !== null && (yield* HostProcess.Platform) !== "win32"; + const sorted = { + ours: [] as Array, + others: {} as Record, + unlisted: {} as Record, + }; + for (const [key, record] of Object.entries(records)) { + if (account !== null && sameAccount(recordAccount(key, record), account)) { + sorted.ours.push(record); + } else if (record.profileId === undefined) sorted.others[key] = record; + else if (profiles === null) sorted.unlisted[key] = record; + else if (proofOfAbsence && !profiles.ids.includes(record.profileId)) continue; + else sorted.others[key] = record; + } + return sorted; +}); + +// A switch while removing a mapping means the removal may have run on the other account. +const switchedDuring = (account: AccountKeys) => + currentAccount().pipe( + Effect.flatMap((now) => + sameAccount(now, account) + ? Effect.void + : Effect.fail( + new ServeRecordError({ + reason: "switched", + message: "Tailscale switched accounts during cleanup.", + }), + ), + ), + ); + +const failureKey = (cause: ClaimError) => + cause._tag === "TailscaleCommandExitError" ? `exit:${cause.stderrDiagnostic}` : cause._tag; + +/** + * Points Tailscale Serve at this server after activation and retries until it + * succeeds. `settled` completes after the first attempt, so headless startup + * can print the Serve URL. Shutdown removes the mapping only if it still points here. + */ +export const layer = (options: { + readonly stateDir: string; + readonly servePort: number; + readonly parked: Deferred.Deferred; + readonly settled: Deferred.Deferred; + readonly awaitActivation: Effect.Effect; +}) => + Layer.effectDiscard( + Effect.gen(function* () { + yield* Deferred.succeed(options.parked, undefined).pipe(Effect.orDie); + yield* options.awaitActivation; + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + yield* Deferred.succeed(options.settled, undefined); + return; + } + + const target = { + localPort: address.port, + servePort: options.servePort, + localHost: loopbackUrlHostForAddress(address), + }; + const logContext = { localPort: target.localPort, servePort: target.servePort }; + const manualCommand = `tailscale serve --bg --https=${target.servePort} http://${target.localHost}:${target.localPort}`; + // The keys of the account this run's mapping is recorded under, once it is. + const account = yield* Ref.make(null); + // An earlier run's mapping on this account that must go before this one is recorded. + const releasing = yield* Ref.make(null); + // Added before the retry fiber is forked, so it runs after that fiber is interrupted. + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const recordedUnder = yield* Ref.get(account); + if (recordedUnder === null) return; + // After a switch to another account, the mapping is not visible, so its record stays. + if (!sameAccount(yield* currentAccount(), recordedUnder)) return; + const released = yield* releaseTailscaleServe(target).pipe( + Effect.tapError((cause) => + Effect.logWarning("Failed to disable Tailscale Serve", { cause, ...logContext }), + ), + ); + if (released) yield* Effect.logInfo("Tailscale Serve disabled", logContext); + yield* switchedDuring(recordedUnder); + const records = yield* readRecords(options.stateDir); + yield* writeRecords( + options.stateDir, + Object.fromEntries( + Object.entries(records).filter( + ([key, record]) => !sameAccount(recordAccount(key, record), recordedUnder), + ), + ), + ); + }).pipe( + Effect.catch((cause) => + Effect.logDebug("Left the Tailscale Serve record for the next run", { cause }), + ), + ), + ); + + let lastFailure: string | undefined; + // The daemon applies the mapping before the CLI exits, so an interrupt + // between the two would leave it behind with nothing to remove it. + // Shutdown waits for the commands' own timeouts instead. + const attempt = Effect.gen(function* () { + // Recorded before the claim, since a claim can apply the mapping and still fail. + // Release removes only a root that points here, so it is safe when none landed. + // Checked on every try, since Tailscale may have switched accounts since the last. + yield* Ref.set(releasing, null); + const profiles = yield* readProfiles; + const now = yield* currentAccount(profiles); + const recordedUnder = yield* Ref.get(account); + if (recordedUnder === null || !sameAccount(now, recordedUnder)) { + // An unreadable record may name a mapping to remove, so Serve waits for it. + const records = yield* readRecords(options.stateDir); + const { ours, others, unlisted } = yield* sortRecords(records, now, profiles); + for (const earlier of ours.filter((record) => !sameTarget(record, target))) { + yield* Ref.set(releasing, earlier); + yield* releaseTailscaleServe(earlier); + yield* Ref.set(releasing, null); + } + // Its record is replaced only once its mapping is gone from this account. + if (ours.length > 0) yield* switchedDuring(now); + yield* writeRecords(options.stateDir, { + ...others, + ...unlisted, + [now.key]: { + ...target, + label: now.label, + keys: now.keys, + ...(now.nodeId === null ? {} : { nodeId: now.nodeId }), + ...(now.profileId === null ? {} : { profileId: now.profileId }), + }, + }); + yield* Ref.set(account, { + keys: now.keys, + profileId: now.profileId, + nodeId: now.nodeId, + }); + } + yield* claimTailscaleServe(target); + }).pipe( + Effect.uninterruptible, + Effect.tap(() => Effect.logInfo("Tailscale Serve configured", logContext)), + Effect.tapError((cause) => + Effect.gen(function* () { + const key = failureKey(cause); + const repeated = key === lastFailure; + lastFailure = key; + const log = repeated ? Effect.logDebug : Effect.logWarning; + const earlier = yield* Ref.get(releasing); + yield* log( + earlier !== null + ? `Could not remove the Tailscale Serve mapping an earlier run left on port ${earlier.servePort}, so Serve waits. ${remedyFor(cause, `tailscale serve --https=${earlier.servePort} --set-path=/ off`)} Retrying in the background.` + : `Failed to configure Tailscale Serve. ${remedyFor(cause, manualCommand)} Retrying in the background.`, + { cause, ...logContext }, + ); + }), + ), + Effect.ensuring(Deferred.succeed(options.settled, undefined)), + ); + + yield* attempt.pipe(Effect.retry(retrySchedule), Effect.forkScoped); + }), + ); + +/** Whether an earlier run left a Serve record here. */ +export const hasRecord = Effect.fn("TailscaleServe.hasRecord")(function* (stateDir: string) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(yield* recordPath(stateDir)); +}); + +/** + * Removes the Serve mapping an earlier run recorded for the current Tailscale account, while + * Serve still points there, then its record. Records for other accounts stay. + */ +export const releaseRecorded = Effect.fn("TailscaleServe.releaseRecorded")(function* ( + stateDir: string, +) { + const records = yield* readRecords(stateDir); + if (Object.keys(records).length === 0) { + return { released: false, otherAccounts: [], currentLabel: null }; + } + const profiles = yield* readProfiles; + // Signed out, a record can still be one `tailscale logout` deleted the profile of. + const now = yield* currentAccount(profiles).pipe( + Effect.catchTags({ + ServeRecordError: (error) => + error.reason === "no_account" ? Effect.succeed(null) : Effect.fail(error), + }), + ); + const { ours, others, unlisted } = yield* sortRecords(records, now, profiles); + let released = false; + for (const record of ours) { + if (yield* releaseTailscaleServe(record)) released = true; + } + if (now !== null && ours.length > 0) yield* switchedDuring(now); + yield* writeRecords(stateDir, { ...others, ...unlisted }); + if (Object.keys(unlisted).length > 0) { + return yield* new ServeRecordError({ + reason: "unlisted", + message: + "Tailscale did not list its accounts, so T3 Code cannot tell whose Serve mapping an earlier run left.", + }); + } + if (now === null && Object.keys(others).length > 0) { + return yield* new ServeRecordError({ + reason: "no_account", + message: "Tailscale is not signed in here. Run `tailscale up` to sign in.", + }); + } + return { + released, + otherAccounts: Object.values(others).map((record) => record.label), + currentLabel: now?.label ?? null, + }; +}); + +/** + * With Serve off, removes the mappings earlier runs set up but could not remove. Retries + * until Tailscale answers, and until a record left on another account can be removed too. + */ +export const layerReleaseLeftover = (options: { readonly stateDir: string }) => + Layer.effectDiscard( + releaseRecorded(options.stateDir).pipe( + Effect.tap(({ released }) => + released + ? Effect.logInfo("Removed the Tailscale Serve mapping left from when Serve was on") + : Effect.void, + ), + Effect.flatMap(({ otherAccounts }) => + otherAccounts.length > 0 + ? Effect.fail( + new ServeRecordError({ + reason: "other_account", + message: "A Serve mapping is recorded for another Tailscale account.", + }), + ) + : Effect.void, + ), + Effect.tapError((cause) => + Effect.logDebug("Retrying the leftover Tailscale Serve removal", { cause }), + ), + Effect.retry({ + schedule: leftoverSchedule, + // An unreadable record stays unreadable, so it is reported once. + while: (error) => error._tag !== "ServeRecordError" || error.reason !== "unreadable", + }), + Effect.catch((cause) => + Effect.logWarning("Could not remove the leftover Tailscale Serve record", { cause }), + ), + Effect.forkScoped, + ), + ); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 40eebbf4825e3..5ccf924653013 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -60,6 +60,8 @@ import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as PeerLinkRequests from "./peer/PeerLinkRequests.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; +import * as TailnetSignIn from "./auth/TailnetSignIn.ts"; +import * as TailnetPeers from "./environment/TailnetPeers.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; @@ -77,6 +79,7 @@ import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; +import * as TailscaleHealth from "./environment/TailscaleHealth.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as ReviewService from "./review/ReviewService.ts"; @@ -133,6 +136,14 @@ import { withLateConfigDiscovery, } from "./ws.ts"; +const TAILSCALE_RUNNING = { + state: "running", + checkedAt: "2026-10-28T00:00:00.000Z", + magicDnsName: null, + keyExpiry: null, + issues: [], +} as const; + const snapshotConfig = (fields: Partial) => ({ availableEditors: [], settings: {}, ...fields }) as unknown as ServerConfig; @@ -236,6 +247,34 @@ it.effect("runs editor, remote open target and direct endpoint discovery side by }), ); +it.effect("resends the host's Tailscale health when its facts change or go stale", () => + Effect.gen(function* () { + const stopped = { ...TAILSCALE_RUNNING, state: "stopped" as const }; + const stoppedLater = { ...stopped, checkedAt: "2026-10-28T00:10:00.000Z" }; + const events = yield* withLateConfigDiscovery( + snapshotConfig({ tailscale: TAILSCALE_RUNNING }), + Stream.empty, + { + resolveAvailableEditors: () => Effect.succeed([]), + resolveFileManagerRevealKind: () => Effect.undefined, + }, + { resolveTargets: () => Effect.succeed([]) }, + Stream.make( + { ...TAILSCALE_RUNNING, checkedAt: "2026-10-28T00:01:00.000Z" }, + stopped, + stopped, + stoppedLater, + ), + ).pipe(Stream.runCollect); + + // A stale `checkedAt` would read as old news, so it is resent every 10 minutes. + assert.deepEqual( + Array.from(events, (event) => (event.type === "snapshot" ? event.config.tailscale : event)), + [stopped, stoppedLater], + ); + }), +); + const ReplayTestLayer = OrchestrationEventStore.layer.pipe( Layer.provideMerge(SqlitePersistence.layerMemory), ); @@ -557,6 +596,8 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(PairingGrantStore.PairingGrantStore)({}), Layer.mock(PeerLinks.PeerLinks)({}), Layer.mock(PeerLinkRequests.PeerLinkRequests)({}), + Layer.mock(TailnetSignIn.TailnetSignIn)({}), + Layer.mock(TailnetPeers.TailnetPeers)({}), Layer.mock(PortScanner.PortDiscovery)({ trackAgent: () => () => undefined }), Layer.mock(PreviewManager.PreviewManager)({}), Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({}), @@ -665,6 +706,10 @@ it.effect.each(["pending", "cached"] as const)( ), }), Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }), + Layer.mock(TailscaleHealth.TailscaleHealth)({ + check: Effect.succeed(TAILSCALE_RUNNING), + reads: Stream.empty, + }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), @@ -859,6 +904,10 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( resolveTargets: () => Effect.succeed([]), }), Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }), + Layer.mock(TailscaleHealth.TailscaleHealth)({ + check: Effect.succeed(TAILSCALE_RUNNING), + reads: Stream.empty, + }), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e880551e65cce..37ce3eed887c7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -4,6 +4,8 @@ import * as Crypto from "effect/Crypto"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as PeerLinkRequests from "./peer/PeerLinkRequests.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; +import * as TailnetSignIn from "./auth/TailnetSignIn.ts"; +import * as TailnetPeers from "./environment/TailnetPeers.ts"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -82,6 +84,8 @@ import { type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, type ServerProvider, + type ServerTailscaleStatus, + TAILNET_SUBJECT_PREFIX, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -226,6 +230,7 @@ import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; +import * as TailscaleHealth from "./environment/TailscaleHealth.ts"; import * as DefectReporter from "./observability/DefectReporter.ts"; import * as EventLoopMonitor from "./observability/EventLoopMonitor.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; @@ -315,11 +320,25 @@ const resolveEditorConfig = Effect.fn("ws.resolveEditorConfig")(function* }; }); +// `checkedAt` changes on every read, so it alone is worth a snapshot only now and then. +const tailscaleFacts = (status: ServerTailscaleStatus | undefined) => + status === undefined ? "" : JSON.stringify({ ...status, checkedAt: null }); +const TAILSCALE_CHECKED_AT_REFRESH_MS = 10 * 60_000; +const tailscaleReadIsNews = ( + previous: ServerTailscaleStatus | undefined, + next: ServerTailscaleStatus | undefined, +) => + tailscaleFacts(previous) !== tailscaleFacts(next) || + (previous !== undefined && + next !== undefined && + Date.parse(next.checkedAt) - Date.parse(previous.checkedAt) >= TAILSCALE_CHECKED_AT_REFRESH_MS); + export const withLateConfigDiscovery = ( config: ClientServerConfig, liveUpdates: Stream.Stream, launcher: EditorDiscovery, remoteOpenTargets: RemoteOpenTargets.RemoteOpenTargets["Service"], + tailscaleReads: Stream.Stream = Stream.empty, ) => { const lateEditorConfig = Stream.fromEffect(launcher.resolveAvailableEditors()).pipe( Stream.filter( @@ -346,8 +365,14 @@ export const withLateConfigDiscovery = ( ), Stream.map((targets) => ({ type: "remoteOpenTargetsResolved" as const, targets })), ); + const lateTailscale = tailscaleReads.pipe( + Stream.map((tailscale) => ({ type: "tailscaleRead" as const, tailscale })), + ); - return Stream.merge(liveUpdates, Stream.merge(lateEditorConfig, lateRemoteOpenTargets)).pipe( + return Stream.merge( + liveUpdates, + Stream.merge(lateEditorConfig, Stream.merge(lateRemoteOpenTargets, lateTailscale)), + ).pipe( Stream.mapAccum( (): ClientServerConfig => config, (current, event): readonly [ClientServerConfig, ReadonlyArray] => { @@ -366,6 +391,13 @@ export const withLateConfigDiscovery = ( const next = { ...current, remoteOpenTargets: event.targets }; return [next, [{ version: 1, type: "snapshot", config: next }]]; } + case "tailscaleRead": { + if (!tailscaleReadIsNews(current.tailscale, event.tailscale)) return [current, []]; + const { tailscale: _previous, ...rest } = current; + const next = + event.tailscale === undefined ? rest : { ...rest, tailscale: event.tailscale }; + return [next, [{ version: 1, type: "snapshot", config: next }]]; + } case "keybindingsUpdated": return [{ ...current, ...event.payload }, [event]]; case "providerStatuses": @@ -1357,6 +1389,7 @@ export const layerWsRpc = ( const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; const directEndpoints = yield* DirectEndpoints.DirectEndpoints; + const tailscaleHealth = yield* TailscaleHealth.TailscaleHealth; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; @@ -1384,6 +1417,8 @@ export const layerWsRpc = ( const secretResolver = yield* SecretResolver.SecretResolver; const peerLinks = yield* PeerLinks.PeerLinks; const peerLinkRequests = yield* PeerLinkRequests.PeerLinkRequests; + const tailnetSignIn = yield* TailnetSignIn.TailnetSignIn; + const tailnetPeers = yield* TailnetPeers.TailnetPeers; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -1432,7 +1467,12 @@ export const layerWsRpc = ( ): Effect.Effect => currentSession.scopes.includes(requiredScope) ? effect - : Effect.fail(rpcAuthorizationError(requiredScope)); + : Effect.fail( + rpcAuthorizationError( + requiredScope, + currentSession.subject.startsWith(TAILNET_SUBJECT_PREFIX), + ), + ); const acpRegistryProject = Effect.fn("ws.acpRegistry.project")(function* ( projectId: ProjectId, @@ -1771,6 +1811,13 @@ export const layerWsRpc = ( readonly workspaceSnapshotsOnDemand: boolean; readonly deferRemoteOpenTargets: boolean; }) { + // Runs beside the rest: `tailscale status` can take seconds on Windows. + const tailscaleFiber = yield* Effect.forkChild( + resolveDiscoveryForConfig( + tailscaleHealth.check, + () => undefined, + ), + ); const keybindingsConfig = yield* keybindings.loadConfigState; const currentProviders = yield* providerRegistry.getProviders; const commandProviders = options.usageLimitsCommand @@ -1794,6 +1841,7 @@ export const layerWsRpc = ( : remoteOpenTargets.resolveTargets(), directEndpoints: directEndpoints.resolve(), }); + const tailscale = yield* Fiber.join(tailscaleFiber); return { environment, @@ -1851,6 +1899,7 @@ export const layerWsRpc = ( }), newProjectsRoot: managedFolders.namedProjectsRoot, ...(lastEventLoopStall === undefined ? {} : { lastEventLoopStall }), + ...(tailscale === undefined ? {} : { tailscale }), }; }); @@ -2561,6 +2610,20 @@ export const layerWsRpc = ( Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( Effect.andThen(peerLinkRequests.answer(input)), ), + [WS_METHODS.tailnetDevicesList]: (_input) => + tailnetSignIn + .listDevices(currentSession.subject) + .pipe(Effect.map((devices) => ({ devices }))), + [WS_METHODS.tailnetDevicesBlock]: ({ stableNodeId }) => + tailnetSignIn + .blockDevice(stableNodeId) + .pipe(Effect.map((revokedSessionCount) => ({ revokedSessionCount }))), + [WS_METHODS.tailnetDevicesUnblock]: ({ stableNodeId }) => + tailnetSignIn + .unblockDevice(stableNodeId) + .pipe(Effect.map((unblocked) => ({ unblocked }))), + [WS_METHODS.tailnetPeersList]: (_input) => + tailnetPeers.list.pipe(Effect.map((peers) => ({ peers }))), [WS_METHODS.serverUpdateSettings]: /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* ({ patch, providerInstanceMutation }) { @@ -3253,9 +3316,13 @@ export const layerWsRpc = ( rpcInitialItems([ { version: 1 as const, type: "snapshot" as const, config: initialConfig }, ]), - withLateConfigDiscovery(initialConfig, liveUpdates, externalLauncher, { - resolveTargets: () => Fiber.join(remoteTargets), - }), + withLateConfigDiscovery( + initialConfig, + liveUpdates, + externalLauncher, + { resolveTargets: () => Fiber.join(remoteTargets) }, + tailscaleHealth.reads, + ), ); }), ), @@ -3343,6 +3410,9 @@ export const layer = Layer.unwrap( const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const peerLinks = yield* PeerLinks.PeerLinks; const peerLinkRequests = yield* PeerLinkRequests.PeerLinkRequests; + // Shared across connections, so the peer probe cache is too. + const tailnetSignIn = yield* TailnetSignIn.TailnetSignIn; + const tailnetPeers = yield* TailnetPeers.TailnetPeers; const sql = yield* SqlClient.SqlClient; return HttpRouter.add( "GET", @@ -3383,21 +3453,25 @@ export const layer = Layer.unwrap( Effect.provideService(RpcServer.Protocol, withTerminalOutputWindow(protocol)), Effect.provide( Layer.merge( - RpcAuthorization.layer(session.scopes, (method, payload) => { - // Anonymous product signal: the feature and its enum variant, - // credited to the connecting client's surface only. - const usage = featureUsage(method, payload); - return usage === undefined - ? Effect.void - : analytics - .record("feature.used", { - ...usage, - ...(clientAnalyticsProps.surface === undefined - ? {} - : { surface: clientAnalyticsProps.surface }), - }) - .pipe(Effect.ignore); - }), + RpcAuthorization.layer( + session.scopes, + (method, payload) => { + // Anonymous product signal: the feature and its enum variant, + // credited to the connecting client's surface only. + const usage = featureUsage(method, payload); + return usage === undefined + ? Effect.void + : analytics + .record("feature.used", { + ...usage, + ...(clientAnalyticsProps.surface === undefined + ? {} + : { surface: clientAnalyticsProps.surface }), + }) + .pipe(Effect.ignore); + }, + session.subject.startsWith(TAILNET_SUBJECT_PREFIX), + ), rpcInstrumentationLayer, ), ), @@ -3426,6 +3500,8 @@ export const layer = Layer.unwrap( ), Layer.provide(Layer.succeed(PeerLinks.PeerLinks, peerLinks)), Layer.provide(Layer.succeed(PeerLinkRequests.PeerLinkRequests, peerLinkRequests)), + Layer.provide(Layer.succeed(TailnetSignIn.TailnetSignIn, tailnetSignIn)), + Layer.provide(Layer.succeed(TailnetPeers.TailnetPeers, tailnetPeers)), Layer.provide( SourceControlDiscovery.layer.pipe( Layer.provide( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index e22dcfaddedeb..b7e500c874428 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,3 +1,4 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { EnvironmentId, EnvironmentMachineKind, @@ -16,6 +17,7 @@ export interface EnvironmentOption { label: string; isPrimary: boolean; machine: EnvironmentMachineKind; + connection: EnvironmentConnectionPhase; } /** "Add to another machine" stays muted until hovered: easy to find, quieter than the machines. */ diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 66232885fb280..d2ff8a88cc7ed 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -9,7 +9,7 @@ import { PlusIcon, ScaleIcon } from "lucide-react"; import { memo } from "react"; import { ADD_TO_MACHINE_LABEL_CLASS, type EnvironmentOption } from "./BranchToolbar.logic"; -import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { MachineStatusIcon } from "./ConnectionStatusDot"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { Select, @@ -34,6 +34,9 @@ interface BranchToolbarEnvironmentSelectorProps { const AUTO_VALUE = "action:auto"; const ADD_MACHINE_VALUE = "action:add-machine"; const environmentValue = (id: EnvironmentId) => `env:${id}`; +// This device is always reachable, so only other machines show a status. +const statusPhase = (env: EnvironmentOption | null) => + env === null || env.isPrimary ? null : env.connection; export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ autoEnvironmentLabel, @@ -71,9 +74,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir className="inline-flex h-7 min-w-0 max-w-full items-center gap-1.5 border border-transparent px-1.75 font-normal text-muted-foreground/70 text-sm sm:h-6 sm:text-xs" data-composer-context-control > - {activeEnvironment?.label ?? "Run on"} @@ -116,9 +120,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir {autoEnvironmentLabel ? ( + ); +} diff --git a/apps/web/src/components/settings/TailscaleSignInRow.tsx b/apps/web/src/components/settings/TailscaleSignInRow.tsx new file mode 100644 index 0000000000000..1a775dbea8760 --- /dev/null +++ b/apps/web/src/components/settings/TailscaleSignInRow.tsx @@ -0,0 +1,89 @@ +import { TAILNET_SIGN_IN_PORT } from "@t3tools/client-runtime/connection"; +import { type DesktopServerExposureState, TAILNET_APP_CAPABILITY } from "@t3tools/contracts"; +import { useState } from "react"; + +import { requestConfirmDialog } from "~/confirmDialog"; +import { refreshDesktopNetworkAccessState } from "~/state/desktopNetworkAccess"; +import { Switch } from "../ui/switch"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +/** + * The desktop switch that lets this user's own Tailscale devices connect with no + * code. The WSL backend never gets sign-in, so WSL only mode turns it off. + */ +export function TailscaleSignInRow({ + state, + wslOnly, + managed, +}: { + readonly state: DesktopServerExposureState | null; + readonly wslOnly: boolean; + /** Why a managed policy keeps it off, if one does. */ + readonly managed: string | undefined; +}) { + const [isUpdating, setIsUpdating] = useState(false); + const enabled = state?.tailscaleSignInEnabled === true && !wslOnly; + const port = state?.port; + const movedPort = enabled && port !== undefined && port !== TAILNET_SIGN_IN_PORT; + + const handleChange = async (next: boolean) => { + const bridge = window.desktopBridge; + if (!bridge) return; + const confirmed = await requestConfirmDialog( + next + ? `Turn on Tailscale sign-in?\nYour own untagged Tailscale devices get full access with no pairing code. Once your tailnet policy grants ${TAILNET_APP_CAPABILITY} here, or grants are required, only the devices it grants get in. ${ + state?.mode === "network-accessible" + ? "" + : "T3 Code accepts tailnet connections and refuses LAN connections. " + }T3 Code will restart.` + : "Turn off Tailscale sign-in?\nDevices that signed in through Tailscale disconnect. T3 Code will restart.", + ); + if (confirmed !== true) return; + setIsUpdating(true); + try { + await bridge.setTailscaleSignInEnabled(next); + refreshDesktopNetworkAccessState(); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not change Tailscale sign-in", + description: error instanceof Error ? error.message : "The setting was not saved.", + }), + ); + } + setIsUpdating(false); + }; + + return ( + + Port {TAILNET_SIGN_IN_PORT} is in use, so T3 Code runs on {port}. Other devices do not + find it on their own; add it by name with :{port}. + + ) : null + } + control={ + void handleChange(checked)} + aria-label="Enable Tailscale sign-in" + /> + } + managed={enabled ? undefined : managed} + /> + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index e5c126e0307b2..0f4cf4c5f6f2c 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -136,6 +136,7 @@ describe("searchSettings", () => { "t3-connect", "hold-webhooks-while-offline", "tailscale-https", + "tailscale-sign-in", "wsl-backend", "auto-settle-inactive-threads", "auto-settle-merged-threads", diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index d28e467b0cf70..4614343233c04 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1083,6 +1083,15 @@ export const SETTINGS_SEARCH_ITEMS = [ desktopOnly: true, localBackendManagementOnly: true, }, + { + id: "tailscale-sign-in", + title: "Tailscale sign-in", + to: "/settings/access", + targetId: "connections-environment", + searchTerms: ["tailnet no pairing code own devices phone port 3773 block"], + desktopOnly: true, + localBackendManagementOnly: true, + }, { id: "wsl-backend", title: "WSL backend", diff --git a/apps/web/src/components/sidebar/SidebarMachineIcon.tsx b/apps/web/src/components/sidebar/SidebarMachineIcon.tsx new file mode 100644 index 0000000000000..85ef1a1127b82 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarMachineIcon.tsx @@ -0,0 +1,32 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, EnvironmentMachineKind } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { environmentPresentations } from "../../state/presentation"; +import { MachineStatusIcon } from "../ConnectionStatusDot"; + +// Rows read the phase alone, so a new attempt or route result does not re-render them. +const connectionPhaseAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get) => + get(environmentPresentations.presentationAtom(environmentId))?.connection.phase ?? null, + ), +); + +/** A remote machine's icon, with a dot only while that machine is not connected. */ +export function SidebarMachineIcon({ + environmentId, + kind, +}: { + readonly environmentId: EnvironmentId; + readonly kind: EnvironmentMachineKind; +}) { + const phase = useAtomValue(connectionPhaseAtom(environmentId)); + return ( + + ); +} diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index fa87252de1457..bc013f30b06c9 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -74,6 +74,7 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { textDirection } from "~/textDirection"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { SidebarMachineIcon } from "./SidebarMachineIcon"; import { animateSidebarLayoutChanges, hasUnseenCompletion, @@ -1127,7 +1128,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const remoteMachineIcon = isRemote && !showsScratchMachine ? ( - + ) : null; const diff = latestRunDiff(thread); diff --git a/apps/web/src/connection/onboarding.ts b/apps/web/src/connection/onboarding.ts index c4368b0b77e9a..0f2883736c55d 100644 --- a/apps/web/src/connection/onboarding.ts +++ b/apps/web/src/connection/onboarding.ts @@ -3,7 +3,8 @@ import { createAtomCommandScheduler, createRuntimeCommand, } from "@t3tools/client-runtime/state/runtime"; -import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; +import { discoverTailnetMachines as discoverTailnetMachinesEffect } from "@t3tools/client-runtime/state/tailnet"; +import type { DesktopSshEnvironmentTarget, TailnetPeerEnvironment } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { connectionAtomRuntime } from "./runtime"; @@ -23,6 +24,27 @@ export const connectPairing = createRuntimeCommand(connectionAtomRuntime, { ), }); +/** Signs in through Tailscale with no code; None when the machine needs a pairing code. */ +export const connectTailnet = createRuntimeCommand(connectionAtomRuntime, { + label: "web:connection:connect-tailnet", + scheduler: onboardingScheduler, + concurrency: { + mode: "singleFlight", + key: (input: ConnectionOnboarding.TailnetConnectionInput) => JSON.stringify(input), + }, + execute: (input: ConnectionOnboarding.TailnetConnectionInput) => + ConnectionOnboarding.ConnectionOnboarding.pipe( + Effect.flatMap((onboarding) => onboarding.registerTailnet(input)), + ), +}); + +export const discoverTailnetMachines = createRuntimeCommand(connectionAtomRuntime, { + label: "web:connection:discover-tailnet", + scheduler: onboardingScheduler, + concurrency: { mode: "singleFlight", key: () => "discover-tailnet" }, + execute: (found: ReadonlyArray) => discoverTailnetMachinesEffect(found), +}); + export const connectSshEnvironment = createRuntimeCommand(connectionAtomRuntime, { label: "web:connection:connect-ssh", scheduler: onboardingScheduler, diff --git a/apps/web/src/connection/storage.test.ts b/apps/web/src/connection/storage.test.ts index 7afe2b1e8573f..7b30d1ba2d72d 100644 --- a/apps/web/src/connection/storage.test.ts +++ b/apps/web/src/connection/storage.test.ts @@ -21,6 +21,7 @@ const emptyCatalog = { credentials: [], remoteDpopTokens: [], disabledEnvironmentIds: [], + dismissedEnvironmentIds: [], } as const; const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument)); const encodeCatalog = Schema.encodeSync(Schema.fromJsonString(ConnectionCatalogDocument)); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 7d9db4afdc494..d94a1eae500b8 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -87,6 +87,7 @@ function persistenceError( operation: | "list-targets" | "list-disabled-targets" + | "list-dismissed-environments" | "register-connection" | "set-connection-routes" | "remove-connection" @@ -634,6 +635,10 @@ export const layer = Layer.effectContext( Effect.map((document) => document.disabledEnvironmentIds), Effect.mapError((cause) => persistenceError("list-disabled-targets", cause)), ), + listDismissed: catalog.read.pipe( + Effect.map((document) => document.dismissedEnvironmentIds), + Effect.mapError((cause) => persistenceError("list-dismissed-environments", cause)), + ), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ register: Effect.fn("Persistence.ConnectionRegistrationStore.register")( @@ -650,11 +655,13 @@ export const layer = Layer.effectContext( .pipe(Effect.mapError((cause) => persistenceError("set-connection-routes", cause))); }, ), - remove: Effect.fn("Persistence.ConnectionRegistrationStore.remove")(function (environmentId) { - return catalog - .update((document) => removeConnectionFromCatalog(document, environmentId)) - .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))); - }), + remove: Effect.fn("Persistence.ConnectionRegistrationStore.remove")( + function (environmentId, options) { + return catalog + .update((document) => removeConnectionFromCatalog(document, environmentId, options)) + .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))); + }, + ), setEnabled: Effect.fn("Persistence.ConnectionRegistrationStore.setEnabled")( function (environmentId, enabled) { return catalog diff --git a/apps/web/src/hooks/useLogicalProjectEnvironments.ts b/apps/web/src/hooks/useLogicalProjectEnvironments.ts index ebf5fe7a4d2fe..02795dda02228 100644 --- a/apps/web/src/hooks/useLogicalProjectEnvironments.ts +++ b/apps/web/src/hooks/useLogicalProjectEnvironments.ts @@ -38,6 +38,7 @@ export function useLogicalProjectEnvironments( label: environment?.label ?? environmentId, isPrimary: environmentId === primaryEnvironmentId, machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null), + connection: environment?.connection.phase ?? "available", }); }; if (scratchDraft) { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1207b5017100d..d757d15282342 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -28,6 +28,7 @@ import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPrompt import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkCoordinator"; +import { TailnetDiscoveryCoordinator } from "../components/desktop/TailnetDiscoveryCoordinator"; import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; @@ -134,6 +135,8 @@ function RootRouteView() { return ( + {/* Runs through first-run setup too, so its machine list shows tailnet machines. */} + {isElectron ? : null} diff --git a/apps/web/src/state/desktopTailscale.ts b/apps/web/src/state/desktopTailscale.ts new file mode 100644 index 0000000000000..349c1305a94d8 --- /dev/null +++ b/apps/web/src/state/desktopTailscale.ts @@ -0,0 +1,29 @@ +import type { DesktopBridge, DesktopTailscaleStatus } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom } from "effect/reactivity"; + +function getDesktopTailscaleBridge(): Pick | undefined { + return typeof window === "undefined" ? undefined : window.desktopBridge; +} + +const readDesktopTailscaleStatus = Effect.suspend(() => { + const bridge = getDesktopTailscaleBridge(); + return bridge === undefined + ? Effect.succeed(null) + : Effect.tryPromise(() => bridge.getTailscaleStatus()).pipe( + Effect.orElseSucceed((): DesktopTailscaleStatus | null => null), + ); +}).pipe(Effect.withSpan("desktopTailscale.read")); + +/** + * This device's Tailscale, read through the desktop app once per connection + * attempt and only while mounted. Null outside the desktop app or on failure. + */ +export const desktopTailscaleStatusAtom = Atom.family((_attempt: number) => + Atom.make(readDesktopTailscaleStatus).pipe(Atom.withLabel("desktop:tailscale-status")), +); + +/** For a diagnosis that does not need this device's Tailscale, so nothing is read. */ +export const noDesktopTailscaleStatusAtom = Atom.make( + AsyncResult.success(null), +).pipe(Atom.withLabel("desktop:tailscale-status:none")); diff --git a/apps/web/src/state/tailnet.ts b/apps/web/src/state/tailnet.ts new file mode 100644 index 0000000000000..30577c10bdd68 --- /dev/null +++ b/apps/web/src/state/tailnet.ts @@ -0,0 +1,5 @@ +import { createTailnetEnvironmentAtoms } from "@t3tools/client-runtime/state/tailnet"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const tailnetEnvironment = createTailnetEnvironmentAtoms(connectionAtomRuntime); diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index 698ddac7a73f9..7ee619962854d 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -16,6 +16,10 @@ client of a restarted server reconnects in the same second; with a short cap, a client that can never connect retries all day. Offline states and authentication failures wait for a wakeup instead of spending attempts on unchanged conditions. +A healthy session stays on its current route: discovery does not prove that a +better route can establish a socket. Necessary reconnects use the latest ranked +routes, including those learned from the current session. + Foregrounding, a network change, an explicit retry, and an offline report probe the established session, and only a failed probe reconnects. Offline reports are often wrong, for example for a loopback server. The OS can kill a suspended diff --git a/docs/user/background-service.md b/docs/user/background-service.md index c36527b2c425c..2b56a74d14baa 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -44,14 +44,16 @@ under T3 home. Pass `--yes` from a script. ## Local network access -A new service answers only on this machine. Other devices reach it through -T3 Connect, or directly once you choose the local network in `t3 up` (or run -`t3 up --lan`, which sets `T3CODE_LAN=true` in `service.env`). It then listens on -your local network and Tailscale addresses, so the desktop and mobile apps on -the same network connect directly instead of through T3 Connect. It still does -not answer on public addresses. Anyone on the same network can reach the port, -so a pairing code is all that keeps them out. The hosted web app at app.t3.codes -stays on T3 Connect, because browsers block plain HTTP from an HTTPS page. +A new service accepts no connections from your local network. Other devices +reach it through T3 Connect, through Tailscale once you choose it in `t3 up`, or +directly once you choose the local network there (or run `t3 up --lan`, which +sets `T3CODE_LAN=true` in `service.env`). +It then listens on your local network and Tailscale addresses, so the desktop +and mobile apps on the same network connect directly instead of through +T3 Connect. It still does not answer on public addresses. Anyone on the same +network can reach the port, so a pairing code or Tailscale sign-in is all that +keeps them out. The hosted web app at app.t3.codes stays on T3 Connect, because +browsers block plain HTTP from an HTTPS page. A service installed before `T3CODE_LAN` existed keeps listening on the local network. `t3 status` says so, and `t3 up --no-lan` turns it off. Any diff --git a/docs/user/managed-policy.md b/docs/user/managed-policy.md index e1ba501e6fa3d..9d325ab391731 100644 --- a/docs/user/managed-policy.md +++ b/docs/user/managed-policy.md @@ -203,9 +203,10 @@ desktop app, keep `desktop` in the list, or the app cannot start. Without `remote`, the server listens only on a loopback address, creates no pairing links, and signs out devices that paired earlier. It does not start with a host other than a loopback address or `localhost`. The [background service](./background-service.md) listens on private -networks by default, so set `T3CODE_HOST=127.0.0.1` in its `service.env`. The desktop app starts -with network access and Tailscale Serve off, whatever the user chose. That also turns off -T3 Connect. To keep other remote access but turn off T3 Connect alone, set `"connect": false`. +networks by default, so set `T3CODE_HOST=127.0.0.1` in its `service.env`, and `t3 up` refuses +LAN access and Tailscale sign-in. The desktop app starts with network access, Tailscale Serve, and +Tailscale sign-in off, whatever the user chose. That also turns off T3 Connect. To keep other +remote access but turn off T3 Connect alone, set `"connect": false`. When it starts, the server also turns off each Tailscale Serve route, HTTPS or TCP, that forwards to it, such as one `t3 pair --tailscale` set up, and leaves other routes alone. It does not start diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 259f58c00e570..4fadd49c83f2e 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -15,8 +15,9 @@ For a command-line host, run: t3 up ``` -It asks how your other devices reach the machine: T3 Connect, the local network, -or both. Choose T3 Connect and follow the sign-in instructions. On Linux and +It asks how your other devices reach the machine: Tailscale (offered when it runs +there), T3 Connect, the local network, or more than one. Choose T3 Connect and +follow the sign-in instructions. On Linux and macOS, it then runs T3 Code as a [background service](./background-service.md). `t3 connect` is the same as `t3 up --connect`. Run `t3 up` again to change your choice, and `t3 status` to see it. @@ -41,8 +42,8 @@ disconnect an otherwise healthy conversation. ### Scripts and agents -`t3 up` asks nothing when you name a choice: `--connect`, `--lan`, or their -`--no-` forms. Choices you do not name keep their settings, and `--yes` takes +`t3 up` asks nothing when you name a choice: `--tailscale`, `--connect`, +`--lan`, or their `--no-` forms. Choices you do not name keep their settings, and `--yes` takes the defaults. Without a terminal: - T3 Connect sign-in prints a URL and a code. Approve it on any device. @@ -71,6 +72,8 @@ Use direct pairing when the other device can reach the host's network address. On a desktop host, open **Settings → Sharing & access**, enable **Network access**, then create a pairing link using an address the other device can reach. Changing network access restarts the desktop app. You can turn it off in the same place. +Network access is off by default because it opens the port to everything on the +LAN, and LAN traffic is not encrypted the way Tailscale traffic is. For a command-line host, choose the local network in `t3 up`, or run: @@ -80,7 +83,8 @@ t3 up --lan For a server in a terminal, run `t3 serve --lan`. Either way, T3 Code then accepts connections from your local network and tailnet, so a pairing code is -all that keeps other devices on them out. +all that keeps other devices on them out, apart from +[Tailscale sign-in](#tailscale-sign-in) for tailnet devices. If a server is already running, generate a fresh link without restarting it: @@ -102,7 +106,7 @@ install that CA on Android as a **CA certificate** in its security settings; the Android app then trusts it. Pairing authorizes that device for future connections. Use a fresh one-time link -for each new device; you do not need the original token to reconnect. Links +for each new device; you do not need the original code to reconnect. Links created in Settings can only be copied from the client that created them while its Sharing & access page stays open. If you leave or reload that page, create another link to share. @@ -132,13 +136,13 @@ reporting that address. T3 Code connects over the first route that answers. Away from home, a LAN address that does not answer is checked briefly and skipped. It is only tried -again, after the other routes, if none of them connect. While connected over a -later route, T3 Code checks the earlier ones when your network changes, when you -return to the app, and every minute, and moves back as soon as one works. +again, after the other routes, if none of them connect. A working connection +stays on its route, even when an earlier route comes back. The route order +applies the next time T3 Code has to reconnect. On web and desktop, select the route count under the machine's name in -**Settings → Environments** to see its routes. Drag a route to change the order, -or remove it. On mobile, open the machine under **Settings → Environments** and +**Settings → Environments** to see its routes. Each route shows whether it +answered on the last try. Drag a route to change the order, or remove it. On mobile, open the machine under **Settings → Environments** and choose **Edit**. Signing out of T3 Connect removes only that route; a machine you can still reach another way stays saved. @@ -167,6 +171,142 @@ on that machine. Existing threads stay where they started. If resource checks ar unavailable or all eligible machines are full, choose a machine manually to continue. Mobile keeps its manual environment selection. +### Tailscale sign-in + +On a personal tailnet, your own devices can connect to a machine with no pairing +code. Run this on each machine, as the user who should own T3 Code: + +```bash +npx t3 up --tailscale +``` + +Plain `npx t3 up` offers Tailscale too, and picks it first when Tailscale runs +on the machine. It checks Tailscale, turns on Tailscale sign-in, installs the +[background service](./background-service.md), and prints the machine's tailnet +address with a QR code. Unless you also choose the local network, T3 Code +accepts loopback and tailnet connections and refuses LAN connections; the port +still answers on the LAN, but the connection closes before T3 Code reads it. +`--tailscale-serve` also sets up Tailscale HTTPS. + +Who gets in: + +- **While your tailnet policy grants `t3.codes/cap/t3code` on the machine to no + device,** any untagged device the machine's owner has on the tailnet gets full + access, including terminals and files. Tagged devices, devices shared in from + another tailnet, other users, and subnet routers cannot sign in. Nothing to + set up. +- **Once your policy grants it on the machine to any device,** the policy + decides: a device gets the scopes its grant names, and a device without a + grant cannot sign in, the owner's included. The machine notices a new grant + within a minute, and at once when a granted device signs in. After you remove + the last grant, your own untagged devices can sign in again within five + minutes, once the machine can read every device on your tailnet. The machine + sees grants through the devices they name, so the same happens when every + granted device leaves your tailnet; `--require-grant` keeps your untagged devices + out. +- **To require a grant before you write one,** run `t3 up --require-grant` or + `t3 tailscale require-grant on`. `t3 tailscale require-grant` shows which rule + applies and why; `off` undoes `on`, but not your policy. + +Browsers still need a pairing code. Removing a device from your tailnet, +changing or removing its grant, or blocking it ends its sessions within about +30 seconds; after a change, the device signs in again with its new scopes. If +Tailscale cannot read a device on your tailnet, the machine cannot tell whether +your policy grants T3 Code. It reuses its last full check for up to five +minutes, then lets in only granted devices until it can tell. Right after it +starts, it has no check to reuse. On that machine, `t3 tailscale require-grant` +shows why. A refused +device shows the grant to add, for that device and this machine only. It names +both by Tailscale address, so remove it when either leaves the tailnet: Tailscale +can give that address to a new device. + +A grant goes in the `grants` section of your tailnet policy. This one lets your +devices sign in to machines tagged `tag:t3-box`: + +```json +"grants": [{ + "src": ["you@example.com"], + "dst": ["tag:t3-box"], + "ip": ["tcp:3773"], + "app": { "t3.codes/cap/t3code": [{ "scopes": ["standard"] }] } +}] +``` + +`"standard"` includes agents and terminals, so a device with it can do anything +the T3 Code user can do on that machine. It leaves out T3 Code's own access +settings (pairing codes, sessions, blocks) and the T3 Connect link; `"admin"` +adds those. `"read"` can view threads, terminals, and files but not change them, +and is the only preset with no shell. You can also list single scopes, such as `"orchestration:read"`. +A device that matches several grants gets all of their scopes. The `ip` line lets +the device reach the port when your policy does not already allow it. Subnet +routers never sign in, grant or not, because traffic they forward could arrive as +them. A grant does cover VMs and containers that a granted machine NATs onto the +tailnet, since they reach T3 Code as that machine. + +To connect from the desktop or mobile app, with Tailscale on: + +- **Desktop:** do nothing. The app finds the machines on your tailnet that offer + sign-in and adds them. It skips another user's untagged machine and one shared in + from another tailnet, so if a grant lets you into one, type its name in **Add + environment**. +- **Phone:** scan the QR code from `t3 up`, or type the machine's name, MagicDNS + name, or `100.x` address in **Add environment** and leave the code empty. After + that, the phone asks the machines it is connected to for the others and adds + them. + +A machine you remove stays removed; add it again by name to bring it back. If a +machine you already have answers at a new address, the app warns you instead of +adding it. When this device is off Tailscale, its machines stay saved and +reconnect when Tailscale is back. + +To let your phone reach your laptop the same way, turn on **Tailscale sign-in** in +**Settings → Sharing & access**. The app restarts and listens on your tailnet at +port 3773; unless **Network access** is on, it still refuses LAN connections. If +that port is in use, Settings shows the port it uses, and other devices add the +laptop by name with that port. + +To cut off one device on one machine, open **Settings → Environments** on a +device that can manage access there, and **Block** it under **Tailscale devices +on **. On that machine itself, the list is in **Settings → Sharing & +access**. A device can manage access when its scopes include `access:write` and +`orchestration:read`: one paired with a default `t3 pair` code, one whose grant +includes `admin`, or your own devices while your policy grants none and +`--require-grant` is off. + +A blocked device's Tailscale sessions on that machine end now, and **Unblock** +undoes it. Sessions it made with a pairing code stay until you revoke them in +**Settings → Sharing & access** there. A browser preview or device screen it +already has open keeps working, clicks and typing included, until it closes. A +device that rejoins Tailscale gets a new ID, so block it again or remove it from +your tailnet. + +To turn Tailscale sign-in off, run `t3 up --no-tailscale` for the background +service, or turn it off in **Settings → Sharing & access** in the desktop app. +Either restarts and ends every Tailscale session and unused Tailscale sign-in +code. A server you started by hand keeps its access until you stop it. + +On an untagged machine the owner is its own Tailscale user. On a tagged machine, +`t3 up` asks for the owner's login, or takes `--owner `; with +`--require-grant` it needs no owner. Without grants, agents on any of your +untagged machines, including their containers and VMs, can sign in to your other +machines. Require grants, or tag the machines that run agents, to stop that. + +`t3 up` writes these settings to `service.env`. Use them with `t3 serve` as flags +too: + +| `service.env` | Flag | Meaning | +| ------------------------------- | ------------------------ | ---------------------------------------------- | +| `T3CODE_TAILSCALE_SIGN_IN=true` | `--tailscale-sign-in` | Tailscale devices sign in with no code | +| `T3CODE_LAN=true` | `--lan` | Also accept connections from the local network | +| `T3CODE_TAILSCALE_OWNER=` | `--tailscale-owner ` | Owner's Tailscale user ID on a tagged host | + +With Tailscale sign-in on and the local network off, T3 Code accepts only +loopback and tailnet connections. + +Tailscale sign-in needs Tailscale 1.80 or newer on the machine and a Tailscale +IPv4 address, and does not work with userspace networking, which is common in LXC +and other containers; `t3 up` tells you when it finds any of these. + ### Tailscale HTTPS Join both devices to the same tailnet. In the desktop app, enable **Tailscale @@ -195,6 +335,23 @@ tailscale serve --https=443 --set-path=/ off If that port is already in use, choose another with `--tailscale-serve-port`. See `t3 pair --help` for other pairing options. +### Tailscale troubleshooting + +When a Tailscale route fails, the machine's row in **Settings → Environments** says +why, using what the host last reported about its own Tailscale: signed out, off, +waiting for approval, or a node key that has expired or expires soon. The desktop +app also checks Tailscale on this device and whether the host is online on your +tailnet. Online only means the host is connected to Tailscale, not that this +device can reach it. + +On the host, run: + +```bash +t3 tailscale status +``` + +It prints the same facts with a fix for each. Add `--json` for scripts. + ### Hosted web app [app.t3.codes](https://app.t3.codes) needs an HTTPS endpoint. It connects directly @@ -366,7 +523,11 @@ fails with that reason. On the host, **Settings → Sharing & access** lets authorized administrators create pairing links and revoke client sessions. Revoking an unused link prevents new -pairings; revoke a device's session to remove its existing access. Command-line +pairings; revoke a device's session to remove its existing access. A device that +signed in through Tailscale signs in again at once, so to keep it out, block it +or turn [Tailscale sign-in](#tailscale-sign-in) off. Removing its grant works +too, but removing the last grant lets your untagged devices back in unless grants +are required or the machine is tagged with no owner. Command-line management is available through `t3 auth --help`. A session with an open connection stays listed after its access credential @@ -380,7 +541,7 @@ npx t3 pair --scope orchestration:read --scope relay:read The selected scopes replace the default permissions. The same option works with `npx t3 auth pairing create` and `npx t3 auth session issue`; each command's -`--help` lists the available scopes. Without `--scope`, pairing tokens and issued +`--help` lists the available scopes. Without `--scope`, pairing codes and issued bearer sessions get every permission, including managing access and T3 Connect. A terminal or a full-access agent on the host can reach those as well, so a client that should not manage access also needs to go without `terminal:operate` diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d1b48e1d76f4f..f3b8eb93b5e64 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -231,6 +231,10 @@ "types": "./src/state/peerLinks.ts", "default": "./src/state/peerLinks.ts" }, + "./state/tailnet": { + "types": "./src/state/tailnet.ts", + "default": "./src/state/tailnet.ts" + }, "./state/peerPairingCode": { "types": "./src/state/peerPairingCode.ts", "default": "./src/state/peerPairingCode.ts" diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index 9ed7b9a63e9d3..e0439d4e3c34c 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -19,7 +19,12 @@ import * as RpcHttp from "../rpc/http.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; import * as RemoteEnvironmentAuthorization from "./service.ts"; import * as TokenStore from "./tokenStore.ts"; -import { BearerConnectionCredential, BearerConnectionProfile } from "../connection/catalog.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + type ConnectionCredential, + TailnetConnectionCredential, +} from "../connection/catalog.ts"; import * as ConnectionCredentialStore from "../connection/credentialStore.ts"; import { BearerConnectionTarget } from "../connection/model.ts"; import * as ConnectionProfileStore from "../connection/profileStore.ts"; @@ -109,13 +114,33 @@ const persistedToken = ( dpopThumbprint: input.dpopThumbprint ?? "thumbprint-1", }); +const PINNED = "http://100.88.1.2:3773/"; +const SERVE = { + httpBaseUrl: "https://studio.tail1234.ts.net/", + wsBaseUrl: "wss://studio.tail1234.ts.net/", +}; +const tailnetSession = (accessToken: string, expiresAtEpochMs: number) => + new TailnetConnectionCredential({ + signInBaseUrl: PINNED, + accessToken, + expiresAtEpochMs, + dpopThumbprint: "thumbprint-1", + }); +const tailnetGrant = () => + Response.json({ credential: "tailnet-grant", expiresAt: "2026-06-06T00:02:00.000Z" }); +const requestedUrls = (calls: ReadonlyArray) => + calls.map(([url]) => String(url)); + const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { readonly initialToken?: TokenStore.RemoteDpopAccessToken; readonly responses: ReadonlyArray; readonly bootstrap?: RelayEnvironmentConnectResponse; readonly beforeBootstrap?: Effect.Effect; readonly beforePut?: Effect.Effect; + readonly beforeCredentialPut?: Effect.Effect; readonly clerkToken?: ClientCapabilities.CloudSession["Service"]["clerkToken"]; + readonly credentials?: ReadonlyArray; + readonly surface?: "desktop" | "mobile"; }) { const tokens = yield* Ref.make( new Map( @@ -141,6 +166,9 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( }> >([]); const fetch = recordedFetch(input.responses); + const credentials = yield* Ref.make( + new Map(input.credentials ?? []), + ); const tokenStore = TokenStore.RemoteDpopAccessTokenStore.of({ get: (environmentId) => @@ -211,6 +239,26 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( deviceId: Effect.succeedSome("device-1"), }), Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore), + Layer.succeed(ConnectionCredentialStore.ConnectionCredentialStore, { + get: (connectionId) => + Ref.get(credentials).pipe( + Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), + ), + put: (connectionId, credential) => + (input.beforeCredentialPut ?? Effect.void).pipe( + Effect.andThen( + Ref.update(credentials, (current) => + new Map(current).set(connectionId, credential), + ), + ), + ), + remove: (connectionId) => + Ref.update(credentials, (current) => { + const next = new Map(current); + next.delete(connectionId); + return next; + }), + }), Layer.succeed( ClientCapabilities.ClientPresentation, ClientCapabilities.ClientPresentation.of({ @@ -218,6 +266,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( label: "T3 Code Test", deviceType: "mobile", os: "test", + ...(input.surface === undefined ? {} : { surface: input.surface }), }, }), ), @@ -226,6 +275,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( ); return { layer, + credentials, tokens, bootstrapCalls, proofInputs, @@ -1104,4 +1154,154 @@ describe("RemoteEnvironmentAuthorization", () => { expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); }), ); + it.effect("renews an expired Tailscale session at the pinned address, not the route in use", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + credentials: [["pinned", tailnetSession("expired-token", 0)]], + responses: [ + Response.json({ + ...DESCRIPTOR, + orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION, + }), + tailnetGrant(), + accessToken("renewed-token"), + websocketTicket("serve-ticket"), + ], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + return yield* remote.authorizeTailnet({ + expectedEnvironmentId: ENVIRONMENT_ID, + credentialId: "pinned", + endpoint: SERVE, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(requestedUrls(harness.fetch.calls)).toEqual([ + "https://studio.tail1234.ts.net/.well-known/t3/environment", + "http://100.88.1.2:3773/api/auth/tailnet", + "http://100.88.1.2:3773/oauth/token", + "https://studio.tail1234.ts.net/api/auth/websocket-ticket/rpc", + ]); + expect(harness.fetch.calls[1]?.[1].headers).toMatchObject({ + dpop: "proof:http://100.88.1.2:3773/api/auth/tailnet", + }); + expect(authorized.socketUrl).toContain("connectionMethod=direct"); + expect(authorized.httpAuthorization).toEqual({ + _tag: "Dpop", + accessToken: "renewed-token", + expiresAtEpochMs: 3_600_000, + tailnetCredentialId: "pinned", + }); + expect((yield* Ref.get(harness.credentials)).get("pinned")).toMatchObject({ + signInBaseUrl: PINNED, + accessToken: "renewed-token", + }); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); + }), + ); + + it.effect( + "renews a live Tailscale socket by signing in again before expiry, not via T3 Connect", + () => + Effect.gen(function* () { + const puts = yield* Queue.unbounded(); + const harness = yield* makeHarness({ + credentials: [["pinned", tailnetSession("current-token", 3_600_000)]], + responses: [tailnetGrant(), accessToken("renewed-token")], + beforeCredentialPut: Queue.offer(puts, undefined).pipe(Effect.asVoid), + }); + + yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + yield* remote + .renewDpopBeforeExpiry({ + expectedEnvironmentId: ENVIRONMENT_ID, + expiresAtEpochMs: 3_600_000, + tailnetCredentialId: "pinned", + }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust(3_540_000 - 1); + expect(harness.fetch.calls).toHaveLength(0); + yield* TestClock.adjust(1); + yield* Queue.take(puts); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(harness.layer, TestClock.layer()))); + + expect(requestedUrls(harness.fetch.calls)).toEqual([ + "http://100.88.1.2:3773/api/auth/tailnet", + "http://100.88.1.2:3773/oauth/token", + ]); + expect((yield* Ref.get(harness.credentials)).get("pinned")).toMatchObject({ + accessToken: "renewed-token", + }); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); + }), + ); + + it.effect("signs in again for HTTP only after the host rejects the Tailscale session", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + credentials: [["pinned", tailnetSession("current-token", Number.MAX_SAFE_INTEGER)]], + responses: [tailnetGrant(), accessToken("renewed-token")], + }); + + const [current, renewed] = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + return [ + yield* remote.authorizeTailnetHttp({ credentialId: "pinned" }), + yield* remote.authorizeTailnetHttp({ + credentialId: "pinned", + rejectedAccessToken: "current-token", + }), + ] as const; + }).pipe(Effect.provide(harness.layer)); + + expect(current.accessToken).toBe("current-token"); + expect(renewed.accessToken).toBe("renewed-token"); + expect(requestedUrls(harness.fetch.calls)).toEqual([ + "http://100.88.1.2:3773/api/auth/tailnet", + "http://100.88.1.2:3773/oauth/token", + ]); + }), + ); + + it.effect("keeps the Tailscale session when the host refuses or cannot be reached", () => + Effect.gen(function* () { + const expired = tailnetSession("expired-token", 0); + const renew = (responses: ReadonlyArray) => + Effect.gen(function* () { + const harness = yield* makeHarness({ credentials: [["pinned", expired]], responses }); + const failure = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + return yield* Effect.flip(remote.authorizeTailnetHttp({ credentialId: "pinned" })); + }).pipe(Effect.provide(harness.layer)); + return { failure, stored: (yield* Ref.get(harness.credentials)).get("pinned") }; + }); + + const refused = yield* renew([ + Response.json( + { + _tag: "EnvironmentTailnetSignInRefusedError", + code: "tailnet_sign_in_refused", + reason: "blocked", + traceId: "trace-blocked", + }, + { status: 403 }, + ), + ]); + expect(refused.failure).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "permission", + tailnetRefusal: "blocked", + traceId: "trace-blocked", + }); + expect(refused.stored).toBe(expired); + + // Off Tailscale the fetch fails; the machine keeps its session and retries later. + const offline = yield* renew([]); + expect(offline.failure._tag).toBe("ConnectionTransientError"); + expect(offline.stored).toBe(expired); + }), + ); }); diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 9b4d9c2f1f892..767e45438fb40 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -17,6 +17,7 @@ import { mapRemoteDpopEnvironmentError, mapRemoteEnvironmentError, } from "../connection/errors.ts"; +import type { TailnetConnectionCredential } from "../connection/catalog.ts"; import { ConnectionBlockedError, ConnectionTransientError, @@ -26,6 +27,7 @@ import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; import { environmentEndpointUrl } from "../environment/endpoint.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; +import { makeTailnetSessions, signInOverTailnet } from "./tailnet.ts"; import * as TokenStore from "./tokenStore.ts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -52,11 +54,13 @@ export interface AuthorizedRemoteEnvironment { readonly descriptor?: ExecutionEnvironmentDescriptor; } +type DpopHttpAuthorization = Extract; + export interface AuthorizedRemoteHttpEnvironment { readonly environmentId: EnvironmentId; readonly label: string; readonly httpBaseUrl: string; - readonly httpAuthorization: Extract; + readonly httpAuthorization: DpopHttpAuthorization; } export class RemoteEnvironmentAuthorization extends Context.Service< @@ -89,7 +93,26 @@ export class RemoteEnvironmentAuthorization extends Context.Service< readonly renewDpopBeforeExpiry: (input: { readonly expectedEnvironmentId: EnvironmentId; readonly expiresAtEpochMs: number; + /** A Tailscale session renews by signing in again at its pinned address, not via T3 Connect. */ + readonly tailnetCredentialId?: string; }) => Effect.Effect; + /** Signs in through Tailscale at a direct tailnet address. */ + readonly signInTailnet: (input: { + readonly httpBaseUrl: string; + }) => Effect.Effect; + /** + * Opens `endpoint` with the Tailscale session stored as `credentialId`, + * which renews only at the address the session pins. + */ + readonly authorizeTailnet: (input: { + readonly expectedEnvironmentId: EnvironmentId; + readonly credentialId: string; + readonly endpoint: { readonly httpBaseUrl: string; readonly wsBaseUrl: string }; + }) => Effect.Effect; + readonly authorizeTailnetHttp: (input: { + readonly credentialId: string; + readonly rejectedAccessToken?: string; + }) => Effect.Effect; } >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} @@ -120,6 +143,10 @@ export const make = Effect.gen(function* () { const presentation = yield* ClientCapabilities.ClientPresentation; const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore; const httpClient = yield* HttpClient.HttpClient; + const tailnet = yield* makeTailnetSessions({ + clientMetadata: presentation.metadata, + scope: serviceScope, + }); const tokenLock = yield* Semaphore.make(1); const tokenOwners = new Map< EnvironmentId, @@ -194,14 +221,15 @@ export const make = Effect.gen(function* () { const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( function* ( - token: TokenStore.RemoteDpopAccessToken, + accessToken: string, + endpoint: { readonly httpBaseUrl: string; readonly wsBaseUrl: string }, timeoutMs?: number, - endpoint: { readonly httpBaseUrl: string; readonly wsBaseUrl: string } = token.endpoint, + connectionMethod: ClientConnectionMethod = "relay", ) { return yield* resolveRemoteDpopWebSocketConnectionUrl({ wsBaseUrl: endpoint.wsBaseUrl, httpBaseUrl: endpoint.httpBaseUrl, - accessToken: token.accessToken, + accessToken, createProof: (proof) => signer.createProof(proof).pipe( Effect.mapError( @@ -213,7 +241,7 @@ export const make = Effect.gen(function* () { ), ), clientMetadata: presentation.metadata, - connectionMethod: "relay", + connectionMethod, ...(timeoutMs === undefined ? {} : { timeoutMs }), }); }, @@ -488,9 +516,9 @@ export const make = Effect.gen(function* () { let selected = yield* getDpopToken(input); if (selected.fromCache) { const cachedSocket = yield* createDpopSocketUrl( - selected.token, + selected.token.accessToken, + endpoint ?? selected.token.endpoint, CACHED_ENDPOINT_SOCKET_TIMEOUT_MS, - endpoint, ).pipe(Effect.result); if (Result.isSuccess(cachedSocket)) { yield* assertSession(selected.identity); @@ -510,9 +538,10 @@ export const make = Effect.gen(function* () { rejectedAccessToken: selected.token.accessToken, }); } - const socket = yield* createDpopSocketUrl(selected.token, undefined, endpoint).pipe( - Effect.result, - ); + const socket = yield* createDpopSocketUrl( + selected.token.accessToken, + endpoint ?? selected.token.endpoint, + ).pipe(Effect.result); if (Result.isFailure(socket)) { if (endpoint === undefined || socket.failure._tag === "ConnectionBlockedError") { yield* tokenLock.withPermits(1)( @@ -525,8 +554,69 @@ export const make = Effect.gen(function* () { return authorized(selected.token, socket.success); }); + const tailnetAuthorization = ( + session: TailnetConnectionCredential, + credentialId: string, + ): DpopHttpAuthorization => ({ + _tag: "Dpop", + accessToken: session.accessToken, + expiresAtEpochMs: session.expiresAtEpochMs, + tailnetCredentialId: credentialId, + }); + + const authorizeTailnet = Effect.fn("clientRuntime.connection.remote.authorizeTailnet")(function* ( + input: Parameters[0], + ) { + const descriptor = yield* verifyDirectEndpoint({ + expectedEnvironmentId: input.expectedEnvironmentId, + httpBaseUrl: input.endpoint.httpBaseUrl, + connectionMethod: "direct", + }); + const openSocket = (session: TailnetConnectionCredential) => + createDpopSocketUrl(session.accessToken, input.endpoint, undefined, "direct").pipe( + Effect.result, + ); + let session = yield* tailnet.session(input.credentialId); + let socket = yield* openSocket(session); + // The host ended the session early, such as after Block: sign in again once. + if (Result.isFailure(socket) && socket.failure._tag === "EnvironmentAuthInvalidError") { + session = yield* tailnet.session(input.credentialId, session.accessToken); + socket = yield* openSocket(session); + } + if (Result.isFailure(socket)) { + const failure = socket.failure; + return yield* failure._tag === "ConnectionBlockedError" + ? failure + : mapRemoteEnvironmentError(failure); + } + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: input.endpoint.httpBaseUrl, + socketUrl: socket.success, + descriptor, + httpAuthorization: tailnetAuthorization(session, input.credentialId), + }; + }); + return RemoteEnvironmentAuthorization.of({ authorizeBearer, + signInTailnet: Effect.fn("clientRuntime.connection.remote.signInTailnet")(function* (input) { + return yield* signInOverTailnet({ + httpBaseUrl: input.httpBaseUrl, + clientMetadata: presentation.metadata, + }).pipe( + Effect.provideService(ManagedRelay.ManagedRelayDpopSigner, signer), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + }), + authorizeTailnet, + authorizeTailnetHttp: Effect.fn("clientRuntime.connection.remote.authorizeTailnetHttp")( + function* (input) { + const session = yield* tailnet.session(input.credentialId, input.rejectedAccessToken); + return tailnetAuthorization(session, input.credentialId); + }, + ), authorizeDpop: Effect.fn("environment.authorization")(function (input) { return authorizeDpop(input); }), @@ -549,13 +639,18 @@ export const make = Effect.gen(function* () { yield* Effect.sleep( Math.max(5_000, expiresAtEpochMs - DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS - now), ); - const renewed = yield* getDpopToken({ - expectedEnvironmentId: input.expectedEnvironmentId, - }).pipe( - Effect.map(({ token }) => token.expiresAtEpochMs), + const renewed = yield* ( + input.tailnetCredentialId === undefined + ? getDpopToken({ expectedEnvironmentId: input.expectedEnvironmentId }).pipe( + Effect.map(({ token }) => token.expiresAtEpochMs), + withRelayClientTracing, + ) + : tailnet + .session(input.tailnetCredentialId) + .pipe(Effect.map((session) => session.expiresAtEpochMs)) + ).pipe( Effect.withSpan("environment.authorization.renewSocket"), - withRelayClientTracing, - // A relay blip retries until expiry; a blocked credential ends the loop. + // A network blip retries until expiry; a blocked credential ends the loop. Effect.catchTags({ ConnectionTransientError: () => Effect.succeed(expiresAtEpochMs) }), ); expiresAtEpochMs = Math.max(expiresAtEpochMs, renewed); diff --git a/packages/client-runtime/src/authorization/tailnet.ts b/packages/client-runtime/src/authorization/tailnet.ts new file mode 100644 index 0000000000000..55c7846a490ff --- /dev/null +++ b/packages/client-runtime/src/authorization/tailnet.ts @@ -0,0 +1,162 @@ +import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import * as HttpClient from "effect/http/HttpClient"; + +import { TailnetConnectionCredential } from "../connection/catalog.ts"; +import * as ConnectionCredentialStore from "../connection/credentialStore.ts"; +import { credentialMissingError, mapRemoteEnvironmentError } from "../connection/errors.ts"; +import { + ConnectionBlockedError, + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS, + type ConnectionAttemptError, +} from "../connection/model.ts"; +import { tailnetRefusalError } from "../connection/tailnet.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiGroupClient } from "../rpc/http.ts"; +import { exchangeRemoteDpopAccessToken } from "./remote.ts"; + +const TAILNET_SIGN_IN_TIMEOUT_MS = 10_000; + +const isTailnetCredential = Schema.is(TailnetConnectionCredential); + +const proofError = () => + new ConnectionBlockedError({ + reason: "configuration", + detail: "Could not create the Tailscale sign-in proof.", + hop: "device", + }); + +/** + * Signs in at a direct tailnet address with this device's DPoP key, then + * exchanges the two-minute grant for a session, like after a T3 Connect mint. + */ +export const signInOverTailnet = Effect.fn("clientRuntime.authorization.signInOverTailnet")( + function* (input: { + readonly httpBaseUrl: string; + readonly clientMetadata?: AuthClientPresentationMetadata; + }) { + const signer = yield* ManagedRelayDpopSigner; + const thumbprint = yield* signer.thumbprint.pipe(Effect.mapError(proofError)); + const signInUrl = environmentEndpointUrl(input.httpBaseUrl, "/api/auth/tailnet"); + const signInProof = yield* signer + .createProof({ method: "POST", url: signInUrl }) + .pipe(Effect.mapError(proofError)); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "tailnet"); + const answer = yield* executeEnvironmentHttpRequest( + signInUrl, + TAILNET_SIGN_IN_TIMEOUT_MS, + client.signIn({ headers: { dpop: signInProof } }).pipe( + Effect.map((grant) => ({ _tag: "Granted" as const, grant })), + // A refusal is a declared answer, not a transport failure. + Effect.catchTags({ + EnvironmentTailnetSignInRefusedError: (refused) => + Effect.succeed({ _tag: "Refused" as const, refused }), + }), + ), + ).pipe(Effect.mapError((error) => mapRemoteEnvironmentError(error))); + if (answer._tag === "Refused") { + return yield* tailnetRefusalError( + answer.refused.reason, + answer.refused.traceId, + answer.refused.suggestedGrant, + ); + } + const tokenProof = yield* signer + .createProof({ + method: "POST", + url: environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), + }) + .pipe(Effect.mapError(proofError)); + const access = yield* exchangeRemoteDpopAccessToken({ + httpBaseUrl: input.httpBaseUrl, + credential: answer.grant.credential, + dpopProof: tokenProof, + ...(input.clientMetadata === undefined ? {} : { clientMetadata: input.clientMetadata }), + }).pipe(Effect.mapError((error) => mapRemoteEnvironmentError(error))); + const issuedAt = yield* Clock.currentTimeMillis; + return new TailnetConnectionCredential({ + signInBaseUrl: input.httpBaseUrl, + accessToken: access.access_token, + expiresAtEpochMs: issuedAt + access.expires_in * 1_000, + dpopThumbprint: thumbprint, + }); + }, +); + +/** + * Tailscale sessions keyed by credential id. A session renews by signing in + * again at its pinned address, before expiry or after a rejection, so every + * host check runs again. Concurrent callers share one sign-in. + */ +export const makeTailnetSessions = Effect.fn("clientRuntime.authorization.makeTailnetSessions")( + function* (input: { + readonly clientMetadata: AuthClientPresentationMetadata; + readonly scope: Scope.Scope; + }) { + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; + const signer = yield* ManagedRelayDpopSigner; + const httpClient = yield* HttpClient.HttpClient; + const renewals = new Map< + string, + { + readonly id: object; + readonly fiber: Fiber.Fiber; + } + >(); + + const renew = Effect.fn("clientRuntime.authorization.tailnet.renew")(function* ( + credentialId: string, + current: TailnetConnectionCredential, + ) { + const pending = renewals.get(credentialId); + if (pending !== undefined) return yield* Fiber.join(pending.fiber); + const id = {}; + const fiber = yield* signInOverTailnet({ + httpBaseUrl: current.signInBaseUrl, + clientMetadata: input.clientMetadata, + }).pipe( + Effect.tap((fresh) => credentials.put(credentialId, fresh)), + Effect.provideService(ManagedRelayDpopSigner, signer), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.ensuring( + Effect.sync(() => { + if (renewals.get(credentialId)?.id === id) renewals.delete(credentialId); + }), + ), + // One caller's interruption must not cancel the renewal others wait on. + Effect.forkIn(input.scope), + ); + renewals.set(credentialId, { id, fiber }); + return yield* Fiber.join(fiber); + }); + + /** The stored session, or a fresh one when it is missing a key match, near expiry, or rejected. */ + const session = Effect.fn("clientRuntime.authorization.tailnet.session")(function* ( + credentialId: string, + rejectedAccessToken?: string, + ) { + const stored = Option.getOrUndefined(yield* credentials.get(credentialId)); + if (stored === undefined || !isTailnetCredential(stored)) { + return yield* credentialMissingError(credentialId); + } + const thumbprint = yield* signer.thumbprint.pipe(Effect.mapError(proofError)); + const now = yield* Clock.currentTimeMillis; + if ( + stored.dpopThumbprint === thumbprint && + stored.expiresAtEpochMs > now + DPOP_ACCESS_TOKEN_REFRESH_SKEW_MS && + stored.accessToken !== rejectedAccessToken + ) { + return stored; + } + return yield* renew(credentialId, stored); + }); + + return { session }; + }, +); diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index 17065b8a4862c..99be561c861bb 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -23,7 +23,11 @@ export class BearerConnectionProfile extends Schema.TaggedClass()( + "TailnetConnectionCredential", + { + signInBaseUrl: Schema.String, + accessToken: Schema.String, + expiresAtEpochMs: Schema.Number, + dpopThumbprint: Schema.String, + }, +) {} + +export const ConnectionCredential = Schema.Union([ + BearerConnectionCredential, + TailnetConnectionCredential, +]); export type ConnectionCredential = typeof ConnectionCredential.Type; export class PrimaryConnectionRegistration extends Schema.TaggedClass()( @@ -90,6 +111,15 @@ export class BearerConnectionRegistration extends Schema.TaggedClass()( + "TailnetConnectionRegistration", + { + target: BearerConnectionTarget, + profile: BearerConnectionProfile, + credential: TailnetConnectionCredential, + }, +) {} + export class SshConnectionRegistration extends Schema.TaggedClass()( "SshConnectionRegistration", { @@ -103,6 +133,7 @@ export class SshConnectionRegistration extends Schema.TaggedClass }), ); +const tailnetTimeout = new ConnectionTransientError({ + reason: "timeout", + detail: "Mac mini did not respond during connection setup.", +}); +const TAILNET_FAILED: ConnectionRouteOutcome = { + routeId: "bearer:tailnet", + kind: "tailnet", + address: "http://100.64.0.1:3773/", + result: "failed", + error: tailnetTimeout, +}; +const RELAY_ANSWERED: ConnectionRouteOutcome = { + routeId: "relay", + kind: "relay", + address: null, + result: "answered", +}; + const diagnose = (state: SupervisorConnectionState, via: "relay" | "direct" = "relay") => presentConnectionDiagnosis({ hostLabel: "Mac mini", state, via }); @@ -122,10 +151,227 @@ describe("presentConnectionDiagnosis", () => { const origin = presentConnectionDiagnosis({ hostLabel: "Mac mini", state: AVAILABLE_CONNECTION_STATE, - via: "direct", + via: "relay", relayStatus: { status: "offline", endpointFailure: "origin_unreachable" }, }); expect(origin?.hops.map((hop) => hop.hop)).toEqual(["device", "relay", "tunnel", "server"]); + // Another route may still work, so the relay's health alone is no failure. + const withDirectRoute = presentConnectionDiagnosis({ + hostLabel: "Mac mini", + state: AVAILABLE_CONNECTION_STATE, + via: "direct", + relayStatus: { status: "offline", endpointFailure: "origin_unreachable" }, + }); + expect(withDirectRoute).toBeNull(); + }); + + it("names a Tailscale route that did not answer and keeps the relay status", () => { + const state = { ...backoff(tailnetTimeout), routes: [TAILNET_FAILED] }; + const plain = diagnose(state, "direct"); + expect(plain?.headline).toBe("Mac mini did not answer over Tailscale"); + expect(plain?.steps[0]).toBe( + "Check that Tailscale is on and signed in on this device and on Mac mini.", + ); + expect(plain?.facts).toContainEqual(["Failed route", "Tailscale http://100.64.0.1:3773/"]); + // Another route reached the host and it failed there, so Tailscale is not the story. + const hostFailed = diagnose( + { + ...backoff(new ConnectionTransientError({ reason: "remote-unavailable", detail: "500" })), + routes: [TAILNET_FAILED], + }, + "direct", + ); + expect(hostFailed?.headline).not.toMatch(/Tailscale/); + expect(hostFailed?.facts.map(([label]) => label)).not.toContain("Failed route"); + expect(plain?.moreInfoUrl).toMatch(/remote-access\.md#tailscale-troubleshooting$/); + + const relayOnline = presentConnectionDiagnosis({ + hostLabel: "Mac mini", + state, + via: "direct", + relayStatus: { status: "online" }, + }); + expect(relayOnline?.headline).toBe( + "Mac mini is online on T3 Connect, but its Tailscale address did not answer", + ); + expect(relayOnline?.facts).toContainEqual(["T3 Connect", "online"]); + + const tailscaleOff = presentConnectionDiagnosis({ + hostLabel: "Mac mini", + state, + via: "direct", + localTailscale: { state: "stopped", peers: [] }, + }); + expect(tailscaleOff?.headline).toBe("Tailscale is off on this device"); + expect(tailscaleOff?.hops.map((hop) => `${hop.hop}:${hop.result}`)).toEqual([ + "device:failed", + "server:unchecked", + ]); + + // A Tailscale route that answered leaves the generic diagnosis. + const answered = { ...TAILNET_FAILED, result: "answered" } as const; + expect(diagnose({ ...state, routes: [answered] }, "direct")?.headline).toBe( + "T3 Code on Mac mini did not answer", + ); + }); + + it("names the check a host's Tailscale sign-in refused instead of guessing", () => { + const refused = (reason: "blocked" | "rate_limited") => { + const error = tailnetRefusalError(reason); + return diagnose({ ...backoff(error), routes: [{ ...TAILNET_FAILED, error }] }, "direct"); + }; + expect(refused("blocked")).toMatchObject({ + headline: "Mac mini blocked this device", + steps: [ + "Unblock this device in Settings > Sharing & access on Mac mini, or in Settings > Environments on another device that can manage access there.", + "A device can manage access when it has access:write and orchestration:read on Mac mini. Devices paired with a default t3 pair code have them, as do devices granted admin, and yours while nothing is granted and grants are not required.", + "Then try again on this device: press Retry now (the reconnect button on mobile) for a saved machine, or submit the form again if you were adding it.", + "Or run `t3 pair --scope orchestration:read --scope access:write` on Mac mini. On this device, paste the link it prints into Add route under Mac mini in Settings > Environments, or into Add environment if Mac mini is not saved. If the link names a LAN address, enter its code at http://: instead (`tailscale ip -4` on Mac mini, or its Tailscale menu, shows the address).", + "Then unblock this device, and revoke that pairing in Settings > Sharing & access on Mac mini so this device signs in through Tailscale again. On a headless host, run `t3 auth session list` and `t3 auth session revoke ` there, then restart T3 Code on this device.", + ], + moreInfoUrl: expect.stringMatching(/#tailscale-troubleshooting$/), + }); + expect(refused("blocked")?.facts).toContainEqual(["Cause", "blocked"]); + // Add forms show the error itself, so it reads as a sentence. + expect(tailnetRefusalError("routes_for_others").message).toBe( + "This device is a subnet router, so it cannot sign in. Traffic it forwards could arrive as this device, so no grant lets it in. Stop advertising routes on it. Or pair this device with a code instead.", + ); + // A transient refusal answered, so it is not an unreachable tailnet. + expect(refused("rate_limited")?.headline).toBe("Mac mini asked this device to slow down"); + // A host that cannot read its policy yet is retried, not given up on. + expect(tailnetRefusalError("policy_unknown")._tag).toBe("ConnectionTransientError"); + // The host's suggested grant is there to paste, and in the copied report. + const snippet = '"grants": [{ "src": ["you@example.com"] }]'; + const noGrant = diagnose( + backoff(tailnetRefusalError("no_grant", undefined, snippet)), + "direct", + ); + expect(noGrant?.policySnippet).toBe(snippet); + expect(connectionDiagnosisText("Mac mini", noGrant!)).toContain(snippet); + const offline = { ...backoff(tailnetRefusalError("blocked")), network: "offline" as const }; + expect(diagnose(offline, "direct")?.headline).toBe("This device is offline"); + }); +}); + +describe("Tailscale facts in the diagnosis", () => { + const NOW = Date.UTC(2026, 9, 28, 12); + const HOUR = 60 * 60 * 1000; + const iso = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)); + const state = { ...backoff(tailnetTimeout), routes: [TAILNET_FAILED] }; + const peer: DesktopTailscalePeer = { + tailnetAddresses: ["100.64.0.1"], + magicDnsName: "mac-mini.tail1234.ts.net", + online: true, + lastSeen: null, + active: true, + curAddr: null, + relay: "fra", + peerRelay: null, + }; + const withFacts = (facts: Partial) => + presentConnectionDiagnosis({ hostLabel: "Mac mini", state, via: "direct", now: NOW, ...facts }); + const fact = (diagnosis: ReturnType, label: string) => + diagnosis?.facts.find(([name]) => name === label)?.[1]; + + it("blames this device first, then what the host last reported, then the peer list", () => { + const signedOutHost: ServerTailscaleStatus = { + state: "needs_login", + checkedAt: iso(NOW - 2 * HOUR), + magicDnsName: null, + keyExpiry: null, + issues: [{ cause: "needs_login", fix: "Run `sudo tailscale up` and log in." }], + }; + const host = withFacts({ hostTailscale: signedOutHost }); + expect(host?.headline).toBe("Tailscale on Mac mini is signed out (as of 2h ago)"); + expect(host?.steps).toEqual(["On Mac mini: Run `sudo tailscale up` and log in."]); + + const localDown = withFacts({ + hostTailscale: signedOutHost, + localTailscale: { state: "needs_login", peers: [] }, + }); + expect(localDown?.headline).toBe("Tailscale on this device is signed out"); + const awaitingApproval = withFacts({ + localTailscale: { state: "needs_machine_approval", peers: [] }, + }); + expect(awaitingApproval?.steps[0]).toMatch( + /^Approve this device at https:\/\/login\.tailscale/, + ); + + const running = (peers: ReadonlyArray) => + withFacts({ localTailscale: { state: "running", peers } }); + const lastSeen = iso(NOW - 2 * HOUR); + expect(running([{ ...peer, online: false, lastSeen }])?.headline).toBe( + "Mac mini is offline on your tailnet (last seen 2h ago)", + ); + expect(running([])?.headline).toBe("Mac mini is not on this device's tailnet"); + // Online is only a hint, so the headline stays the generic one. + expect(running([peer])?.headline).toBe("Mac mini did not answer over Tailscale"); + // A direct path needs a handshake, so Tailscale reached the host. + expect(running([{ ...peer, curAddr: "203.0.113.4:41641" }])?.headline).toBe( + "T3 Code on Mac mini did not answer", + ); + }); + + it("names the path to a peer only while it is active", () => { + const peerFact = (candidate: DesktopTailscalePeer) => + fact( + withFacts({ localTailscale: { state: "running", peers: [candidate] } }), + "Tailscale peer", + ); + expect(peerFact(peer)).toBe("online, DERP (fra)"); + expect(peerFact({ ...peer, curAddr: "203.0.113.4:41641" })).toBe("online, direct"); + expect(peerFact({ ...peer, peerRelay: "100.64.0.9:40000" })).toBe("online, peer relay"); + expect(peerFact({ ...peer, active: false, curAddr: "203.0.113.4:41641" })).toBe("online"); + }); + + it("shows only host issues that matter to someone connecting", () => { + const status = (issues: ServerTailscaleStatus["issues"]): ServerTailscaleStatus => ({ + state: "running", + checkedAt: iso(NOW), + magicDnsName: null, + keyExpiry: iso(NOW + 3 * 24 * HOUR), + issues, + }); + const https = { cause: "https_off", fix: "Turn on HTTPS." } as const; + const expiring = { cause: "key_expiring", fix: "Log in again." } as const; + expect(hostTailscaleNotice("Mac mini", status([https]), NOW)).toBeNull(); + expect(hostTailscaleNotice("Mac mini", status([https, expiring]), NOW)?.headline).toBe( + "The Tailscale node key on Mac mini expires in 3 days", + ); + expect(hostTailscaleNotice("Mac mini", status([expiring]), NOW + 4 * 24 * HOUR)?.headline).toBe( + "The Tailscale node key on Mac mini has expired (as of 4 days ago)", + ); + const outdated = { cause: "outdated", fix: "Update Tailscale." } as const; + expect(hostTailscaleNotice("Mac mini", status([https, outdated]), NOW)?.headline).toBe( + "Tailscale on Mac mini is too old for Tailscale sign-in", + ); + const userspace = { cause: "userspace_networking", fix: "Use a TUN device." } as const; + expect(hostTailscaleNotice("Mac mini", status([userspace]), NOW)?.headline).toBe( + "Tailscale on Mac mini uses userspace networking", + ); + }); +}); + +describe("offTailnetHint", () => { + it("hints only when every Tailscale route failed and something else works", () => { + const connected: SupervisorConnectionState = { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + phase: "connected", + routes: [TAILNET_FAILED, RELAY_ANSWERED], + }; + expect(offTailnetHint(connected)).toBe("Check that Tailscale is on for this device."); + const offline = { ...connected, network: "offline" as const, routes: [TAILNET_FAILED] }; + expect(offTailnetHint(offline)).toBeNull(); + // A host that is off fails every route while this device is online. + expect(offTailnetHint({ ...offline, network: "online" })).toBeNull(); + expect(offTailnetHint({ ...connected, routes: [RELAY_ANSWERED] })).toBeNull(); + // The host answered on Tailscale and failed for its own reason. + const hostFailed: ConnectionRouteOutcome = { + ...TAILNET_FAILED, + error: new ConnectionTransientError({ reason: "remote-unavailable", detail: "500" }), + }; + expect(offTailnetHint({ ...connected, routes: [hostFailed, RELAY_ANSWERED] })).toBeNull(); }); it("keeps the relay health status out of a failure that has its own cause", () => { diff --git a/packages/client-runtime/src/connection/diagnosis.ts b/packages/client-runtime/src/connection/diagnosis.ts index a3de100766a7c..742435477c889 100644 --- a/packages/client-runtime/src/connection/diagnosis.ts +++ b/packages/client-runtime/src/connection/diagnosis.ts @@ -1,16 +1,27 @@ import { + type DesktopTailscalePeer, + type DesktopTailscaleStatus, + formatRoughDuration, + type ServerTailscaleIssueCause, + type ServerTailscaleStatus, t3ConnectTroubleshootingUrl, type T3ConnectTroubleshootingSection, + tailscaleIssueHeadline, + type TailscaleState, } from "@t3tools/contracts"; import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; import * as DateTime from "effect/DateTime"; -import type { - ConnectionAttemptError, - ConnectionFailureCause, - ConnectionHop, - SupervisorConnectionState, +import { + type ConnectionAttemptError, + type ConnectionFailureCause, + type ConnectionHop, + type ConnectionRouteOutcome, + failureDidNotAnswer, + routeDidNotAnswer, + type SupervisorConnectionState, } from "./model.ts"; +import { tailnetRefusalVerdict } from "./tailnet.ts"; export type ConnectionHopResult = "ok" | "failed" | "unchecked"; @@ -26,22 +37,72 @@ export interface ConnectionDiagnosis { readonly hops: ReadonlyArray; readonly steps: ReadonlyArray; readonly facts: ReadonlyArray; - /** The troubleshooting guide section for this failure. Null for direct routes. */ + /** The troubleshooting guide section for this failure, when one exists. */ readonly moreInfoUrl: string | null; + /** A tailnet policy grant the host says would let this device in, to paste. */ + readonly policySnippet: string | null; } +/** This device's own Tailscale, when the platform can read it (desktop). */ +export type LocalTailscaleStatus = Pick; + export interface ConnectionDiagnosisInput { readonly hostLabel: string; readonly state: SupervisorConnectionState; /** The environment's usual route, used when the failure does not name one. */ readonly via: "relay" | "direct"; - /** The relay's last health check, which can name a cause before any connect attempt. */ + /** + * The relay's last health check. For a relay-only environment it can name a + * cause before any connect attempt; with other routes it is one more fact. + */ readonly relayStatus?: Pick< RelayEnvironmentStatusResponse, "status" | "endpointFailure" | "endpointStatus" | "offlineReason" > | null; readonly clientVersion?: string | undefined; readonly serverVersion?: string | null | undefined; + /** Turns the Tailscale hint into a verdict on platforms that can read it. */ + readonly localTailscale?: LocalTailscaleStatus | null; + /** The host's own Tailscale, as it last reported in its server config. */ + readonly hostTailscale?: ServerTailscaleStatus | null | undefined; + readonly now?: number; +} + +const TAILSCALE_TROUBLESHOOTING_URL = + "https://github.com/pingdotgg/t3code/blob/main/docs/user/remote-access.md#tailscale-troubleshooting"; + +const OFF_TAILNET_HINT = "Check that Tailscale is on for this device."; + +/** Tailscale routes when every one of them failed to reach the host, else none. */ +function failedTailnetRoutes( + routes: ReadonlyArray, +): ReadonlyArray { + const tailnet = routes.filter((route) => route.kind === "tailnet"); + return tailnet.every(routeDidNotAnswer) ? tailnet : []; +} + +/** True when every Tailscale route failed, so this device's own Tailscale is worth reading. */ +export function everyTailnetRouteFailed(state: SupervisorConnectionState): boolean { + return failedTailnetRoutes(state.routes).length > 0; +} + +/** The latest outcome on one route, for a route list. */ +export function routeOutcomeText(outcome: ConnectionRouteOutcome): string { + if (outcome.result === "answered") return "Answered"; + return routeDidNotAnswer(outcome) ? "Did not answer" : "Failed"; +} + +/** + * A hint, not a verdict: every Tailscale route failed while another route + * answered, so Tailscale may be off on this device. A host that is off fails + * every route, so it gets no hint. + */ +export function offTailnetHint(state: SupervisorConnectionState): string | null { + if (!everyTailnetRouteFailed(state)) return null; + const otherAnswered = state.routes.some( + (route) => route.kind !== "tailnet" && route.result === "answered", + ); + return otherAnswered ? OFF_TAILNET_HINT : null; } interface Located { @@ -73,7 +134,8 @@ function locate(input: ConnectionDiagnosisInput): Located | null { const via = failure.via ?? input.via; return { hop: unreachable && via === "relay" ? "tunnel" : "server", cause: null }; } - const status = input.relayStatus; + // With other routes, the relay's health says nothing about the route in use. + const status = input.via === "relay" ? input.relayStatus : null; if (status?.status !== "offline") return null; if (status.offlineReason === "tunnel_released") return { hop: "tunnel", cause: "released" }; const cause = status.endpointFailure ?? null; @@ -279,6 +341,166 @@ function hopLabel(hop: ConnectionHop, host: string): string { } } +/** Local states that rule out every Tailscale route from this device. */ +function localTailscaleDown( + state: TailscaleState | undefined, +): state is Exclude { + return state !== undefined && state !== "running" && state !== "unreadable"; +} + +/** Host issues that stop its Tailscale address from answering. */ +const HOST_TAILSCALE_DOWN: ReadonlySet = new Set([ + "not_running", + "needs_login", + "needs_machine_approval", + "stopped", + "key_expired", +]); + +/** Also shown on a host's row; the rest only matter while setting up the host. */ +const HOST_TAILSCALE_NOTICE: ReadonlySet = new Set([ + ...HOST_TAILSCALE_DOWN, + "key_expiring", + "health_warning", + "outdated", + "no_ipv4", + "userspace_networking", +]); + +/** " (as of 2h ago)" for facts a minute old or more. */ +function asOf(checkedAt: string, now: number): string { + const age = now - Date.parse(checkedAt); + return age >= 60_000 ? ` (as of ${formatRoughDuration(age)} ago)` : ""; +} + +/** The host's Tailscale problem as one headline and its fix, from the facts it last reported. */ +export function hostTailscaleNotice( + hostLabel: string, + status: ServerTailscaleStatus | null | undefined, + now: number, + causes: ReadonlySet = HOST_TAILSCALE_NOTICE, +): { readonly headline: string; readonly fix: string } | null { + const issue = status?.issues.find((candidate) => causes.has(candidate.cause)); + if (status == null || issue === undefined) return null; + return { + headline: `${tailscaleIssueHeadline(hostLabel, issue, status.keyExpiry, now)}${asOf(status.checkedAt, now)}`, + fix: `On ${hostLabel}: ${issue.fix}`, + }; +} + +/** + * The path to a peer, only while it is active. `relay` is the peer's home DERP + * region even on a direct path, so it means DERP only without the others. + */ +function tailscalePeerPath(peer: DesktopTailscalePeer): string | null { + if (!peer.active) return null; + if (peer.curAddr !== null) return "direct"; + if (peer.peerRelay !== null) return "peer relay"; + return peer.relay !== null ? `DERP (${peer.relay})` : null; +} + +// Peers list only IPv4 tailnet addresses, so an IPv6 route cannot be matched. +function routeHostname(address: string | null): string | null { + if (address === null || !URL.canParse(address)) return null; + const hostname = new URL(address).hostname.toLowerCase(); + return hostname.startsWith("[") ? null : hostname; +} + +/** + * The peer behind any of the failed routes, by tailnet address or MagicDNS + * name. Undefined when no route has an address a peer could match. + */ +function routePeer( + peers: ReadonlyArray, + routes: ReadonlyArray, +): DesktopTailscalePeer | null | undefined { + const hostnames = new Set(routes.flatMap((route) => routeHostname(route.address) ?? [])); + if (hostnames.size === 0) return undefined; + return ( + peers.find((peer) => { + const dnsName = peer.magicDnsName?.toLowerCase(); + return ( + peer.tailnetAddresses.some((address) => hostnames.has(address)) || + (dnsName !== undefined && + (hostnames.has(dnsName) || hostnames.has(dnsName.split(".")[0] ?? ""))) + ); + }) ?? null + ); +} + +function lastSeen(peer: DesktopTailscalePeer, now: number): string | null { + return peer.lastSeen === null + ? null + : `last seen ${formatRoughDuration(now - Date.parse(peer.lastSeen))} ago`; +} + +function peerFact(peer: DesktopTailscalePeer | null | undefined, now: number): string | null { + if (peer === undefined) return null; + if (peer === null) return "not on this device's tailnet"; + const detail = peer.online ? tailscalePeerPath(peer) : lastSeen(peer, now); + return [peer.online ? "online" : "offline", detail].filter(Boolean).join(", "); +} + +interface TailnetVerdict { + readonly headline: string; + readonly steps: ReadonlyArray; +} + +/** Why every Tailscale route failed: this device, then the host's report, then the peer list. */ +function tailnetVerdict( + input: ConnectionDiagnosisInput, + routes: ReadonlyArray, + now: number, +): TailnetVerdict { + const host = input.hostLabel; + const local = input.localTailscale; + const awake = `Make sure ${host} is awake and T3 Code is running on it.`; + if (localTailscaleDown(local?.state)) { + return { + headline: tailscaleIssueHeadline("this device", { cause: local.state, fix: "" }, null, now), + steps: [ + local.state === "needs_machine_approval" + ? "Approve this device at https://login.tailscale.com/admin/machines. T3 Code reconnects on its own." + : "Turn on Tailscale on this device and sign in. T3 Code reconnects on its own.", + ], + }; + } + const hostNotice = hostTailscaleNotice(host, input.hostTailscale, now, HOST_TAILSCALE_DOWN); + if (hostNotice !== null) return { headline: hostNotice.headline, steps: [hostNotice.fix] }; + + const generic = + input.relayStatus?.status === "online" + ? `${host} is online on T3 Connect, but its Tailscale address did not answer` + : `${host} did not answer over Tailscale`; + const peer = local?.state === "running" ? routePeer(local.peers, routes) : undefined; + if (peer === undefined) { + return { + headline: generic, + steps: [`Check that Tailscale is on and signed in on this device and on ${host}.`, awake], + }; + } + if (peer === null) { + return { + headline: `${host} is not on this device's tailnet`, + steps: [`Sign in to the same tailnet on this device and on ${host}.`, awake], + }; + } + // Online means connected to Tailscale's control plane, not reachable from here. + if (!peer.online) { + const seen = lastSeen(peer, now); + return { + headline: `${host} is offline on your tailnet${seen === null ? "" : ` (${seen})`}`, + steps: [`Make sure ${host} is awake and Tailscale is on.`], + }; + } + // A direct or peer-relay path means a handshake worked, so Tailscale reaches the host. + const reached = peer.active && (peer.curAddr !== null || peer.peerRelay !== null); + return { + headline: reached ? `T3 Code on ${host} did not answer` : generic, + steps: [awake, `Check that no firewall on ${host} blocks its port.`], + }; +} + /** * Turns a connection state into what failed, in order, and what to try. Returns * null when nothing has failed. Web and mobile render this same model. @@ -290,13 +512,27 @@ export function presentConnectionDiagnosis( if (located === null) return null; const host = input.hostLabel; const failure = input.state.lastFailure; + // A direct route that did not answer, where every Tailscale route also failed. + // A refusal from an earlier attempt says nothing once this device is offline. + const refusal = located.hop === "server" ? failure?.tailnetRefusal : undefined; + const tailnetRoutes = + located.hop === "server" && + located.cause === null && + failure !== null && + failureDidNotAnswer(failure) + ? failedTailnetRoutes(input.state.routes) + : []; + const tailnet = tailnetRoutes.length > 0; + const local = input.localTailscale; + const tailscaleOff = tailnet && localTailscaleDown(local?.state); + const now = input.now ?? DateTime.toEpochMillis(DateTime.nowUnsafe()); const relayPath = located.hop === "relay" || located.hop === "tunnel" || (failure === null && input.relayStatus?.status === "offline") || (failure?.via ?? input.via) === "relay"; const order: ReadonlyArray = HOP_ORDER[relayPath ? "relay" : "direct"]; - const failedIndex = order.indexOf(located.hop); + const failedIndex = order.indexOf(tailscaleOff ? "device" : located.hop); const hops = order.map((hop, index): ConnectionDiagnosisHop => ({ hop, label: hopLabel(hop, host), @@ -309,7 +545,7 @@ export function presentConnectionDiagnosis( const add = (label: string, value: string | number | null | undefined) => { if (value !== null && value !== undefined && value !== "") facts.push([label, String(value)]); }; - add("Cause", located.cause ?? failure?.reason); + add("Cause", refusal ?? located.cause ?? failure?.reason); add("HTTP status", httpStatus); add("Attempts", input.state.attempt > 0 ? input.state.attempt : null); add( @@ -319,17 +555,41 @@ export function presentConnectionDiagnosis( : DateTime.formatIso(DateTime.makeUnsafe(input.state.retryAt)), ); add("Route", relayPath ? "T3 Connect" : "Direct"); + for (const route of tailnetRoutes) add("Failed route", `Tailscale ${route.address ?? ""}`.trim()); + if (tailnet) { + add("Tailscale on this device", local?.state); + if (local?.state === "running") { + add("Tailscale peer", peerFact(routePeer(local.peers, tailnetRoutes), now)); + } + const hostTailscale = input.hostTailscale; + if (hostTailscale != null) { + add("Tailscale on host", `${hostTailscale.state}${asOf(hostTailscale.checkedAt, now)}`); + } + } + if (input.via !== "relay") add("T3 Connect", input.relayStatus?.status); add("App version", input.clientVersion); add("Host version", input.serverVersion); - add("Error", failure?.detail); + add("Error", refusal === undefined ? failure?.detail : undefined); add("Trace ID", failure?.traceId); + const verdict = + refusal !== undefined + ? tailnetRefusalVerdict(host, refusal) + : tailnet + ? tailnetVerdict(input, tailnetRoutes, now) + : null; return { - headline: headline(host, located, failure), + headline: verdict?.headline ?? headline(host, located, failure), hops, - steps: steps(host, located, failure), + steps: verdict?.steps ?? steps(host, located, failure), facts, - moreInfoUrl: relayPath ? t3ConnectTroubleshootingUrl(helpSection(located, failure)) : null, + moreInfoUrl: + tailnet || refusal !== undefined + ? TAILSCALE_TROUBLESHOOTING_URL + : relayPath + ? t3ConnectTroubleshootingUrl(helpSection(located, failure)) + : null, + policySnippet: refusal === undefined ? null : (failure?.suggestedGrant ?? null), }; } @@ -343,6 +603,9 @@ export function connectionDiagnosisText(hostLabel: string, diagnosis: Connection ...diagnosis.hops.map((hop) => `${hop.label}: ${HOP_MARK[hop.result]}`), "", ...diagnosis.facts.map(([label, value]) => `${label}: ${value}`), + ...(diagnosis.policySnippet + ? ["", "Grant to add to your tailnet policy:", diagnosis.policySnippet] + : []), ...(diagnosis.moreInfoUrl ? ["", `More info: ${diagnosis.moreInfoUrl}`] : []), ].join("\n"); } diff --git a/packages/client-runtime/src/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts index 6039f25fa99d6..d1db6c5a8deec 100644 --- a/packages/client-runtime/src/connection/driver.ts +++ b/packages/client-runtime/src/connection/driver.ts @@ -11,15 +11,19 @@ import type { ConnectionCatalogEntry, ConnectionRoute } from "./catalog.ts"; import type { ConnectionAttemptError, ConnectionAttemptStage, + ConnectionRouteOutcome, PreparedConnection, } from "./model.ts"; import { ConnectionTransientError } from "./model.ts"; import * as ConnectionResolver from "./resolver.ts"; import { + connectionRouteAddress, + connectionRouteId, + connectionRouteKind, connectionRoutes, routeEntry, routeHttpBaseUrl, - sendsTokenInClear, + unusedWithTailnetSignIn, usableRoutes, } from "./routes.ts"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; @@ -41,13 +45,12 @@ export interface EnvironmentConnectionLease { } export type RouteCheck = "answered" | "silent" | "unchecked"; -/** "unprepared" means the route answered its check but could not be prepared. */ -export type RoutePreflight = "ready" | "unprepared" | "silent" | "unchecked"; + +export type ReportRouteOutcome = (outcome: ConnectionRouteOutcome) => Effect.Effect; const ROUTE_CHECK_TIMEOUT_MS = 2_500; // Preferred direct routes share one head start, so a relay connection does not -// wait the full probe timeout for a LAN the device has already left. A route -// that misses it connects over relay, then upgrades with a second reconnect. +// wait the full probe timeout for a LAN the device has already left. const ROUTE_CHECK_HEAD_START_MS = 500; export class ConnectionDriver extends Context.Service< @@ -56,11 +59,8 @@ export class ConnectionDriver extends Context.Service< readonly connect: ( entry: ConnectionCatalogEntry, reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + reportRoute: ReportRouteOutcome, ) => Effect.Effect; - readonly preflight: ( - entry: ConnectionCatalogEntry, - route: ConnectionRoute, - ) => Effect.Effect; /** Asks the host behind a live session to answer a fresh request on the session's own origin. */ readonly checkHost: (prepared: PreparedConnection) => Effect.Effect; } @@ -72,14 +72,31 @@ export const connectOverRoutes = Effect.fn("ConnectionDriver.connectOverRoutes") connectRoute: ( route: ConnectionRoute, ) => Effect.Effect, + reportRoute: ReportRouteOutcome, ) { const routes = usableRoutes(entry); + const describe = (route: ConnectionRoute) => ({ + routeId: connectionRouteId(route.target), + kind: connectionRouteKind(route), + address: connectionRouteAddress(route), + }); + const attemptScope = yield* Scope.Scope; + // The checks live in the attempt's scope, so a route that answers after + // another one connected still reports its outcome. Each check is bounded. const checks = routes.length === 1 ? [] - : yield* Effect.forEach(routes, (route) => Effect.forkChild(checkRoute(route))); + : yield* Effect.forEach(routes, (route) => + checkRoute(route).pipe( + Effect.tap((check) => + check === "unchecked" + ? Effect.void + : reportRoute({ ...describe(route), result: check }), + ), + Effect.forkIn(attemptScope), + ), + ); const headStart = yield* Effect.forkChild(Effect.sleep(ROUTE_CHECK_HEAD_START_MS)); - const attemptScope = yield* Scope.Scope; let transient: ConnectionAttemptError | undefined; let blocked: ConnectionAttemptError | undefined; // Each route gets its own scope so a half-open session closes before the next try. @@ -92,12 +109,19 @@ export const connectOverRoutes = Effect.fn("ConnectionDriver.connectOverRoutes") ); if (result._tag === "Failure") { if (result.failure._tag === "ConnectionTransientError") transient ??= result.failure; - else blocked ??= result.failure; + // The host's own refusal says more than this device's. + else if (blocked?.hop === undefined || blocked.hop === "device") blocked = result.failure; } + yield* reportRoute( + result._tag === "Success" + ? { ...describe(route), result: "answered" } + : { ...describe(route), result: "failed", error: result.failure }, + ); return result; }); const silent: Array<{ readonly route: ConnectionRoute; readonly index: number }> = []; for (const [index, route] of routes.entries()) { + if (unusedWithTailnetSignIn(entry, route)) continue; const check = checks.length === 0 ? "unchecked" @@ -128,6 +152,9 @@ export const connectOverRoutes = Effect.fn("ConnectionDriver.connectOverRoutes") if (result._tag === "Success") return result.success; if (result.failure.reason === "unsupported") return yield* result.failure; } + // A skipped route's check still tells the diagnosis whether this device + // reaches the host off Tailscale. Every check is bounded. + yield* Fiber.joinAll(checks); return yield* ( transient ?? blocked ?? @@ -181,6 +208,7 @@ export const make = Effect.gen(function* () { const connect = Effect.fn("ConnectionDriver.connect")(function* ( entry: ConnectionCatalogEntry, reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + reportRoute: ReportRouteOutcome, ) { const target = entry.target; yield* Effect.annotateCurrentSpan({ @@ -204,6 +232,9 @@ export const make = Effect.gen(function* () { .renewDpopBeforeExpiry({ expectedEnvironmentId: prepared.environmentId, expiresAtEpochMs: prepared.httpAuthorization.expiresAtEpochMs, + ...(prepared.httpAuthorization.tailnetCredentialId === undefined + ? {} + : { tailnetCredentialId: prepared.httpAuthorization.tailnetCredentialId }), }) .pipe( Effect.catch((error) => @@ -214,26 +245,11 @@ export const make = Effect.gen(function* () { } return { prepared, session } satisfies EnvironmentConnectionLease; }), + reportRoute, ); }); - const preflight = (entry: ConnectionCatalogEntry, route: ConnectionRoute) => - (sendsTokenInClear(connectionRoutes(entry), route) - ? Effect.succeed("silent" as const) - : checkRoute(entry, route) - ).pipe( - Effect.filterOrElse( - (check): check is Exclude => check !== "answered", - () => - resolver.prepare(routeEntry(entry, route)).pipe( - Effect.as("ready"), - Effect.orElseSucceed((): RoutePreflight => "unprepared"), - ), - ), - Effect.withSpan("ConnectionDriver.preflight"), - ); - - return ConnectionDriver.of({ connect, preflight, checkHost }); + return ConnectionDriver.of({ connect, checkHost }); }); export const layer = Layer.effect(ConnectionDriver, make); diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts index a5961df442f2c..eac0ff48f4452 100644 --- a/packages/client-runtime/src/connection/errors.ts +++ b/packages/client-runtime/src/connection/errors.ts @@ -200,7 +200,11 @@ export function mapRemoteEnvironmentError( case "EnvironmentOperationForbiddenError": return new ConnectionBlockedError({ reason: "permission", - detail: "The environment credential does not grant the required access.", + detail: + error._tag === "EnvironmentScopeRequiredError" + ? (error.tailnetGrantFix ?? + `The environment credential does not grant ${error.requiredPermission ?? error.requiredScope}.`) + : "The environment credential does not grant the required access.", traceId: error.traceId, hop: "server", via, diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index 02a30adb57fae..1f9b3cb06b836 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -12,6 +12,7 @@ export * as ConnectionOnboarding from "./onboarding.ts"; export * from "./presentation.ts"; export * as ProfileStore from "./profileStore.ts"; export * from "./routes.ts"; +export * from "./tailnet.ts"; export { type RouteCheck } from "./driver.ts"; export * as EnvironmentRegistry from "./registry.ts"; // Flat so consumers' inferred types can name them. diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index 73675d431aa7a..823371e9eab09 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -1,7 +1,9 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, TailnetSignInRefusedReason } from "@t3tools/contracts"; import { RelayEnvironmentEndpointFailure } from "@t3tools/contracts/relay"; import * as Schema from "effect/Schema"; +import type { ConnectionRouteKind } from "./routes.ts"; + const ConnectionTargetBase = { environmentId: EnvironmentId, label: Schema.String, @@ -100,6 +102,10 @@ const ConnectionFailureFields = { via: Schema.optionalKey(Schema.Literals(["relay", "direct"])), failureCause: Schema.optionalKey(ConnectionFailureCause), httpStatus: Schema.optionalKey(Schema.Number), + /** The check a host's Tailscale sign-in failed. */ + tailnetRefusal: Schema.optionalKey(TailnetSignInRefusedReason), + /** The tailnet policy grant the host says would let this device in. */ + suggestedGrant: Schema.optionalKey(Schema.String), }; export class ConnectionTransientError extends Schema.TaggedError()( @@ -145,6 +151,8 @@ export type PreparedHttpAuthorization = readonly _tag: "Dpop"; readonly accessToken: string; readonly expiresAtEpochMs: number; + /** Set for a Tailscale session, which renews from this credential instead of T3 Connect. */ + readonly tailnetCredentialId?: string; }; export interface PreparedConnection { @@ -166,6 +174,42 @@ export type SupervisorConnectionPhase = export type ConnectionAttemptStage = "preparing" | "opening" | "synchronizing"; +/** + * What the latest attempt saw on one route: the host answered there, did not + * answer the reachability check, or the connection over it failed. + */ +export type ConnectionRouteOutcome = { + readonly routeId: string; + readonly kind: ConnectionRouteKind; + readonly address: string | null; +} & ( + | { readonly result: "answered" | "silent" } + | { readonly result: "failed"; readonly error: ConnectionAttemptError } +); + +const UNANSWERED_REASONS: ReadonlySet = new Set([ + "network", + "timeout", + "transport", +]); + +/** A failure that gave no sign the host is there, as opposed to one where it answered. */ +export function failureDidNotAnswer(error: ConnectionAttemptError): boolean { + return ( + error._tag === "ConnectionTransientError" && + UNANSWERED_REASONS.has(error.reason) && + error.tailnetRefusal === undefined + ); +} + +/** A route that gave no sign the host is there, as opposed to one that answered and refused. */ +export function routeDidNotAnswer(outcome: ConnectionRouteOutcome): boolean { + return ( + outcome.result === "silent" || + (outcome.result === "failed" && failureDidNotAnswer(outcome.error)) + ); +} + export interface SupervisorConnectionState { readonly desired: boolean; readonly network: NetworkStatus; @@ -175,6 +219,8 @@ export interface SupervisorConnectionState { readonly generation: number; readonly lastFailure: ConnectionAttemptError | null; readonly retryAt: number | null; + /** Each route's latest outcome. A route keeps it until an attempt reaches that route again. */ + readonly routes: ReadonlyArray; } export type ConnectionProjectionPhase = "disconnected" | "synchronizing" | "ready"; @@ -204,4 +250,5 @@ export const AVAILABLE_CONNECTION_STATE: SupervisorConnectionState = Object.free generation: 0, lastFailure: null, retryAt: null, + routes: [], }); diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 2131507e547f7..2c7f03f405a1e 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, type AuthEnvironmentScope, ORCHESTRATION_PROTOCOL_VERSION, + TailnetIpv4, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -16,11 +17,15 @@ import * as RpcHttp from "../rpc/http.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; import * as Persistence from "../platform/persistence.ts"; import { fetchRemoteSessionState } from "../authorization/remote.ts"; +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import { BearerConnectionCredential, BearerConnectionProfile, BearerConnectionRegistration, + type ConnectionCredential, PrimaryConnectionRegistration, + TailnetConnectionCredential, + TailnetConnectionRegistration, } from "./catalog.ts"; import * as Connectivity from "./connectivity.ts"; import * as ConnectionCredentialStore from "./credentialStore.ts"; @@ -35,12 +40,20 @@ import { prepareBearerConnectionUpdate, preparePairingRegistration, prepareSshRegistration, + prepareTailnetRegistration, } from "./onboarding.ts"; import * as EnvironmentRegistry from "./registry.ts"; import * as ConnectionProfileStore from "./profileStore.ts"; import { connectionRoutes, routeHttpBaseUrl } from "./routes.ts"; import * as ConnectionWakeups from "./wakeups.ts"; +const TAILNET_SESSION = new TailnetConnectionCredential({ + signInBaseUrl: "http://100.88.1.2:3773/", + accessToken: "tailnet-access-token", + expiresAtEpochMs: 3_600_000, + dpopThumbprint: "thumbprint-1", +}); + const layerClientPresentation = Layer.succeed( ClientCapabilities.ClientPresentation, ClientCapabilities.ClientPresentation.of({ @@ -48,6 +61,7 @@ const layerClientPresentation = Layer.succeed( label: "T3 Code Test", deviceType: "desktop", os: "Test OS", + surface: "desktop", }, }), ); @@ -59,6 +73,7 @@ function layerPairingHttp( readonly failExchange?: boolean; readonly protocolVersion?: number; readonly selfUpdate?: boolean; + readonly tailnetSignIn?: boolean; readonly grantScopes?: ReadonlyArray; readonly exchangeGate?: { readonly reached: () => void; readonly release: Promise }; }, @@ -88,6 +103,7 @@ function layerPairingHttp( capabilities: { repositoryIdentity: true, ...(options?.selfUpdate === true ? { serverSelfUpdate: "boot-service" } : {}), + ...(options?.tailnetSignIn === true ? { tailnetSignIn: true } : {}), }, }), ); @@ -160,7 +176,13 @@ function layerPairingHttp( } function layerPairingOnboarding( - options?: Parameters[1], + options?: Parameters[1] & { + readonly savedCredential?: ConnectionCredential; + /** Saving fails, as with no OS keyring for the catalog. */ + readonly saveFails?: boolean; + /** Collects each Tailscale sign-in's address. */ + readonly signIns?: Array; + }, calls: Array<{ readonly url: string; readonly init: RequestInit }> = [], ) { const dependencies = Layer.mergeAll( @@ -169,15 +191,24 @@ function layerPairingOnboarding( Layer.mock(Persistence.ConnectionTargetStore)({ list: Effect.succeed([]), listDisabled: Effect.succeed([]), + listDismissed: Effect.succeed([]), }), Layer.mock(Persistence.ConnectionRegistrationStore)({ - register: () => Effect.void, + register: () => + options?.saveFails === true + ? Effect.fail( + new Persistence.ConnectionPersistenceError({ + operation: "register-connection", + message: "No secure storage.", + }), + ) + : Effect.void, setEnabled: () => Effect.void, }), Layer.mock(Persistence.EnvironmentCacheStore)({}), Layer.mock(ConnectionProfileStore.ConnectionProfileStore)({}), Layer.mock(ConnectionCredentialStore.ConnectionCredentialStore)({ - get: () => Effect.succeedNone, + get: () => Effect.succeed(Option.fromUndefinedOr(options?.savedCredential)), put: () => Effect.void, }), Layer.mock(ClientCapabilities.SshEnvironmentGateway)({}), @@ -193,6 +224,20 @@ function layerPairingOnboarding( ); return Onboarding.layer.pipe( Layer.provideMerge(EnvironmentRegistry.layer.pipe(Layer.provideMerge(dependencies))), + Layer.provideMerge( + Layer.mock(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization)({ + // A session is pinned to the address it signed in at. + signInTailnet: (input) => + Effect.sync(() => options?.signIns?.push(input.httpBaseUrl)).pipe( + Effect.as( + new TailnetConnectionCredential({ + ...TAILNET_SESSION, + signInBaseUrl: input.httpBaseUrl, + }), + ), + ), + }), + ), ); } @@ -273,6 +318,17 @@ describe("connection onboarding", () => { : String(tokenRequest?.init.body); const tokenParams = new URLSearchParams(tokenBody); expect(tokenParams.get("subject_token")).toBe("pairing-token"); + const tailnet = yield* preparePairingRegistration({ + host: "100.88.1.2", + pairingCode: "pairing-token", + }).pipe(Effect.provide(layerPairingOnboarding())); + // A code is a secret, so a bare address keeps the HTTPS default, tailnet or not. + expect(tailnet.profile.httpBaseUrl).toBe("https://100.88.1.2/"); + const shortName = yield* preparePairingRegistration({ + host: "studio", + pairingCode: "pairing-token", + }).pipe(Effect.provide(layerPairingOnboarding())); + expect(shortName.profile.httpBaseUrl).toBe("https://studio/"); expect(tokenParams.has("scope")).toBe(false); expect(tokenParams.get("client_label")).toBe("T3 Code Test"); expect(tokenParams.get("client_device_type")).toBe("desktop"); @@ -280,6 +336,37 @@ describe("connection onboarding", () => { }), ); + it.effect("signs in with no code only when the machine offers Tailscale sign-in", () => + Effect.gen(function* () { + const prepare = (tailnetSignIn: boolean) => { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + return prepareTailnetRegistration({ httpBaseUrl: "http://100.88.1.2:3773/" }).pipe( + Effect.provide(layerPairingOnboarding({ tailnetSignIn }, calls)), + Effect.map((registration) => ({ registration, calls })), + ); + }; + + const pairing = yield* prepare(false); + expect(pairing.registration).toEqual(Option.none()); + expect(pairing.calls.map((call) => new URL(call.url).pathname)).toEqual([ + "/.well-known/t3/environment", + ]); + + const signedIn = yield* prepare(true); + expect(Option.getOrThrow(signedIn.registration)).toMatchObject({ + _tag: "TailnetConnectionRegistration", + target: { connectionId: "tailnet:environment-paired:http://100.88.1.2:3773" }, + profile: { + httpBaseUrl: "http://100.88.1.2:3773/", + wsBaseUrl: "ws://100.88.1.2:3773/", + authorization: "tailnet", + network: "tailscale", + }, + credential: TAILNET_SESSION, + }); + }), + ); + it.effect("turns a switched-off environment back on when it is paired again", () => Effect.gen(function* () { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; @@ -617,6 +704,112 @@ describe("connection onboarding", () => { }), ); + it.effect("renames a Tailscale machine and signs in again when its address moves", () => + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const onboarding = yield* Onboarding.ConnectionOnboarding; + const connectionId = "tailnet:environment-paired:http://100.88.1.2:3773"; + yield* registry.register( + new TailnetConnectionRegistration({ + target: new BearerConnectionTarget({ + environmentId: PAIRED_ENVIRONMENT_ID, + label: "studio", + connectionId, + }), + profile: new BearerConnectionProfile({ + connectionId, + environmentId: PAIRED_ENVIRONMENT_ID, + label: "studio", + httpBaseUrl: "http://100.88.1.2:3773/", + wsBaseUrl: "ws://100.88.1.2:3773/", + authorization: "tailnet", + network: "tailscale", + }), + credential: TAILNET_SESSION, + }), + ); + const savedProfile = SubscriptionRef.get(registry.entries).pipe( + Effect.map((entries) => Option.getOrThrow(entries.get(PAIRED_ENVIRONMENT_ID)!.profile)), + ); + + // The saved address typed bare, as the add form accepts it. + yield* onboarding.updateBearer({ + environmentId: PAIRED_ENVIRONMENT_ID, + label: "Studio", + httpBaseUrl: "100.88.1.2", + }); + expect(yield* savedProfile).toMatchObject({ + label: "Studio", + httpBaseUrl: "http://100.88.1.2:3773/", + authorization: "tailnet", + network: "tailscale", + }); + + // A LAN address hides who the device is, so the session cannot move there. + const refused = yield* Effect.flip( + onboarding.updateBearer({ + environmentId: PAIRED_ENVIRONMENT_ID, + label: "Studio", + httpBaseUrl: "http://192.168.1.10:3773/", + }), + ); + expect(refused).toMatchObject({ _tag: "ConnectionBlockedError", reason: "configuration" }); + + yield* onboarding.updateBearer({ + environmentId: PAIRED_ENVIRONMENT_ID, + label: "Studio", + httpBaseUrl: "100.88.1.3", + }); + expect(yield* savedProfile).toMatchObject({ + connectionId, + httpBaseUrl: "http://100.88.1.3:3773/", + authorization: "tailnet", + }); + + // A bare Tailscale address means port 3773, so a typed `:80` must stay in the saved URL. + yield* onboarding.updateBearer({ + environmentId: PAIRED_ENVIRONMENT_ID, + label: "Studio", + httpBaseUrl: "100.88.1.4:80", + }); + yield* onboarding.updateBearer({ + environmentId: PAIRED_ENVIRONMENT_ID, + label: "Studio 80", + httpBaseUrl: "http://100.88.1.4:80/", + }); + expect(yield* savedProfile).toMatchObject({ + label: "Studio 80", + httpBaseUrl: "http://100.88.1.4:80/", + authorization: "tailnet", + }); + }).pipe( + Effect.provide( + layerPairingOnboarding({ tailnetSignIn: true, savedCredential: TAILNET_SESSION }), + ), + ), + ); + + it.effect("does not sign in again to a found machine it could not save", () => { + const signIns: Array = []; + return Effect.gen(function* () { + const onboarding = yield* Onboarding.ConnectionOnboarding; + const found = [ + { + name: "studio", + magicDnsName: "studio.tail.ts.net", + tailnetIpv4: TailnetIpv4.make("100.88.1.2"), + environmentId: PAIRED_ENVIRONMENT_ID, + }, + ]; + expect((yield* onboarding.addDiscoveredTailnetMachines(found)).added).toEqual([]); + yield* onboarding.addDiscoveredTailnetMachines(found); + // Each sign-in mints a session on the host, so a save that cannot work is not retried. + expect(signIns).toEqual(["http://100.88.1.2:3773/"]); + }).pipe( + Effect.provide(layerPairingOnboarding({ tailnetSignIn: true, saveFails: true, signIns })), + ); + }); + it.effect("prepares an SSH registration from the provisioned platform environment", () => Effect.gen(function* () { const target = { diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 48e8b614c67c8..4a7dbca2c7346 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -1,4 +1,8 @@ -import type { DesktopSshEnvironmentTarget, EnvironmentId } from "@t3tools/contracts"; +import type { + DesktopSshEnvironmentTarget, + EnvironmentId, + TailnetPeerEnvironment, +} from "@t3tools/contracts"; import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -9,6 +13,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import * as HttpClient from "effect/http/HttpClient"; import { bootstrapRemoteBearerSession } from "../authorization/remote.ts"; +import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts"; import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; @@ -20,6 +25,8 @@ import { type ConnectionCredential, SshConnectionProfile, SshConnectionRegistration, + TailnetConnectionCredential, + TailnetConnectionRegistration, } from "./catalog.ts"; import * as ConnectionCredentialStore from "./credentialStore.ts"; import { mapRemoteEnvironmentError } from "./errors.ts"; @@ -32,7 +39,13 @@ import { import * as Persistence from "../platform/persistence.ts"; import * as EnvironmentRegistry from "./registry.ts"; import { orchestrationProtocolCompatibilityError } from "./compatibility.ts"; -import { connectionRoutes, routeEntry, sshTargetKey } from "./routes.ts"; +import { connectionRoutes, routeEntry, routeHttpBaseUrl, sshTargetKey } from "./routes.ts"; +import { + machinesToAdd, + supportsTailnetSignIn, + TAILNET_SIGN_IN_PORT, + tailnetSignInBaseUrl, +} from "./tailnet.ts"; export interface PairingConnectionInput { readonly pairingUrl?: string; @@ -47,6 +60,20 @@ export interface SshConnectionInput { readonly expectedEnvironmentId?: EnvironmentId; } +export interface TailnetConnectionInput { + /** `studio`, `studio.tailnet.ts.net`, `100.x.y.z`, or the `t3 up` QR's plain URL. */ + readonly address: string; + /** Adds the address as a route to this saved environment. */ + readonly expectedEnvironmentId?: EnvironmentId; +} + +/** What one pass over machines found on the tailnet did. */ +export interface TailnetDiscoveryResult { + readonly added: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }>; + /** A saved environment answered at an address it was not saved with. */ + readonly conflicts: ReadonlyArray<{ readonly label: string; readonly address: string }>; +} + export interface BearerConnectionUpdateInput { readonly environmentId: EnvironmentId; readonly label: string; @@ -62,6 +89,20 @@ export class ConnectionOnboarding extends Context.Service< EnvironmentId, ConnectionAttemptError | Persistence.ConnectionPersistenceError >; + /** + * Signs in through Tailscale with no code. None when the machine does not + * offer Tailscale sign-in, so the caller asks for a pairing code. + */ + readonly registerTailnet: ( + input: TailnetConnectionInput, + ) => Effect.Effect< + Option.Option, + ConnectionAttemptError | Persistence.ConnectionPersistenceError + >; + /** Adds found machines that offer Tailscale sign-in, skipping removed and saved ones. */ + readonly addDiscoveredTailnetMachines: ( + machines: ReadonlyArray, + ) => Effect.Effect; readonly registerSsh: ( input: SshConnectionInput, ) => Effect.Effect< @@ -76,6 +117,8 @@ export class ConnectionOnboarding extends Context.Service< const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.resolvePairingTarget")( function* (input: PairingConnectionInput) { + // A code is a secret, so its address resolves as it always has; an address alone means + // Tailscale sign-in, which goes through `registerTailnet`, not here. return yield* Effect.try({ try: () => resolveRemotePairingTarget(input), catch: (cause) => @@ -169,8 +212,12 @@ const registerPairingConnection = Effect.fn( }); const isBearerCredential = Schema.is(BearerConnectionCredential); +const isTailnetCredential = Schema.is(TailnetConnectionCredential); const isBearerProfile = Schema.is(BearerConnectionProfile); +// URL drops a default port, so `http://host:80/` and `http://host/` are one address. +const sameAddress = (a: string, b: string) => normalizeHttpBaseUrl(a) === normalizeHttpBaseUrl(b); + const updateBearerConnection = Effect.fn( "clientRuntime.connection.onboarding.updateBearerConnection", )(function* (input: BearerConnectionUpdateInput) { @@ -188,14 +235,173 @@ const updateBearerConnection = Effect.fn( entry?.target._tag === "BearerConnectionTarget" ? yield* credentials.get(entry.target.connectionId) : Option.none(); + // A Tailscale session is pinned to where it signed in, so a new address signs in again. + const signInBaseUrl = Option.exists(credential, isTailnetCredential) + ? tailnetSignInBaseUrl(input.httpBaseUrl) + : null; + const savedHttpBaseUrl = entry === undefined ? null : routeHttpBaseUrl(entry); + const signedIn = + signInBaseUrl === null || + savedHttpBaseUrl === null || + sameAddress(signInBaseUrl, savedHttpBaseUrl) + ? Option.none() + : yield* prepareTailnetRegistration({ + httpBaseUrl: signInBaseUrl, + expectedEnvironmentId: input.environmentId, + joinsRoutes: true, + }); const registration = yield* prepareBearerConnectionUpdate({ - input, + input: Option.match(signedIn, { + onNone: () => (signInBaseUrl === null ? input : { ...input, httpBaseUrl: signInBaseUrl }), + onSome: (fresh) => ({ ...input, httpBaseUrl: fresh.profile.httpBaseUrl }), + }), entry: Option.fromUndefinedOr(entry), - credential, + credential: Option.match(signedIn, { + onNone: () => credential, + onSome: (fresh) => Option.some(fresh.credential), + }), }); yield* registry.register(registration); }); +const DISCOVERY_PROBE_TIMEOUT_MS = 3_000; +const DISCOVERY_CONCURRENCY = 4; + +export const prepareTailnetRegistration = Effect.fn( + "clientRuntime.connection.onboarding.prepareTailnetRegistration", +)(function* (input: { + readonly httpBaseUrl: string; + readonly expectedEnvironmentId?: EnvironmentId; + /** Only when the user picked the saved environment this address joins. */ + readonly joinsRoutes?: boolean; + readonly timeoutMs?: number; +}) { + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: input.httpBaseUrl, + ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), + }).pipe(Effect.mapError(mapRemoteEnvironmentError)); + if ( + input.expectedEnvironmentId !== undefined && + descriptor.environmentId !== input.expectedEnvironmentId + ) { + return yield* differentMachineError(descriptor.label); + } + if (descriptor.capabilities.tailnetSignIn !== true) { + return Option.none(); + } + const connectionId = `tailnet:${descriptor.environmentId}:${new URL(input.httpBaseUrl).origin}`; + const route = { + target: new BearerConnectionTarget({ + environmentId: descriptor.environmentId, + label: descriptor.label, + connectionId, + }), + profile: new BearerConnectionProfile({ + connectionId, + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: input.httpBaseUrl, + wsBaseUrl: deriveWsBaseUrl(input.httpBaseUrl), + authorization: "tailnet", + network: "tailscale", + }), + }; + if (input.joinsRoutes !== true) { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.assertKnownAddress({ ...route, profile: Option.some(route.profile) }); + } + const compatibilityError = orchestrationProtocolCompatibilityError(descriptor); + if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) { + return yield* compatibilityError; + } + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + const credential = yield* remote.signInTailnet({ httpBaseUrl: input.httpBaseUrl }); + return Option.some(new TailnetConnectionRegistration({ ...route, credential })); +}); + +const saveTailnetRegistration = Effect.fn( + "clientRuntime.connection.onboarding.saveTailnetRegistration", +)(function* (registration: TailnetConnectionRegistration, joinsRoutes: boolean) { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const environmentId = registration.target.environmentId; + // A found or typed address joins a saved environment only when the user picked it. + if (!(yield* registry.register(registration, { knownAddressOnly: !joinsRoutes }))) { + return environmentId; + } + yield* registry.setCompatibility(environmentId, null); + yield* registry + .setEnabled(environmentId, true) + .pipe(Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void })); + return environmentId; +}); + +const registerTailnetConnection = Effect.fn( + "clientRuntime.connection.onboarding.registerTailnetConnection", +)(function* (input: TailnetConnectionInput) { + const presentation = yield* ClientCapabilities.ClientPresentation; + const httpBaseUrl = tailnetSignInBaseUrl(input.address); + if (httpBaseUrl === null || !supportsTailnetSignIn(presentation.metadata)) { + return Option.none(); + } + const joinsRoutes = input.expectedEnvironmentId !== undefined; + const registration = yield* prepareTailnetRegistration({ + httpBaseUrl, + joinsRoutes, + ...(input.expectedEnvironmentId === undefined + ? {} + : { expectedEnvironmentId: input.expectedEnvironmentId }), + }); + if (Option.isNone(registration)) return Option.none(); + return Option.some(yield* saveTailnetRegistration(registration.value, joinsRoutes)); +}); + +const addDiscoveredTailnetMachines = Effect.fn( + "clientRuntime.connection.onboarding.addDiscoveredTailnetMachines", +)(function* (machines: ReadonlyArray, unsaveable: Set) { + const presentation = yield* ClientCapabilities.ClientPresentation; + if (!supportsTailnetSignIn(presentation.metadata)) return { added: [], conflicts: [] }; + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const { add, conflicts } = machinesToAdd({ + machines, + entries: yield* SubscriptionRef.get(registry.entries), + dismissed: yield* SubscriptionRef.get(registry.dismissedEnvironmentIds), + }); + const added = yield* Effect.forEach( + add.filter((machine) => !unsaveable.has(machine.environmentId)), + (machine) => + prepareTailnetRegistration({ + httpBaseUrl: `http://${machine.tailnetIpv4}:${TAILNET_SIGN_IN_PORT}/`, + expectedEnvironmentId: machine.environmentId, + timeoutMs: DISCOVERY_PROBE_TIMEOUT_MS, + }).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed([]), + onSome: (registration) => + saveTailnetRegistration(registration, false).pipe( + // Every try signs in again and leaves a session behind on the host. + Effect.tapError(() => Effect.sync(() => unsaveable.add(machine.environmentId))), + Effect.as([ + { environmentId: machine.environmentId, label: registration.target.label }, + ]), + ), + }), + ), + Effect.catch((error) => + Effect.logInfo("Could not add a machine found on the tailnet.", { + environmentId: machine.environmentId, + error, + }).pipe(Effect.as([])), + ), + ), + { concurrency: DISCOVERY_CONCURRENCY }, + ); + return { + added: added.flat(), + conflicts: conflicts.map(({ machine, label }) => ({ label, address: machine.tailnetIpv4 })), + } satisfies TailnetDiscoveryResult; +}); + export const prepareBearerConnectionUpdate = Effect.fn( "clientRuntime.connection.onboarding.prepareBearerConnectionUpdate", )(function* (options: { @@ -204,21 +410,19 @@ export const prepareBearerConnectionUpdate = Effect.fn( readonly credential: Option.Option; }) { const entry = Option.getOrNull(options.entry); - if ( - entry === undefined || - entry === null || - entry.target._tag !== "BearerConnectionTarget" || - Option.isNone(entry.profile) || - !isBearerProfile(entry.profile.value) - ) { + const saved = entry === null ? null : Option.getOrNull(entry.profile); + if (entry === null || entry.target._tag !== "BearerConnectionTarget" || !isBearerProfile(saved)) { return yield* new ConnectionBlockedError({ reason: "configuration", detail: "Only saved bearer environments can be edited.", }); } - const credential = options.credential; - if (Option.isNone(credential) || !isBearerCredential(credential.value)) { + const credential = Option.getOrUndefined(options.credential); + if ( + credential === undefined || + (!isBearerCredential(credential) && !isTailnetCredential(credential)) + ) { return yield* new ConnectionBlockedError({ reason: "authentication", detail: "The saved bearer credential is unavailable.", @@ -241,20 +445,49 @@ export const prepareBearerConnectionUpdate = Effect.fn( }), }); const connectionId = entry.target.connectionId; - return new BearerConnectionRegistration({ - target: new BearerConnectionTarget({ - environmentId: options.input.environmentId, - label, - connectionId, - }), + const target = new BearerConnectionTarget({ + environmentId: options.input.environmentId, + label, + connectionId, + }); + if (isBearerCredential(credential)) { + return new BearerConnectionRegistration({ + target, + profile: new BearerConnectionProfile({ + connectionId, + environmentId: options.input.environmentId, + label, + httpBaseUrl, + wsBaseUrl: deriveWsBaseUrl(httpBaseUrl), + }), + credential, + }); + } + const moved = !sameAddress(httpBaseUrl, saved.httpBaseUrl); + // A Tailscale session works only at the address it signed in at. + if (moved && !sameAddress(httpBaseUrl, credential.signInBaseUrl)) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + detail: "A Tailscale machine's address must be its Tailscale name or 100.x address.", + }); + } + return new TailnetConnectionRegistration({ + target, profile: new BearerConnectionProfile({ connectionId, environmentId: options.input.environmentId, label, - httpBaseUrl, - wsBaseUrl: deriveWsBaseUrl(httpBaseUrl), + // The saved form keeps an explicit `:80`, which a bare Tailscale address would read as 3773. + httpBaseUrl: moved ? credential.signInBaseUrl : saved.httpBaseUrl, + wsBaseUrl: moved ? deriveWsBaseUrl(httpBaseUrl) : saved.wsBaseUrl, + ...(moved + ? { authorization: "tailnet" as const, network: "tailscale" as const } + : { + ...(saved.authorization === undefined ? {} : { authorization: saved.authorization }), + ...(saved.network === undefined ? {} : { network: saved.network }), + }), }), - credential: credential.value, + credential, }); }); @@ -304,9 +537,25 @@ export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const ssh = yield* ClientCapabilities.SshEnvironmentGateway; const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; + const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + // Found machines that signed in but could not be saved, skipped until the app restarts. + const unsaveable = new Set(); + const withTailnetServices = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), + Effect.provideService(ClientCapabilities.ClientPresentation, presentation), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), + ); // Provisioning retains the delegated onboarding operation spans. return ConnectionOnboarding.of({ + registerTailnet: Effect.fnUntraced(function* (input) { + return yield* withTailnetServices(registerTailnetConnection(input)); + }), + addDiscoveredTailnetMachines: Effect.fnUntraced(function* (machines) { + return yield* withTailnetServices(addDiscoveredTailnetMachines(machines, unsaveable)); + }), registerPairing: Effect.fnUntraced( function* (input) { return yield* registerPairingConnection(input); @@ -324,9 +573,8 @@ export const make = Effect.gen(function* () { ), updateBearer: Effect.fnUntraced( function* (input) { - return yield* updateBearerConnection(input); + return yield* withTailnetServices(updateBearerConnection(input)); }, - Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), Effect.provideService(ConnectionCredentialStore.ConnectionCredentialStore, credentials), ), }); diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index 2a27148fcd613..7ccec09256847 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -45,6 +45,7 @@ function supervisorState(overrides: Partial): Supervi generation: 0, lastFailure: null, retryAt: null, + routes: [], ...overrides, }; } diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index c579f9587a3df..769dcc86670d8 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -2,8 +2,14 @@ import type { ServerConfig } from "@t3tools/contracts"; import * as Option from "effect/Option"; import type { ConnectionCatalogEntry } from "./catalog.ts"; +import { hostTailscaleNotice } from "./diagnosis.ts"; import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts"; -import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts"; +import { + connectionRouteId, + connectionRouteKind, + connectionRoutes, + routeHttpBaseUrl, +} from "./routes.ts"; export type EnvironmentConnectionPhase = | "available" @@ -94,6 +100,24 @@ export function connectionStatusTitle(connection: EnvironmentConnectionPresentat return connectionStatusText({ ...connection, error: null }); } +/** + * The host's Tailscale problem for its row, from the facts it last reported. + * Only for environments with a Tailscale route, where it can explain a failure. + */ +export function environmentTailscaleNotice(input: { + readonly hostLabel: string; + readonly entry: ConnectionCatalogEntry; + readonly serverConfig: Pick | null; + readonly now: number; +}) { + const tailnet = connectionRoutes(input.entry).some( + (route) => connectionRouteKind(route) === "tailnet", + ); + return tailnet + ? hostTailscaleNotice(input.hostLabel, input.serverConfig?.tailscale, input.now) + : null; +} + /** * SSH connections ride a local forward that disappears with the client, so they have no stable * address. diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index e10d57ab5e854..26108c03ad886 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -35,6 +35,8 @@ import { RelayConnectionRegistration, SshConnectionProfile, SshConnectionRegistration, + TailnetConnectionCredential, + TailnetConnectionRegistration, type ConnectionCredential, type ConnectionProfile, } from "./catalog.ts"; @@ -208,6 +210,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( const targetStore = Persistence.ConnectionTargetStore.of({ list: Ref.get(storedTargets), listDisabled: Ref.get(storedDisabled).pipe(Effect.map((ids) => [...ids])), + listDismissed: Effect.succeed([]), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ register: (registration, routes) => @@ -394,9 +397,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( ...PREPARED, environmentId: target.environmentId, label: target.label, - httpAuthorization: Option.isSome(credential) - ? { _tag: "Bearer", token: credential.value.token } - : null, + httpAuthorization: + Option.isSome(credential) && credential.value._tag === "BearerConnectionCredential" + ? { _tag: "Bearer", token: credential.value.token } + : null, target, }; if (options?.prepareError) return yield* options.prepareError; @@ -430,20 +434,17 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( return { prepared, session }; }); const driver = ConnectionDriver.ConnectionDriver.of({ - connect: (entry, reportProgress) => + connect: (entry, reportProgress, reportRoute) => reportProgress({ stage: "preparing" }).pipe( Effect.andThen( - ConnectionDriver.connectOverRoutes(entry, checkRoute, (route) => - connectRoute(entry, route), + ConnectionDriver.connectOverRoutes( + entry, + checkRoute, + (route) => connectRoute(entry, route), + reportRoute, ), ), ), - preflight: (_entry, route) => - checkRoute(route).pipe( - Effect.map((check): ConnectionDriver.RoutePreflight => - check === "answered" ? "ready" : check, - ), - ), checkHost: () => Effect.succeed("unchecked"), }); @@ -1419,11 +1420,13 @@ describe("EnvironmentRegistry", () => { ? // @effect-diagnostics-next-line effectSucceedWithVoid:off - Admission checks return an optional blocker, so absence must be undefined. Effect.succeed(undefined) : Ref.get(storedCredentials).pipe( - Effect.map((current) => - current.get(target.connectionId)?.token === "old-bearer" + Effect.map((current) => { + const credential = current.get(target.connectionId); + return credential?._tag === "BearerConnectionCredential" && + credential.token === "old-bearer" ? new ConnectionBlockedError({ reason: "authentication", detail: "revoked" }) - : undefined, - ), + : undefined; + }), ), }); storedCredentials = harness.storedCredentials; @@ -2122,6 +2125,156 @@ describe("EnvironmentRegistry routes", () => { }), ); + it.effect("re-pairing a route drops routes learned over its Tailscale sign-in", () => + Effect.gen(function* () { + const learnedId = `learned:${LAN_TARGET.environmentId}:http://100.88.1.2:3773@${LAN_TARGET.connectionId}`; + const learned = new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }); + const harness = yield* makeHarness( + [LAN_TARGET, learned, RELAY_TARGET], + [ + LAN_PROFILE, + new BearerConnectionProfile({ + connectionId: learnedId, + environmentId: LAN_TARGET.environmentId, + label: LAN_TARGET.label, + httpBaseUrl: "http://100.88.1.2:3773/", + wsBaseUrl: "ws://100.88.1.2:3773/", + learned: true, + authorization: "tailnet", + network: "tailscale", + }), + ], + [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]], + ); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.register(lanRegistration()); + + expect(routesOf(yield* Ref.get(harness.storedTargets), LAN_TARGET.environmentId)).toEqual([ + LAN_TARGET, + RELAY_TARGET, + ]); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect( + "registering a route again with the same kind of credential keeps its learned routes", + () => + Effect.gen(function* () { + const learnedId = `learned:${LAN_TARGET.environmentId}:http://100.88.1.2:3773@${LAN_TARGET.connectionId}`; + const learned = new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }); + const harness = yield* makeHarness( + [LAN_TARGET, learned, RELAY_TARGET], + [ + LAN_PROFILE, + new BearerConnectionProfile({ + connectionId: learnedId, + environmentId: LAN_TARGET.environmentId, + label: LAN_TARGET.label, + httpBaseUrl: "http://100.88.1.2:3773/", + wsBaseUrl: "ws://100.88.1.2:3773/", + learned: true, + network: "tailscale", + }), + ], + [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]], + ); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.register(lanRegistration()); + + expect(routesOf(yield* Ref.get(harness.storedTargets), LAN_TARGET.environmentId)).toEqual( + [LAN_TARGET, learned, RELAY_TARGET], + ); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect( + "renaming a route upgraded to Tailscale sign-in keeps its learned Tailscale route", + () => + Effect.gen(function* () { + const learnedId = `learned:${LAN_TARGET.environmentId}:http://100.88.1.2:3773@${LAN_TARGET.connectionId}`; + const learned = new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }); + const session = new TailnetConnectionCredential({ + signInBaseUrl: "http://100.88.1.2:3773/", + accessToken: "tailnet-token", + expiresAtEpochMs: 4_102_444_800_000, + dpopThumbprint: "thumbprint", + }); + const harness = yield* makeHarness( + [LAN_TARGET, learned, RELAY_TARGET], + [ + LAN_PROFILE, + new BearerConnectionProfile({ + connectionId: learnedId, + environmentId: LAN_TARGET.environmentId, + label: LAN_TARGET.label, + httpBaseUrl: "http://100.88.1.2:3773/", + wsBaseUrl: "ws://100.88.1.2:3773/", + learned: true, + authorization: "tailnet", + network: "tailscale", + }), + ], + [[LAN_TARGET.connectionId, session]], + ); + const renamed = new BearerConnectionTarget({ ...LAN_TARGET, label: "Renamed" }); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.register( + new TailnetConnectionRegistration({ + target: renamed, + profile: new BearerConnectionProfile({ ...LAN_PROFILE, label: "Renamed" }), + credential: session, + }), + ); + + expect(routesOf(yield* Ref.get(harness.storedTargets), LAN_TARGET.environmentId)).toEqual( + [renamed, learned, RELAY_TARGET], + ); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("pairing at a saved address under a new id drops routes learned over the old one", () => + Effect.gen(function* () { + const learnedId = `learned:${LAN_TARGET.environmentId}:http://100.88.1.2:3773@${LAN_TARGET.connectionId}`; + const learned = new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }); + const harness = yield* makeHarness( + [LAN_TARGET, learned, RELAY_TARGET], + [ + LAN_PROFILE, + new BearerConnectionProfile({ + connectionId: learnedId, + environmentId: LAN_TARGET.environmentId, + label: LAN_TARGET.label, + httpBaseUrl: "http://100.88.1.2:3773/", + wsBaseUrl: "ws://100.88.1.2:3773/", + learned: true, + }), + ], + [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]], + ); + const renamed = new BearerConnectionTarget({ ...LAN_TARGET, connectionId: "bearer:renamed" }); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.register( + lanRegistration( + renamed, + new BearerConnectionProfile({ ...LAN_PROFILE, connectionId: renamed.connectionId }), + ), + ); + + expect(routesOf(yield* Ref.get(harness.storedTargets), LAN_TARGET.environmentId)).toEqual([ + renamed, + RELAY_TARGET, + ]); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect( "adding a saved SSH host again replaces its route, whatever id it was saved under", () => diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 12f8aa5ec505f..1a77a31433292 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -50,6 +50,7 @@ import { entryWithRoutes, findRouteToSameAddress, hasSavedBearerRoute, + isLearned, mergeLearnedRoutes, routesAfterRemoving, upsertRoute, @@ -98,6 +99,8 @@ export class EnvironmentRegistry extends Context.Service< ReadonlyMap >; readonly networkStatus: SubscriptionRef.SubscriptionRef; + /** Environments the user removed. Discovery does not add them again until the user does. */ + readonly dismissedEnvironmentIds: SubscriptionRef.SubscriptionRef>; readonly start: Effect.Effect; /** * Succeeds with `false` and changes nothing when the environment is @@ -206,6 +209,13 @@ interface EnvironmentServiceScope { readonly scope: Scope.Closeable; } +const authorizationOf = (route: ConnectionRoute) => + Option.match(route.profile, { + onNone: () => undefined, + onSome: (profile) => + profile._tag === "BearerConnectionProfile" ? profile.authorization : undefined, + }); + /** @public */ export const make = Effect.gen(function* () { const registryScope = yield* Scope.Scope; @@ -222,6 +232,17 @@ export const make = Effect.gen(function* () { const ssh = yield* ClientCapabilities.SshEnvironmentGateway; const persistedTargets = yield* storage.list; const disabledEnvironmentIds = new Set(yield* storage.listDisabled); + const dismissedEnvironmentIds = yield* SubscriptionRef.make>( + new Set(yield* storage.listDismissed), + ); + const setDismissed = (environmentId: EnvironmentId, dismissed: boolean) => + SubscriptionRef.update(dismissedEnvironmentIds, (current) => { + if (current.has(environmentId) === dismissed) return current; + const next = new Set(current); + if (dismissed) next.add(environmentId); + else next.delete(environmentId); + return next; + }); const loadRoute = Effect.fn("EnvironmentRegistry.loadRoute")(function* ( target: ConnectionTarget, ) { @@ -579,10 +600,30 @@ export const make = Effect.gen(function* () { if (previous !== undefined) { const existing = connectionRoutes(previous); const sameAddress = findRouteToSameAddress(existing, route); + const id = connectionRouteId(route.target); + const replacedId = + sameAddress !== undefined && connectionRouteId(sameAddress.target) !== id + ? connectionRouteId(sameAddress.target) + : undefined; + // Learned routes borrow a credential. Drop them when it is deleted, or + // when its kind changes (a pairing replacing a Tailscale sign-in); the + // saved credential decides, since an upgraded LAN route keeps a LAN profile. + const registeredAuthorization = + registration._tag === "TailnetConnectionRegistration" + ? "tailnet" + : authorizationOf(route); + const dropsLearned = (candidate: ConnectionRoute) => { + if (!isLearned(candidate)) return false; + const borrowed = credentialConnectionId(connectionRouteId(candidate.target)); + return ( + borrowed === replacedId || + (borrowed === id && authorizationOf(candidate) !== registeredAuthorization) + ); + }; const routes = existing.filter( (existing) => - existing !== sameAddress || - connectionRouteId(existing.target) === connectionRouteId(route.target), + (existing !== sameAddress || connectionRouteId(existing.target) === id) && + !dropsLearned(existing), ); const next = withRoutes(previous, upsertRoute(routes, route)); if (next.addressesChanged) { @@ -591,6 +632,7 @@ export const make = Effect.gen(function* () { entry = next.entry; } yield* registrations.register(registration, persistedRoutes(entry)); + yield* setDismissed(environmentId, false); yield* Ref.update(persistedEnvironmentIds, (current) => new Set(current).add(environmentId), ); @@ -852,6 +894,7 @@ export const make = Effect.gen(function* () { /** Callers hold its lease lock. */ const removeLocked = Effect.fn("EnvironmentRegistry.removeLocked")(function* ( environmentId: EnvironmentId, + dismiss: boolean, ) { if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { return yield* new PlatformEnvironmentRemovalError({ @@ -861,7 +904,8 @@ export const make = Effect.gen(function* () { const entry = yield* getEntry(environmentId); yield* pullRequestRoutingPermissions.forget(environmentId); - yield* registrations.remove(environmentId); + yield* registrations.remove(environmentId, { dismiss }); + yield* setDismissed(environmentId, dismiss); yield* Ref.update(persistedEnvironmentIds, (current) => { const next = new Set(current); next.delete(environmentId); @@ -897,7 +941,7 @@ export const make = Effect.gen(function* () { }); const remove = Effect.fn("EnvironmentRegistry.remove")(function* (environmentId: EnvironmentId) { - return yield* withLeaseLock(environmentId, removeLocked(environmentId)); + return yield* withLeaseLock(environmentId, removeLocked(environmentId, true)); }); const disconnectSsh = (environmentId: EnvironmentId, profile: SshConnectionProfile) => @@ -911,6 +955,20 @@ export const make = Effect.gen(function* () { Effect.ignore, ); + /** The resolver saves a new profile when a paired route switches to Tailscale sign-in. */ + const upgradedRoute = Effect.fn("EnvironmentRegistry.upgradedRoute")(function* ( + route: ConnectionRoute, + ) { + const current = Option.getOrNull(route.profile); + if (current?._tag !== "BearerConnectionProfile" || current.authorization !== undefined) { + return route; + } + const stored = Option.getOrNull(yield* profiles.get(current.connectionId)); + return stored?._tag === "BearerConnectionProfile" && stored.authorization === "tailnet" + ? { target: route.target, profile: Option.some(stored) } + : route; + }); + // Plain HTTP routes are unusable from an HTTPS page, which blocks mixed content. const allowInsecureRoutes = typeof globalThis.location === "undefined" || globalThis.location.protocol !== "https:"; @@ -930,14 +988,27 @@ export const make = Effect.gen(function* () { if ((yield* Ref.get(platformEnvironmentIds)).has(input.environmentId)) { return Option.none(); } - const entry = (yield* SubscriptionRef.get(entries)).get(input.environmentId); - if (entry === undefined) return Option.none(); - const routes = mergeLearnedRoutes({ - entry, - activeRoute: input.activeRoute, - reported: input.reported, - allowInsecure: allowInsecureRoutes, - }); + const saved = (yield* SubscriptionRef.get(entries)).get(input.environmentId); + if (saved === undefined) return Option.none(); + const activeRoute = yield* upgradedRoute(input.activeRoute); + const entry = + activeRoute === input.activeRoute + ? saved + : entryWithRoutes( + saved, + connectionRoutes(saved).map((route) => + connectionRouteId(route.target) === connectionRouteId(activeRoute.target) + ? activeRoute + : route, + ), + ); + const routes = + mergeLearnedRoutes({ + entry, + activeRoute, + reported: input.reported, + allowInsecure: allowInsecureRoutes, + }) ?? (entry === saved ? null : connectionRoutes(entry)); if (routes === null) return Option.none(); const next = entryWithRoutes(entry, routes); const previousProfiles = new Map( @@ -978,9 +1049,11 @@ export const make = Effect.gen(function* () { ); }); - const removeRoute = Effect.fn("EnvironmentRegistry.removeRoute")(function* ( + /** `dismiss` when the user removes the last route, not when T3 Connect signs out. */ + const removeRouteWith = Effect.fn("EnvironmentRegistry.removeRoute")(function* ( environmentId: EnvironmentId, routeId: string, + dismiss: boolean, ) { // One lock for the whole decision: a route registered between "this was // the last route" and the removal must not be deleted with it. @@ -992,7 +1065,7 @@ export const make = Effect.gen(function* () { const route = routes.find((candidate) => connectionRouteId(candidate.target) === routeId); if (route === undefined) return; const remaining = routesAfterRemoving(routes, routeId); - if (remaining.length === 0) return yield* removeLocked(environmentId); + if (remaining.length === 0) return yield* removeLocked(environmentId, dismiss); yield* replaceRoutesLocked(entry, remaining); const profile = Option.getOrNull(route.profile); if (profile !== null && isSshConnectionProfile(profile)) { @@ -1002,6 +1075,9 @@ export const make = Effect.gen(function* () { ); }); + const removeRoute = (environmentId: EnvironmentId, routeId: string) => + removeRouteWith(environmentId, routeId, true); + const reorderRoutes = Effect.fn("EnvironmentRegistry.reorderRoutes")(function* ( environmentId: EnvironmentId, routeIds: ReadonlyArray, @@ -1036,7 +1112,7 @@ export const make = Effect.gen(function* () { yield* Effect.forEach( relayEnvironmentIds, (environmentId) => - removeRoute(environmentId, RELAY_ROUTE_ID).pipe( + removeRouteWith(environmentId, RELAY_ROUTE_ID, false).pipe( Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void }), ), { @@ -1186,6 +1262,7 @@ export const make = Effect.gen(function* () { return EnvironmentRegistry.of({ entries, networkStatus, + dismissedEnvironmentIds, start, register, assertKnownAddress, diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index b70b2ef17615f..035a293457434 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -14,13 +14,17 @@ import * as ClientCapabilities from "../platform/capabilities.ts"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import { BearerConnectionCredential, + BearerConnectionProfile, type ConnectionCatalogEntry, SshConnectionProfile, + TailnetConnectionCredential, type ConnectionCredential, type ConnectionProfile, } from "./catalog.ts"; import * as ConnectionCredentialStore from "./credentialStore.ts"; import { + BearerConnectionTarget, + ConnectionBlockedError, ConnectionTransientError, PrimaryConnectionTarget, SshConnectionTarget, @@ -39,6 +43,34 @@ const ENDPOINT = { httpBaseUrl: "https://environment.example.test", wsBaseUrl: "wss://environment.example.test", }; +const TAILNET_SESSION = new TailnetConnectionCredential({ + signInBaseUrl: "http://100.88.1.2:3773/", + accessToken: "tailnet-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint-1", +}); + +function bearerRoute( + connectionId: string, + httpBaseUrl: string, + extra: Partial> = {}, +) { + const target = new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Studio", + connectionId, + }); + const profile = new BearerConnectionProfile({ + connectionId, + environmentId: ENVIRONMENT_ID, + label: "Studio", + httpBaseUrl, + wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), + ...extra, + }); + return { target, profile, entry: catalogEntry(target, Option.some(profile)) }; +} + const SSH_TARGET: DesktopSshEnvironmentTarget = { alias: "development", hostname: "development.example.test", @@ -59,9 +91,13 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o readonly credentials?: ReadonlyArray; readonly authorizeBearer?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; readonly authorizeDpop?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; + readonly signInTailnet?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["signInTailnet"]; + readonly authorizeTailnet?: RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization["Service"]["authorizeTailnet"]; readonly primaryBearerToken?: string; readonly prepareSsh?: ClientCapabilities.SshEnvironmentGateway["Service"]["prepare"]; readonly descriptorProtocolVersion?: number | null | undefined; + readonly tailnetSignIn?: boolean; + readonly surface?: "web" | "desktop" | "mobile"; }) => { const profiles = new Map( (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]), @@ -109,6 +145,9 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o })), authorizeDpopHttp: () => Effect.die("unused"), renewDpopBeforeExpiry: () => Effect.die("unused"), + signInTailnet: options?.signInTailnet ?? (() => Effect.die("unused")), + authorizeTailnet: options?.authorizeTailnet ?? (() => Effect.die("unused")), + authorizeTailnetHttp: () => Effect.die("unused"), }); const ssh = ClientCapabilities.SshEnvironmentGateway.of({ provision: () => Effect.die("unused"), @@ -140,7 +179,10 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o : options.descriptorProtocolVersion === null ? {} : { orchestrationProtocolVersion: options.descriptorProtocolVersion }), - capabilities: { repositoryIdentity: true }, + capabilities: { + repositoryIdentity: true, + ...(options?.tailnetSignIn === true ? { tailnetSignIn: true } : {}), + }, }), )) satisfies typeof fetch), Layer.succeed( @@ -157,14 +199,19 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o Layer.succeed( ClientCapabilities.ClientPresentation, ClientCapabilities.ClientPresentation.of({ - metadata: { label: "Test Client", deviceType: "desktop", surface: "web" }, + metadata: { + label: "Test Client", + deviceType: "desktop", + surface: options?.surface ?? "web", + }, }), ), Layer.succeed(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, remote), Layer.succeed(ClientCapabilities.SshEnvironmentGateway, ssh), ); - return Effect.succeed(ConnectionResolver.layer.pipe(Layer.provide(layerDependencies))); + // Merged so a test can read back what the resolver stored. + return Effect.succeed(ConnectionResolver.layer.pipe(Layer.provideMerge(layerDependencies))); }); describe("ConnectionResolver", () => { @@ -346,4 +393,213 @@ describe("ConnectionResolver", () => { expect(yield* permissions.get(entry)).toBe(scenario === "changed" ? "off" : "read-write"); }), ); + it.effect("uses a Tailscale session only on the host's Tailscale routes", () => + Effect.gen(function* () { + const authorized = yield* Ref.make>([]); + const layerBroker = yield* makeDependencies({ + credentials: [["pinned", TAILNET_SESSION]], + surface: "mobile", + authorizeTailnet: (input) => + Ref.update(authorized, (current) => [...current, input.endpoint.httpBaseUrl]).pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Studio", + httpBaseUrl: input.endpoint.httpBaseUrl, + socketUrl: "ws://studio/ws", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: TAILNET_SESSION.accessToken, + expiresAtEpochMs: TAILNET_SESSION.expiresAtEpochMs, + tailnetCredentialId: input.credentialId, + }, + }), + ), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(layerBroker)); + const lan = bearerRoute( + `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773@pinned`, + "http://192.168.1.10:3773/", + { learned: true, authorization: "tailnet" }, + ); + const serve = bearerRoute( + `learned:${ENVIRONMENT_ID}:https://studio.tail1234.ts.net@pinned`, + "https://studio.tail1234.ts.net/", + { learned: true, authorization: "tailnet", network: "tailscale" }, + ); + + expect(yield* Effect.flip(broker.prepare(lan.entry))).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "configuration", + }); + const prepared = yield* broker.prepare(serve.entry); + + expect(prepared.httpAuthorization).toMatchObject({ tailnetCredentialId: "pinned" }); + expect(yield* Ref.get(authorized)).toEqual(["https://studio.tail1234.ts.net/"]); + }), + ); + + it.effect("switches a paired tailnet route to Tailscale sign-in when its session ends", () => + Effect.gen(function* () { + const ended = new ConnectionBlockedError({ + reason: "authentication", + detail: "The environment credential is invalid.", + hop: "server", + }); + const paired = bearerRoute("paired", "http://100.88.1.2:3773/"); + const ticketRequests = yield* Ref.make(0); + const profileWriteFailed = new ConnectionTransientError({ + reason: "remote-unavailable", + detail: "Could not save the connection profile.", + }); + const profileWrites = yield* Ref.make(0); + const savedProfiles = new Map(); + const dependencies = (surface: "web" | "mobile") => + makeDependencies({ + credentials: [["paired", new BearerConnectionCredential({ token: "expired" })]], + profileStore: { + get: (connectionId) => + Effect.sync(() => Option.fromNullishOr(savedProfiles.get(connectionId))), + put: (profile) => + Ref.updateAndGet(profileWrites, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.fail(profileWriteFailed) + : Effect.sync(() => void savedProfiles.set(profile.connectionId, profile)), + ), + ), + remove: () => Effect.die("unused"), + }, + surface, + tailnetSignIn: true, + authorizeBearer: () => Effect.fail(ended), + signInTailnet: () => Effect.succeed(TAILNET_SESSION), + authorizeTailnet: (input) => + Ref.updateAndGet(ticketRequests, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.fail( + new ConnectionTransientError({ + reason: "network", + detail: "The ticket request failed.", + }), + ) + : Effect.succeed({ + environmentId: input.expectedEnvironmentId, + label: "Studio", + httpBaseUrl: input.endpoint.httpBaseUrl, + socketUrl: "ws://100.88.1.2:3773/ws", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: TAILNET_SESSION.accessToken, + expiresAtEpochMs: TAILNET_SESSION.expiresAtEpochMs, + tailnetCredentialId: input.credentialId, + }, + }), + ), + ), + }); + + // Browsers cannot sign in through Tailscale, so the old session's error stands. + const web = yield* ConnectionResolver.ConnectionResolver.pipe( + Effect.provide(yield* dependencies("web")), + ); + expect(yield* Effect.flip(web.prepare(paired.entry))).toBe(ended); + + const layerMobile = yield* dependencies("mobile"); + const { prepared, profile, credential } = yield* Effect.gen(function* () { + const broker = yield* ConnectionResolver.ConnectionResolver; + // A failed profile write keeps the old session, so the retry signs in again. + expect(yield* Effect.flip(broker.prepare(paired.entry))).toBe(profileWriteFailed); + // The first connect after sign-in fails, and the retry still holds the old profile. + expect(yield* Effect.flip(broker.prepare(paired.entry))).toMatchObject({ + _tag: "ConnectionTransientError", + }); + const prepared = yield* broker.prepare(paired.entry); + const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; + return { + prepared, + profile: yield* profiles.get("paired"), + credential: yield* credentials.get("paired"), + }; + }).pipe(Effect.provide(layerMobile)); + + expect(prepared.httpAuthorization).toMatchObject({ tailnetCredentialId: "paired" }); + expect(Option.getOrThrow(credential)).toBe(TAILNET_SESSION); + expect(Option.getOrThrow(profile)).toMatchObject({ + httpBaseUrl: "http://100.88.1.2:3773/", + authorization: "tailnet", + network: "tailscale", + }); + }), + ); + + it.effect("switches a Serve pairing to Tailscale sign-in at its direct Tailscale route", () => + Effect.gen(function* () { + const ended = new ConnectionBlockedError({ + reason: "authentication", + detail: "The environment credential is invalid.", + hop: "server", + }); + const signIns = yield* Ref.make>([]); + const authorized = yield* Ref.make>([]); + const serve = bearerRoute("serve", "https://studio.tail1234.ts.net/"); + const direct = bearerRoute( + `learned:${ENVIRONMENT_ID}:http://100.88.1.2:3773@serve`, + "http://100.88.1.2:3773/", + { learned: true, network: "tailscale" }, + ); + const lan = bearerRoute( + `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773@serve`, + "http://192.168.1.10:3773/", + { learned: true }, + ); + const layer = yield* makeDependencies({ + credentials: [["serve", new BearerConnectionCredential({ token: "expired" })]], + surface: "mobile", + tailnetSignIn: true, + authorizeBearer: () => Effect.fail(ended), + signInTailnet: (input) => + Ref.update(signIns, (urls) => [...urls, input.httpBaseUrl]).pipe( + Effect.as(TAILNET_SESSION), + ), + authorizeTailnet: (input) => + Ref.update(authorized, (calls) => [ + ...calls, + [input.credentialId, input.endpoint.httpBaseUrl] as const, + ]).pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Studio", + httpBaseUrl: input.endpoint.httpBaseUrl, + socketUrl: "ws://studio/ws", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: TAILNET_SESSION.accessToken, + expiresAtEpochMs: TAILNET_SESSION.expiresAtEpochMs, + tailnetCredentialId: input.credentialId, + }, + }), + ), + }); + + const credential = yield* Effect.gen(function* () { + const broker = yield* ConnectionResolver.ConnectionResolver; + // Serve and LAN routes hide who the device is, so neither signs in. + expect(yield* Effect.flip(broker.prepare(serve.entry))).toBe(ended); + expect(yield* Effect.flip(broker.prepare(lan.entry))).toBe(ended); + yield* broker.prepare(direct.entry); + yield* broker.prepare(serve.entry); + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; + return yield* credentials.get("serve"); + }).pipe(Effect.provide(layer)); + + expect(yield* Ref.get(signIns)).toEqual(["http://100.88.1.2:3773/"]); + expect(Option.getOrThrow(credential)).toBe(TAILNET_SESSION); + expect(yield* Ref.get(authorized)).toEqual([ + ["serve", "http://100.88.1.2:3773/"], + ["serve", "https://studio.tail1234.ts.net/"], + ]); + }), + ); }); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index d3b22143d327a..fb5c633bfa328 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -22,6 +22,7 @@ import { BearerConnectionProfile, type ConnectionCatalogEntry, SshConnectionProfile, + TailnetConnectionCredential, } from "./catalog.ts"; import * as ConnectionCredentialStore from "./credentialStore.ts"; import { @@ -48,7 +49,8 @@ import { appendOrchestrationProtocol, orchestrationProtocolCompatibilityError, } from "./compatibility.ts"; -import { credentialConnectionId } from "./routes.ts"; +import { connectionRouteKind, credentialConnectionId } from "./routes.ts"; +import { supportsTailnetSignIn } from "./tailnet.ts"; import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; export class ConnectionResolver extends Context.Service< @@ -74,6 +76,7 @@ export class ConnectionResolver extends Context.Service< const isBearerProfile = Schema.is(BearerConnectionProfile); const isSshProfile = Schema.is(SshConnectionProfile); const isBearerCredential = Schema.is(BearerConnectionCredential); +const isTailnetCredential = Schema.is(TailnetConnectionCredential); function primarySocketUrl( target: PrimaryConnectionTarget, @@ -124,7 +127,82 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () { const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; + const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; + const presentation = yield* ClientCapabilities.ClientPresentation; + const httpClient = yield* HttpClient.HttpClient; + + // An upgrade saves its profile before it connects, so a connect that fails + // after it leaves the caller holding the old profile. + const savedTailnetProfile = Effect.fnUntraced(function* (profile: BearerConnectionProfile) { + if (profile.authorization !== undefined) return profile; + const stored = Option.getOrNull(yield* profiles.get(profile.connectionId)); + return stored !== null && + isBearerProfile(stored) && + stored.authorization === "tailnet" && + stored.httpBaseUrl === profile.httpBaseUrl + ? stored + : profile; + }); + + /** + * A paired tailnet route whose session ended switches to Tailscale sign-in. + * A Serve pairing switches at its host-reported direct route, where renewal + * stays pinned. The registry picks up the profile after the next connect. + */ + const upgradeToTailnet = Effect.fn("clientRuntime.connection.broker.upgradeToTailnet")(function* ( + target: BearerConnectionTarget, + profile: BearerConnectionProfile, + ended: ConnectionAttemptError, + ) { + // A bare 100.x route reads as "vpn" until the host reports it as Tailscale. + const kind = connectionRouteKind({ target, profile: Option.some(profile) }); + const tailnetRoute = + profile.learned === true + ? profile.network === "tailscale" + : kind === "tailnet" || kind === "vpn"; + const canUpgrade = + ended._tag === "ConnectionBlockedError" && + ended.reason === "authentication" && + tailnetRoute && + profile.httpBaseUrl.startsWith("http:") && + supportsTailnetSignIn(presentation.metadata); + if (!canUpgrade) return yield* ended; + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: profile.httpBaseUrl, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + if ( + descriptor.environmentId !== target.environmentId || + descriptor.capabilities.tailnetSignIn !== true + ) { + return yield* ended; + } + const credentialId = credentialConnectionId(target.connectionId); + const session = yield* remote.signInTailnet({ httpBaseUrl: profile.httpBaseUrl }); + // Profile first: a Tailscale session under the old profile is refused on + // every connect, while the old token under the new one signs in again. + yield* profiles.put( + new BearerConnectionProfile({ + connectionId: profile.connectionId, + environmentId: profile.environmentId, + label: profile.label, + httpBaseUrl: profile.httpBaseUrl, + wsBaseUrl: profile.wsBaseUrl, + ...(profile.learned === undefined ? {} : { learned: profile.learned }), + authorization: "tailnet", + network: "tailscale", + }), + ); + yield* credentials.put(credentialId, session); + return yield* remote.authorizeTailnet({ + expectedEnvironmentId: target.environmentId, + credentialId, + endpoint: { httpBaseUrl: profile.httpBaseUrl, wsBaseUrl: profile.wsBaseUrl }, + }); + }); return Effect.fn("clientRuntime.connection.broker.bearer")(function* ( entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget }, @@ -152,7 +230,8 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") }); return { ...authorized, target } satisfies PreparedConnection; } - const credential = yield* credentials.get(credentialConnectionId(target.connectionId)).pipe( + const credentialId = credentialConnectionId(target.connectionId); + const credential = yield* credentials.get(credentialId).pipe( Effect.flatMap( Option.match({ onNone: () => Effect.fail(credentialMissingError(target.connectionId)), @@ -160,16 +239,35 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") }), ), ); + if (isTailnetCredential(credential)) { + const tailnetProfile = yield* savedTailnetProfile(profile); + // A Tailscale session goes only to the host's tailnet and Serve routes. + if (connectionRouteKind({ target, profile: Option.some(tailnetProfile) }) !== "tailnet") { + return yield* new ConnectionBlockedError({ + reason: "configuration", + detail: "A Tailscale sign-in is only used on Tailscale addresses.", + hop: "device", + }); + } + const authorized = yield* remote.authorizeTailnet({ + expectedEnvironmentId: target.environmentId, + credentialId, + endpoint: { httpBaseUrl: tailnetProfile.httpBaseUrl, wsBaseUrl: tailnetProfile.wsBaseUrl }, + }); + return { ...authorized, target } satisfies PreparedConnection; + } if (!isBearerCredential(credential)) { return yield* credentialMissingError(target.connectionId); } - const authorized = yield* remote.authorizeBearer({ - expectedEnvironmentId: target.environmentId, - httpBaseUrl: profile.httpBaseUrl, - wsBaseUrl: profile.wsBaseUrl, - bearerToken: credential.token, - connectionMethod: "direct", - }); + const authorized = yield* remote + .authorizeBearer({ + expectedEnvironmentId: target.environmentId, + httpBaseUrl: profile.httpBaseUrl, + wsBaseUrl: profile.wsBaseUrl, + bearerToken: credential.token, + connectionMethod: "direct", + }) + .pipe(Effect.catch((ended) => upgradeToTailnet(target, profile, ended))); return { ...authorized, target, diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts index da9c631638cc4..500d869a6bcac 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -16,6 +16,7 @@ import { credentialConnectionId, insertRoute, entryWithRoutes, + findRouteToSameAddress, mergeLearnedRoutes, pendingRouteOrder, removedWithRelay, @@ -77,6 +78,13 @@ describe("connection routes", () => { expect(connectionRouteLabel(RELAY)).toBe("T3 Connect"); }); + it("finds an address with or without its default port as the same", () => { + const saved = direct("paired", "http://100.88.1.2/"); + expect(findRouteToSameAddress([saved], direct("signed-in", "http://100.88.1.2:80/"))).toBe( + saved, + ); + }); + it("places a new route after faster kinds and ahead of T3 Connect", () => { expect(insertRoute([RELAY], LAN)).toEqual([LAN, RELAY]); expect(insertRoute([LAN, RELAY], TAILNET)).toEqual([LAN, TAILNET, RELAY]); @@ -119,6 +127,17 @@ describe("learned routes", () => { routes?.map((route) => connectionRouteId(route.target)) ?? null; const profileOf = (route: ConnectionRoute) => Option.getOrThrow(route.profile); + it("knows a saved address reported with or without its default port", () => { + const saved = direct("tailnet", "http://100.88.1.2:80/"); + const routes = mergeLearnedRoutes({ + entry: entryWithRoutes(relayOnly, [saved, RELAY]), + activeRoute: RELAY, + reported: [{ httpBaseUrl: "http://100.88.1.2/" }], + allowInsecure: true, + }); + expect(ids(routes)?.some((id) => id.startsWith("learned:")) ?? false).toBe(false); + }); + it("learns a LAN address over T3 Connect, ahead of it, using the T3 Connect credential", () => { const routes = mergeLearnedRoutes({ entry: relayOnly, @@ -252,6 +271,50 @@ describe("learned routes", () => { expect(credentialConnectionId(connectionRouteId(learned.target))).toBe("lan"); }); + it("keeps a Tailscale sign-in on the host's tailnet and Serve routes, never its LAN", () => { + const pinned: ConnectionRoute = { + target: new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Desk", + connectionId: "pinned", + }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId: "pinned", + environmentId: ENVIRONMENT_ID, + label: "Desk", + httpBaseUrl: "http://100.88.1.2:3773/", + wsBaseUrl: "ws://100.88.1.2:3773/", + authorization: "tailnet", + network: "tailscale", + }), + ), + }; + // A LAN route learned before the upgrade borrowed the same credential. + const staleLan = learned( + `learned:${ENVIRONMENT_ID}:http://192.168.1.10:3773@pinned`, + "http://192.168.1.10:3773/", + ); + const routes = mergeLearnedRoutes({ + entry: entryWithRoutes(relayOnly, [staleLan, pinned]), + activeRoute: pinned, + reported: [ + { httpBaseUrl: "http://192.168.1.10:3773/", kind: "lan" }, + { httpBaseUrl: "http://100.88.1.2:3773/", kind: "tailnet" }, + { httpBaseUrl: "https://desk.tail1234.ts.net/", kind: "tailnet" }, + ], + allowInsecure: true, + })!; + expect(ids(routes)).toEqual([ + "pinned", + `learned:${ENVIRONMENT_ID}:https://desk.tail1234.ts.net@pinned`, + ]); + expect(profileOf(routes[1]!)).toMatchObject({ + authorization: "tailnet", + network: "tailscale", + }); + }); + it("keeps a token paired over HTTPS off plain HTTP addresses outside the tailnet", () => { const entry = entryWithRoutes(relayOnly, [PUBLIC, RELAY]); const routes = mergeLearnedRoutes({ diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index 9d83550a8c4a2..33668ad3aa7d9 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -148,11 +148,21 @@ export function findRouteToSameAddress( return key === null ? undefined : routes.find((existing) => routeAddressKey(existing) === key); } +// URL drops a default port, so `http://host:80/` and `http://host/` key the same. +function httpBaseUrlKey(httpBaseUrl: string): string { + try { + const url = new URL(httpBaseUrl); + return `${url.protocol}//${url.host}${url.pathname}`.replace(/\/+$/, ""); + } catch { + return httpBaseUrl.replace(/\/+$/, ""); + } +} + function routeAddressKey(route: ConnectionRoute): string | null { const profile = Option.getOrNull(route.profile); switch (profile?._tag) { case "BearerConnectionProfile": - return `bearer:${profile.httpBaseUrl.replace(/\/+$/, "")}`; + return `bearer:${httpBaseUrlKey(profile.httpBaseUrl)}`; case "SshConnectionProfile": return `ssh:${sshTargetKey(profile.target)}`; default: @@ -218,15 +228,17 @@ export function mergeLearnedRoutes(input: { } const activeProfile = Option.getOrNull(input.activeRoute.profile); const authorization = - active._tag === "RelayConnectionTarget" || - (activeProfile?._tag === "BearerConnectionProfile" && - activeProfile.authorization === "t3-connect") + active._tag === "RelayConnectionTarget" ? ("t3-connect" as const) - : undefined; + : activeProfile?._tag === "BearerConnectionProfile" + ? activeProfile.authorization + : undefined; const sharedCredential = - authorization === undefined && active._tag === "BearerConnectionTarget" + authorization !== "t3-connect" && active._tag === "BearerConnectionTarget" ? credentialConnectionId(active.connectionId) : undefined; + // A Tailscale session is used only on the host's tailnet and Serve routes. + const tailnetOnly = authorization === "tailnet"; const reported = new Map(); const tailscaleOrigins = new Set(); for (const endpoint of input.reported) { @@ -243,7 +255,7 @@ export function mergeLearnedRoutes(input: { reported.set(url.origin, url); if (endpoint.kind === "tailnet") tailscaleOrigins.add(url.origin); } - const normalized = (url: string) => url.replace(/\/+$/, ""); + const normalized = httpBaseUrlKey; const known = new Set( saved.flatMap((route) => { const url = routeHttpBaseUrl(route); @@ -261,12 +273,20 @@ export function mergeLearnedRoutes(input: { } if (url === null || !reported.has(normalized(url)) || known.has(normalized(url))) return []; if (sendsTokenInClear(saved, route, tailscaleOrigins.has(normalized(url)))) return []; + if ( + tailnetOnly && + !tailscaleOrigins.has(normalized(url)) && + credentialConnectionId(connectionRouteId(route.target)) === sharedCredential + ) { + return []; + } known.add(normalized(url)); return [withNetwork(route, tailscaleOrigins.has(normalized(url)))]; }); let next: ReadonlyArray = kept; for (const url of reported.values()) { if (known.has(url.origin)) continue; + if (tailnetOnly && !tailscaleOrigins.has(url.origin)) continue; if ( url.protocol === "http:" && sharedCredential !== undefined && @@ -356,6 +376,29 @@ export function usableRoutes(entry: ConnectionCatalogEntry): ReadonlyArray !sendsTokenInClear(routes, route)); } +/** + * Whether a route shares a Tailscale sign-in it cannot use. The resolver + * refuses the session off the host's tailnet and Serve routes, so a connect + * skips them and a route list shows them as unused, not failed. + */ +export function unusedWithTailnetSignIn( + entry: ConnectionCatalogEntry, + route: ConnectionRoute, +): boolean { + if (route.target._tag !== "BearerConnectionTarget" || connectionRouteKind(route) === "tailnet") { + return false; + } + const credentialId = credentialConnectionId(route.target.connectionId); + return connectionRoutes(entry).some((candidate) => { + const profile = Option.getOrNull(candidate.profile); + return ( + profile?._tag === "BearerConnectionProfile" && + profile.authorization === "tailnet" && + credentialConnectionId(connectionRouteId(candidate.target)) === credentialId + ); + }); +} + export interface ReportedEndpoint { readonly httpBaseUrl: string; readonly kind?: string; diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 66d151043b115..3df5def2e1c84 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -2,10 +2,12 @@ import { AuthStandardClientScopes, EnvironmentId, type ServerConfig } from "@t3t import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import * as TestClock from "effect/testing/TestClock"; @@ -19,7 +21,10 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { fetchEnvironmentSessionState } from "../state/session.ts"; import type { ConnectionCatalogEntry, ConnectionRoute } from "./catalog.ts"; import * as Connectivity from "./connectivity.ts"; +import * as ConnectionCredentialStore from "./credentialStore.ts"; +import { offTailnetHint } from "./diagnosis.ts"; import * as ConnectionDriver from "./driver.ts"; +import * as ConnectionResolver from "./resolver.ts"; import { BearerConnectionProfile } from "./catalog.ts"; import { BearerConnectionTarget, @@ -148,7 +153,6 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: readonly initialConfig?: (attempt: number) => Effect.Effect; readonly checkRoute?: (route: ConnectionRoute) => Effect.Effect; readonly checkHost?: (prepared: PreparedConnection) => Effect.Effect; - readonly preflight?: (route: ConnectionRoute) => Effect.Effect; readonly inboundFrames?: Effect.Effect; }) { const networkStatus = yield* SubscriptionRef.make( @@ -218,10 +222,14 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: const connect = Effect.fn("TestConnectionDriver.connect")(function* ( entry: ConnectionCatalogEntry, reportProgress: (progress: ConnectionDriver.ConnectionDriverProgress) => Effect.Effect, + reportRoute: ConnectionDriver.ReportRouteOutcome, ) { yield* reportProgress({ stage: "preparing" }); - return yield* ConnectionDriver.connectOverRoutes(entry, checkRoute, (route) => - connectRoute(route.target, reportProgress), + return yield* ConnectionDriver.connectOverRoutes( + entry, + checkRoute, + (route) => connectRoute(route.target, reportProgress), + reportRoute, ); }); @@ -244,13 +252,6 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: ConnectionDriver.ConnectionDriver, ConnectionDriver.ConnectionDriver.of({ connect, - preflight: (_entry, route) => - options?.preflight?.(route) ?? - checkRoute(route).pipe( - Effect.map((check): ConnectionDriver.RoutePreflight => - check === "answered" ? "ready" : check, - ), - ), checkHost: (prepared) => options?.checkHost?.(prepared) ?? Effect.succeed("unchecked"), @@ -1235,6 +1236,7 @@ describe("EnvironmentSupervisor", () => { Layer.succeed(ClientCapabilities.ClientPresentation, { metadata: { label: "Test client", deviceType: "desktop" }, }), + Layer.mock(ConnectionCredentialStore.ConnectionCredentialStore)({}), ), ), ); @@ -1311,6 +1313,116 @@ describe("EnvironmentSupervisor", () => { }); describe("EnvironmentSupervisor routes", () => { + it.effect.each(["opening", "synchronizing"] as const)( + "keeps a healthy tailnet session when LAN answers but fails %s", + (failureStage) => + Effect.gen(function* () { + const tailnetTarget = new BearerConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, + connectionId: "bearer:tailnet", + }); + const entry = entryWithRoutes(LAN_THEN_RELAY_ENTRY, [ + LAN_ROUTE, + { + target: tailnetTarget, + profile: Option.some( + new BearerConnectionProfile({ + connectionId: tailnetTarget.connectionId, + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: "http://100.64.0.1:3773/", + wsBaseUrl: "ws://100.64.0.1:3773/", + network: "tailscale", + }), + ), + }, + ]); + const harness = yield* makeHarness(); + const opened = yield* Ref.make>([]); + const released = yield* Ref.make>([]); + const driver = yield* ConnectionDriver.make.pipe( + Effect.provideService(ConnectionResolver.ConnectionResolver, { + prepare: (route) => Effect.succeed(preparedFor(route.target)), + prepareForUpdate: () => Effect.die("unused"), + }), + Effect.provideService(RpcSession.RpcSessionFactory, { + connect: (prepared) => + Effect.gen(function* () { + const id = connectionRouteId(prepared.target); + yield* Ref.update(opened, (ids) => [...ids, id]); + const fails = id === LAN_TARGET.connectionId; + if (fails && failureStage === "opening") return yield* transient(); + return yield* Effect.acquireRelease( + Effect.succeed({ + client: TEST_RPC_CLIENT, + initialConfig: Effect.die("unused"), + subscribeServerConfig: () => Stream.die("unused"), + ready: fails ? Effect.fail(transient()) : Effect.void, + probe: Effect.void, + inboundFrames: Effect.succeed(0), + closed: Effect.never, + } satisfies RpcSession.RpcSession), + () => Ref.update(released, (ids) => [...ids, id]), + ); + }), + }), + Effect.provide( + Layer.mergeAll( + // Bearer routes only, so no DPoP renewal runs. + Layer.mock(RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization)({}), + // Every route answers the descriptor check, LAN included. + RpcHttp.layerRemoteHttpClient((() => + Promise.resolve( + Response.json({ + environmentId: TARGET.environmentId, + label: TARGET.label, + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: {}, + }), + )) satisfies typeof fetch), + ), + ), + ); + const scope = yield* Scope.fork(yield* Scope.Scope); + const supervisor = yield* EnvironmentSupervisor.make(entry, { + initiallyDesired: true, + }).pipe( + Effect.provideService(ConnectionDriver.ConnectionDriver, driver), + Effect.provide(harness.dependencies), + Scope.provide(scope), + ); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + const original = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session)); + const baseline = yield* Ref.get(opened); + const baselineReleased = yield* Ref.get(released); + expect(baseline).toEqual([LAN_TARGET.connectionId, tailnetTarget.connectionId]); + + yield* harness.wake("network-changed"); + yield* harness.setNetworkStatus("unknown"); + yield* harness.setNetworkStatus("online"); + yield* supervisor.retryNow; + yield* TestClock.adjust("12 minutes"); + expect(yield* Ref.get(opened)).toEqual(baseline); + expect(yield* Ref.get(released)).toEqual(baselineReleased); + expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.session))).toBe(original); + expect(yield* SubscriptionRef.get(supervisor.state)).toMatchObject({ + phase: "connected", + generation: 1, + }); + + yield* Scope.close(scope, Exit.void); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + expect( + (yield* Ref.get(released)).filter((id) => id === tailnetTarget.connectionId), + ).toHaveLength(1); + expect( + (yield* Ref.get(released)).filter((id) => id === LAN_TARGET.connectionId), + ).toHaveLength(failureStage === "opening" ? 0 : 1); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("connects over relay before a slow LAN reachability check times out", () => Effect.gen(function* () { const checkingLan = yield* Deferred.make(); @@ -1335,6 +1447,18 @@ describe("EnvironmentSupervisor routes", () => { "RelayConnectionTarget", ); expect(yield* Ref.get(harness.prepareCount)).toBe(1); + + // The check outlives the connection it lost to, so the diagnosis sees the LAN outcome. + yield* TestClock.adjust("2 seconds"); + expect( + (yield* SubscriptionRef.get(supervisor.state)).routes.map((route) => [ + route.routeId, + route.result, + ]), + ).toEqual([ + ["relay", "answered"], + [LAN_TARGET.connectionId, "silent"], + ]); }).pipe(Effect.provide(TestClock.layer())), ); @@ -1521,10 +1645,14 @@ describe("EnvironmentSupervisor routes", () => { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); - yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + const connected = yield* awaitState(supervisor.state, (state) => state.phase === "connected"); const prepared = Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)); expect(prepared.target._tag).toBe("RelayConnectionTarget"); expect(yield* Ref.get(harness.prepareCount)).toBe(1); + expect(connected.routes.map((route) => [route.routeId, route.kind, route.result])).toEqual([ + [LAN_TARGET.connectionId, "lan", "silent"], + ["relay", "relay", "answered"], + ]); }), ); @@ -1563,72 +1691,117 @@ describe("EnvironmentSupervisor routes", () => { }), ); - it.effect("keeps retrying when one route is blocked and another failed transiently", () => + it.effect.each([ + { + name: "keeps retrying when one route is blocked and another failed transiently", + lan: transient("LAN socket refused."), + relay: blocked("Sign in to T3 Connect."), + phase: "backoff", + reported: "lan", + }, + { + name: "reports the host's refusal over a refusal on this device", + lan: new ConnectionBlockedError({ + reason: "configuration", + detail: "A Tailscale sign-in is only used on Tailscale addresses.", + hop: "device", + }), + relay: new ConnectionBlockedError({ + reason: "permission", + detail: "The environment refused this device.", + hop: "server", + }), + phase: "blocked", + reported: "relay", + }, + { + name: "reports the host's refusal over a refusal with no hop", + lan: blocked(), + relay: new ConnectionBlockedError({ + reason: "permission", + detail: "The environment refused this device.", + hop: "server", + }), + phase: "blocked", + reported: "relay", + }, + ] as const)("$name", ({ lan, relay, phase, reported }) => Effect.gen(function* () { const harness = yield* makeHarness({ checkRoute: () => Effect.succeed("answered"), prepare: (_attempt, target) => - target._tag === "RelayConnectionTarget" - ? Effect.fail(blocked("Sign in to T3 Connect.")) - : Effect.fail(transient("LAN socket refused.")), + Effect.fail(target._tag === "RelayConnectionTarget" ? relay : lan), }); const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); - const state = yield* awaitState(supervisor.state, (value) => value.phase === "backoff"); - expect(state.lastFailure?._tag).toBe("ConnectionTransientError"); + const state = yield* awaitState(supervisor.state, (value) => value.phase === phase); + expect(state.lastFailure).toBe(reported === "lan" ? lan : relay); }), ); - it.effect("moves back to the LAN route when the network changes and it answers again", () => + it.effect("skips routes that cannot use the environment's Tailscale sign-in", () => Effect.gen(function* () { - const lanReachable = yield* Ref.make(false); + const tailnetTarget = new BearerConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, + connectionId: `learned:${TARGET.environmentId}:http://100.88.1.2:3773@${LAN_TARGET.connectionId}`, + }); + const entry = entryWithRoutes(LAN_ENTRY, [ + LAN_ROUTE, + { + target: tailnetTarget, + profile: Option.some( + new BearerConnectionProfile({ + connectionId: tailnetTarget.connectionId, + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: "http://100.88.1.2:3773/", + wsBaseUrl: "ws://100.88.1.2:3773/", + learned: true, + authorization: "tailnet", + network: "tailscale", + }), + ), + }, + ]); + const prepared = yield* Ref.make>([]); const harness = yield* makeHarness({ - checkRoute: (route) => - route.target._tag === "BearerConnectionTarget" - ? Ref.get(lanReachable).pipe( - Effect.map((reachable) => (reachable ? "answered" : "silent")), - ) - : Effect.succeed("unchecked"), - prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), + checkRoute: (route) => Effect.succeed(route.target === LAN_TARGET ? "answered" : "silent"), + prepare: (_attempt, target) => + Ref.update(prepared, (ids) => [...ids, connectionRouteId(target)]).pipe( + Effect.andThen( + Effect.fail( + target === LAN_TARGET + ? new ConnectionBlockedError({ + reason: "configuration", + detail: "A Tailscale sign-in is only used on Tailscale addresses.", + hop: "device", + }) + : transient("Tailscale did not answer."), + ), + ), + ), }); - const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, { + const supervisor = yield* EnvironmentSupervisor.make(entry, { initiallyDesired: true, }).pipe(Effect.provide(harness.dependencies)); - yield* awaitState( - supervisor.state, - (state) => state.phase === "connected" && state.generation === 1, - ); - expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe( - "RelayConnectionTarget", - ); - - yield* Ref.set(lanReachable, true); - yield* harness.setNetworkStatus("unknown"); - yield* harness.setNetworkStatus("online"); - - yield* awaitState( - supervisor.state, - (state) => state.phase === "connected" && state.generation === 2, - ); - expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe( - "BearerConnectionTarget", - ); - expect(yield* Ref.get(harness.releaseCount)).toBe(1); + const state = yield* awaitState(supervisor.state, (value) => value.phase === "backoff"); + expect(yield* Ref.get(prepared)).toEqual([tailnetTarget.connectionId]); + // The LAN answering while Tailscale does not points at this device's Tailscale. + expect(offTailnetHint(state)).toBe("Check that Tailscale is on for this device."); }), ); - it.effect("checks a dead LAN route less often until the network changes", () => + it.effect("uses a newly reachable LAN route on the next necessary connection", () => Effect.gen(function* () { const lanReachable = yield* Ref.make(false); - const lanChecks = yield* Ref.make(0); const harness = yield* makeHarness({ checkRoute: (route) => route.target._tag === "BearerConnectionTarget" - ? Ref.update(lanChecks, (count) => count + 1).pipe( - Effect.andThen(Ref.get(lanReachable)), + ? Ref.get(lanReachable).pipe( Effect.map((reachable) => (reachable ? "answered" : "silent")), ) : Effect.succeed("unchecked"), @@ -1642,100 +1815,15 @@ describe("EnvironmentSupervisor routes", () => { supervisor.state, (state) => state.phase === "connected" && state.generation === 1, ); - yield* Ref.set(lanChecks, 0); - - // Checked at 1, 3 and 7 minutes instead of every minute. - for (let minute = 0; minute < 10; minute += 1) { - yield* TestClock.adjust("60 seconds"); - } - expect(yield* Ref.get(lanChecks)).toBe(3); - - yield* Ref.set(lanReachable, true); - yield* harness.wake("network-changed"); - yield* awaitState( - supervisor.state, - (state) => state.phase === "connected" && state.generation === 2, - ); expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe( - "BearerConnectionTarget", - ); - }), - ); - - it.effect("keeps checking a LAN route every minute when it answers but cannot be prepared", () => - Effect.gen(function* () { - const lanPreflights = yield* Ref.make(0); - const harness = yield* makeHarness({ - checkRoute: (route) => - Effect.succeed(route.target._tag === "BearerConnectionTarget" ? "silent" : "unchecked"), - preflight: (route) => - route.target._tag === "BearerConnectionTarget" - ? Ref.update(lanPreflights, (count) => count + 1).pipe(Effect.as("unprepared" as const)) - : Effect.succeed("unchecked"), - prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), - }); - const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, { - initiallyDesired: true, - }).pipe(Effect.provide(harness.dependencies)); - - yield* awaitState( - supervisor.state, - (state) => state.phase === "connected" && state.generation === 1, + "RelayConnectionTarget", ); - for (let minute = 0; minute < 10; minute += 1) { - yield* TestClock.adjust("60 seconds"); - } - expect(yield* Ref.get(lanPreflights)).toBe(10); - }), - ); - it.effect("ignores a dead LAN check that was running when the network changed", () => - Effect.gen(function* () { - const lanChecks = yield* Ref.make(0); - const checkStarted = yield* Deferred.make(); - const finishCheck = yield* Deferred.make(); - const woke = yield* Ref.make(false); - const wakeupHandled = yield* Deferred.make(); - const harness = yield* makeHarness({ - checkRoute: (route) => - route.target._tag === "BearerConnectionTarget" - ? Ref.updateAndGet(lanChecks, (count) => count + 1).pipe( - Effect.flatMap((check) => - check === 1 - ? Effect.succeed("silent" as const) - : check === 2 - ? Deferred.succeed(checkStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishCheck)), - Effect.as("silent" as const), - ) - : Effect.succeed("answered" as const), - ), - ) - : Effect.succeed("unchecked"), - prepare: (_attempt, target) => Effect.succeed(preparedFor(target)), - // The wakeup probes the session only after it forgets the silent routes. - probe: () => - Ref.get(woke).pipe( - Effect.flatMap((afterWake) => - afterWake ? Deferred.succeed(wakeupHandled, undefined) : Effect.void, - ), - Effect.asVoid, - ), - }); - const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, { - initiallyDesired: true, - }).pipe(Effect.provide(harness.dependencies)); - - yield* awaitState( - supervisor.state, - (state) => state.phase === "connected" && state.generation === 1, - ); - yield* TestClock.adjust("60 seconds"); - yield* Deferred.await(checkStarted); - yield* Ref.set(woke, true); - yield* harness.wake("network-changed"); - yield* Deferred.await(wakeupHandled); - yield* Deferred.succeed(finishCheck, undefined); + yield* Ref.set(lanReachable, true); + yield* harness.setNetworkStatus("unknown"); + yield* harness.setNetworkStatus("online"); + yield* harness.closeLatestSession(); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, @@ -1744,7 +1832,8 @@ describe("EnvironmentSupervisor routes", () => { expect(Option.getOrThrow(yield* SubscriptionRef.get(supervisor.prepared)).target._tag).toBe( "BearerConnectionTarget", ); - }), + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }).pipe(Effect.provide(TestClock.layer())), ); it.effect("moves off a dead LAN socket when the phone switches to cellular", () => @@ -1789,7 +1878,7 @@ describe("EnvironmentSupervisor routes", () => { }), ); - it.effect("learns the LAN address over T3 Connect and moves to it", () => + it.effect("uses learned LAN routes on the next necessary connection", () => Effect.gen(function* () { const relayEntry: ConnectionCatalogEntry = { target: RELAY_TARGET, @@ -1813,6 +1902,7 @@ describe("EnvironmentSupervisor routes", () => { ), }); const current = yield* Ref.make(relayEntry); + const routesLearned = yield* Deferred.make(); const supervisor = yield* EnvironmentSupervisor.make(relayEntry, { initiallyDesired: true, learnRoutes: ({ activeRoute, reported }) => @@ -1831,10 +1921,17 @@ describe("EnvironmentSupervisor routes", () => { ...all, ...routes.filter(isLearned).map((route) => connectionRouteId(route.target)), ]); + yield* Deferred.succeed(routesLearned, undefined); return Option.some(next); }), }).pipe(Effect.provide(harness.dependencies)); + yield* Deferred.await(routesLearned); + yield* TestClock.adjust("12 minutes"); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + yield* harness.closeLatestSession(); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -1845,6 +1942,41 @@ describe("EnvironmentSupervisor routes", () => { expect(yield* Ref.get(learned)).toEqual([ `learned:${TARGET.environmentId}:http://192.168.1.10:3773`, ]); - }), + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("drops the outcome of a route that learned routes replaced", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + checkRoute: (route) => + Effect.succeed(route.target._tag === "BearerConnectionTarget" ? "answered" : "unchecked"), + prepare: (_attempt, target) => + target._tag === "BearerConnectionTarget" + ? Effect.fail(transient("LAN socket refused.")) + : Effect.succeed(preparedFor(target)), + initialConfig: () => + Effect.succeed({ directEndpoints: [] } as Pick< + ServerConfig, + "directEndpoints" + > as ServerConfig), + }); + const routesLearned = yield* Deferred.make(); + const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, { + initiallyDesired: true, + learnRoutes: () => + Deferred.succeed(routesLearned, undefined).pipe(Effect.as(Option.some(RELAY_ENTRY))), + }).pipe(Effect.provide(harness.dependencies)); + + const connected = yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + expect(connected.routes.map((route) => route.routeId)).toEqual([ + LAN_TARGET.connectionId, + "relay", + ]); + yield* Deferred.await(routesLearned); + yield* TestClock.adjust("1 second"); + expect( + (yield* SubscriptionRef.get(supervisor.state)).routes.map((route) => route.routeId), + ).toEqual(["relay"]); + }).pipe(Effect.provide(TestClock.layer())), ); }); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 212381921961b..dc888fb1c823d 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -11,6 +11,7 @@ import * as Queue from "effect/Queue"; import * as Random from "effect/Random"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Tracer from "effect/Tracer"; @@ -20,6 +21,7 @@ import * as Connectivity from "./connectivity.ts"; import * as ConnectionDriver from "./driver.ts"; import { type ConnectionAttemptError, + type ConnectionRouteOutcome, type ConnectionTarget, ConnectionTransientError, type NetworkStatus, @@ -35,7 +37,6 @@ import { connectionRouteKind, connectionRoutes, type ConnectionRouteKind, - entryWithRoutes, type ReportedEndpoint, } from "./routes.ts"; @@ -58,14 +59,6 @@ const SILENT_SOCKET_RECHECK_EVERY = "1 second"; // instead of timing out. const REMOTE_SILENT_SOCKET_AFTER = "2500 millis"; const BACKOFF_RESET_AFTER_MS = 30_000; -const BETTER_ROUTE_CHECK_INTERVAL_MS = 60_000; -// Each check of a dead route can wait out the full probe timeout, so a route -// that keeps failing is checked half as often each time, until the network -// changes and it may answer again. -const SILENT_ROUTE_BACKOFF_MAX_MS = 1_800_000; -// A better route that answered the check but then failed to connect is not -// tried again for this long, so a flaky LAN cannot bounce the connection. -const BETTER_ROUTE_COOLDOWN_MS = 300_000; interface SupervisorIntent { readonly desired: boolean; @@ -77,8 +70,7 @@ type SupervisorSignal = | { readonly _tag: "DisconnectRequested" } | { readonly _tag: "RetryRequested" } | { readonly _tag: "NetworkChanged"; readonly network: NetworkStatus } - | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeups.ConnectionWakeup } - | { readonly _tag: "BetterRouteAvailable"; readonly routeId: string }; + | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeups.ConnectionWakeup }; interface PendingRetryTrace { readonly previousAttempt: Tracer.Span; @@ -143,20 +135,6 @@ function retryDelayMs(failureCount: number, random: number): number { return Math.round(ceiling / 2 + (ceiling / 2) * random); } -interface SilentRoute { - readonly failures: number; - readonly until: bigint; -} - -function backOffSilentRoute(previous: SilentRoute | undefined, now: bigint): SilentRoute { - const failures = (previous?.failures ?? 0) + 1; - const delayMs = Math.min( - SILENT_ROUTE_BACKOFF_MAX_MS, - BETTER_ROUTE_CHECK_INTERVAL_MS * 2 ** failures, - ); - return { failures, until: now + BigInt(delayMs) * 1_000_000n }; -} - function annotateTarget(target: ConnectionTarget) { return Effect.annotateCurrentSpan({ "environment.id": target.environmentId, @@ -165,7 +143,10 @@ function annotateTarget(target: ConnectionTarget) { }); } -function availableState(intent: SupervisorIntent, generation: number): SupervisorConnectionState { +/** The supervisor adds route outcomes as it publishes a state. */ +type PhaseState = Omit; + +function availableState(intent: SupervisorIntent, generation: number): PhaseState { return { desired: false, network: intent.network, @@ -183,7 +164,7 @@ function offlineState( generation: number, attempt: number, lastFailure: ConnectionAttemptError | null, -): SupervisorConnectionState { +): PhaseState { return { desired: true, network: intent.network, @@ -202,7 +183,7 @@ function connectingState( attempt: number, lastFailure: ConnectionAttemptError | null, stage: SupervisorConnectionState["stage"] = "preparing", -): SupervisorConnectionState { +): PhaseState { return { desired: true, network: intent.network, @@ -291,45 +272,38 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const signals = yield* Queue.unbounded(); const resetRetryState = yield* Ref.make(false); const probeUnanswered = yield* Ref.make(false); - const state = yield* SubscriptionRef.make( - !initialIntent.desired + const state = yield* SubscriptionRef.make({ + ...(!initialIntent.desired ? availableState(initialIntent, 0) : initialIntent.network === "offline" ? offlineState(initialIntent, 0, 0, null) - : connectingState(initialIntent, 0, 1, null), - ); + : connectingState(initialIntent, 0, 1, null)), + routes: [], + }); + const routeOutcomes = yield* Ref.make>(new Map()); + // Held while outcomes are checked against the routes or intent and written, + // so a prune or a clear cannot land between the check and the write. + const routeOutcomesLock = yield* Semaphore.make(1); const session = yield* SubscriptionRef.make>(Option.none()); const prepared = yield* SubscriptionRef.make>(Option.none()); // Learned routes arrive while a session is live, so the route list is read - // fresh by each attempt and check instead of being fixed at creation. + // fresh by each attempt instead of being fixed at creation. const currentEntry = yield* Ref.make(entry); const currentRoutes = Ref.get(currentEntry).pipe(Effect.map(connectionRoutes)); - const preferredRouteId = yield* Ref.make(Option.none()); - const routeCooldowns = yield* Ref.make>(new Map()); - // The generation keeps a check that started before a reset from recording - // its stale failures after it. - const silentRoutes = yield* Ref.make({ - generation: 0, - routes: new Map() as ReadonlyMap, - }); - const forgetSilentRoutes = Ref.update(silentRoutes, ({ generation }) => ({ - generation: generation + 1, - routes: new Map(), - })); - - const attemptEntry = Effect.gen(function* () { - const latest = yield* Ref.get(currentEntry); - const routes = connectionRoutes(latest); - const preferred = yield* Ref.getAndSet(preferredRouteId, Option.none()); - if (Option.isNone(preferred)) return latest; - const route = routes.find( - (candidate) => connectionRouteId(candidate.target) === preferred.value, - ); - return route === undefined - ? latest - : entryWithRoutes(latest, [route, ...routes.filter((candidate) => candidate !== route)]); + const currentRouteIds = currentRoutes.pipe( + Effect.map((routes) => new Set(routes.map((route) => connectionRouteId(route.target)))), + ); + const updateRouteOutcomes = Effect.fnUntraced(function* ( + update: ( + current: ReadonlyMap, + ) => ReadonlyMap, + ) { + const outcomes = yield* Ref.updateAndGet(routeOutcomes, update); + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + routes: [...outcomes.values()], + })); }); - const learnRoutesFrom = Effect.fnUntraced(function* ( learn: NonNullable, lease: ConnectionDriver.EnvironmentConnectionLease, @@ -344,78 +318,39 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( if (activeRoute === undefined) return; const updated = yield* learn({ activeRoute, reported }); if (Option.isSome(updated)) { - yield* Ref.set(currentEntry, updated.value); - yield* requestBetterRouteCheck(lease); + yield* routeOutcomesLock.withPermits(1)( + Effect.gen(function* () { + yield* Ref.set(currentEntry, updated.value); + // An outcome on a route that is gone would still sway the diagnosis. + const ids = yield* currentRouteIds; + yield* updateRouteOutcomes( + (current) => new Map([...current].filter(([routeId]) => ids.has(routeId))), + ); + }), + ); } }); - const routeIndex = (lease: ConnectionDriver.EnvironmentConnectionLease) => - currentRoutes.pipe( - Effect.map((routes) => - routes.findIndex( - (route) => connectionRouteId(route.target) === connectionRouteId(lease.prepared.target), - ), - ), - ); - - const checkBetterRoutes = Effect.fnUntraced(function* ( - lease: ConnectionDriver.EnvironmentConnectionLease, - ) { - const latest = yield* Ref.get(currentEntry); - const current = yield* routeIndex(lease); - if (current <= 0) return; - const now = yield* Clock.monotonicTimeNanos; - const cooldowns = yield* Ref.get(routeCooldowns); - const silent = yield* Ref.get(silentRoutes); - const better = connectionRoutes(latest) - .slice(0, current) - .filter((route) => { - const id = connectionRouteId(route.target); - return (cooldowns.get(id) ?? 0n) <= now && (silent.routes.get(id)?.until ?? 0n) <= now; - }); - const results = yield* Effect.forEach( - better, - (route: ConnectionRoute) => driver.preflight(latest, route), - { concurrency: "unbounded" }, - ); - yield* Ref.update(silentRoutes, (previous) => { - if (previous.generation !== silent.generation) return previous; - const routes = new Map(previous.routes); - for (const [position, route] of better.entries()) { - const id = connectionRouteId(route.target); - if (results[position] !== "silent") routes.delete(id); - else routes.set(id, backOffSilentRoute(previous.routes.get(id), now)); - } - return { generation: previous.generation, routes }; - }); - const index = results.indexOf("ready"); - if (index === -1) return; - const live = yield* SubscriptionRef.get(session); - if (Option.isNone(live) || live.value !== lease.session) return; - yield* signal({ - _tag: "BetterRouteAvailable", - routeId: connectionRouteId(better[index]!.target), - }); - }); - - const betterRouteChecks = yield* Queue.dropping(1); - const requestBetterRouteCheck = (lease: ConnectionDriver.EnvironmentConnectionLease) => - routeIndex(lease).pipe( - Effect.flatMap((index) => - index > 0 ? Queue.offer(betterRouteChecks, lease).pipe(Effect.asVoid) : Effect.void, - ), - ); - const clearLease = Effect.all( [SubscriptionRef.set(session, Option.none()), SubscriptionRef.set(prepared, Option.none())], { discard: true }, ); - const setState = Effect.fn("EnvironmentSupervisor.setState")(function* ( - next: SupervisorConnectionState, + const setState = Effect.fn("EnvironmentSupervisor.setState")(function* (next: PhaseState) { + const routes = next.desired ? [...(yield* Ref.get(routeOutcomes)).values()] : []; + if (!next.desired) yield* Ref.set(routeOutcomes, new Map()); + yield* SubscriptionRef.set(state, { ...next, routes }); + }, routeOutcomesLock.withPermits(1)); + + // A route check can end after a disconnect cleared the outcomes, or after + // learned routes replaced the route it checked. + const recordRoute = Effect.fn("EnvironmentSupervisor.recordRoute")(function* ( + outcome: ConnectionRouteOutcome, ) { - yield* SubscriptionRef.set(state, next); - }); + if (!(yield* Ref.get(intent)).desired) return; + if (!(yield* currentRouteIds).has(outcome.routeId)) return; + yield* updateRouteOutcomes((current) => new Map(current).set(outcome.routeId, outcome)); + }, routeOutcomesLock.withPermits(1)); const signal = Effect.fn("EnvironmentSupervisor.signal")(function* (next: SupervisorSignal) { yield* Queue.offer(signals, next); @@ -450,11 +385,14 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( lastFailure: ConnectionAttemptError | null, synchronizedRoutes: Ref.Ref, ) { - return yield* driver.connect(yield* attemptEntry, (progress) => - (progress.stage === "synchronizing" - ? Ref.update(synchronizedRoutes, (count) => count + 1) - : Effect.void - ).pipe(Effect.andThen(reportProgress(attempt, generation, lastFailure, progress))), + return yield* driver.connect( + yield* Ref.get(currentEntry), + (progress) => + (progress.stage === "synchronizing" + ? Ref.update(synchronizedRoutes, (count) => count + 1) + : Effect.void + ).pipe(Effect.andThen(reportProgress(attempt, generation, lastFailure, progress))), + recordRoute, ); }); @@ -570,7 +508,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } break; case "ConnectRequested": - case "BetterRouteAvailable": break; case "Wakeup": // An attempt that ran into a long suspension is likely stuck. @@ -589,23 +526,19 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const isRelayLease = (lease: ConnectionDriver.EnvironmentConnectionLease) => lease.prepared.target._tag === "RelayConnectionTarget"; - const connectedLeaseEnd = Effect.fnUntraced(function* ( + const endsConnectedLease = Effect.fnUntraced(function* ( next: SupervisorSignal, lease: ConnectionDriver.EnvironmentConnectionLease, ) { if (next._tag === "DisconnectRequested") { - return "end" as const; - } - if (next._tag === "BetterRouteAvailable") { - yield* Ref.set(preferredRouteId, Option.some(next.routeId)); - return "reset" as const; + return true; } // Only a session over T3 Connect holds the old account's credential. if (next._tag === "Wakeup" && next.reason === "credentials-changed" && isRelayLease(lease)) { yield* logManagedRelayAccountChange; - return "end" as const; + return true; } - return undefined; + return false; }); const leaseRouteKind = (lease: ConnectionDriver.EnvironmentConnectionLease) => @@ -655,7 +588,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( : CONNECTION_PROBE_TIMEOUT; case "ConnectRequested": case "DisconnectRequested": - case "BetterRouteAvailable": return undefined; } }; @@ -708,25 +640,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( next._tag === "RetryRequested" ? Ref.set(resetRetryState, false) : Effect.void, ), ); - // Ticks always run: the check skips the preferred route itself, and the - // route list can grow while connected (learned routes). - yield* Stream.tick(Duration.millis(BETTER_ROUTE_CHECK_INTERVAL_MS)).pipe( - Stream.drop(1), - Stream.runForEach(() => requestBetterRouteCheck(lease)), - Effect.forkScoped, - ); const routeKind = yield* leaseRouteKind(lease); for (;;) { const next = yield* takeSignal; - const end = yield* connectedLeaseEnd(next, lease); - if (end !== undefined) { - return end === "reset"; - } - if ( - (next._tag === "NetworkChanged" && next.network !== "offline") || - (next._tag === "Wakeup" && ConnectionWakeups.resetsRetryBackoff(next.reason)) - ) { - yield* requestBetterRouteCheck(lease); + if (yield* endsConnectedLease(next, lease)) { + return false; } const probeTimeout = probeTimeoutFor(next, routeKind); if (probeTimeout === undefined) { @@ -808,10 +726,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( yield* probeEvent.exit; break; } - const endDuringProbe = yield* connectedLeaseEnd(probeEvent.signal, lease); - if (endDuringProbe !== undefined) { + if (yield* endsConnectedLease(probeEvent.signal, lease)) { yield* stopProbe; - return endDuringProbe === "reset"; + return false; } const signalTimeout = probeTimeoutFor(probeEvent.signal, routeKind); if (signalTimeout !== undefined) { @@ -832,7 +749,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( pendingRetry: Option.Option, ignoreOffline: boolean, ) { - const switchingTo = yield* Ref.get(preferredRouteId); yield* SubscriptionRef.set(prepared, Option.none()); const synchronizedRoutes = yield* Ref.make(0); const establishment = yield* Effect.raceAllFirst([ @@ -918,16 +834,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } const active = establishment.exit.value; - if (Option.isSome(switchingTo)) { - const landed = connectionRouteId(active.lease.prepared.target); - if (landed !== switchingTo.value) { - const until = - (yield* Clock.monotonicTimeNanos) + BigInt(BETTER_ROUTE_COOLDOWN_MS) * 1_000_000n; - yield* Ref.update(routeCooldowns, (current) => - new Map(current).set(switchingTo.value, until), - ); - } - } const currentIntent = yield* Ref.get(intent); if (!currentIntent.desired || (currentIntent.network === "offline" && !ignoreOffline)) { return { @@ -1017,8 +923,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( case "RetryRequested": case "NetworkChanged": return false; - case "BetterRouteAvailable": - break; } } }), @@ -1026,7 +930,6 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }); const waitForSignal = Queue.take(signals).pipe( - Effect.repeat({ while: (next) => next._tag === "BetterRouteAvailable" }), Effect.map( (next) => next._tag === "Wakeup" && ConnectionWakeups.resetsRetryBackoff(next.reason), ), @@ -1156,29 +1059,14 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( current.network === network ? [false, current] : ([true, { ...current, network }] as const), ).pipe( Effect.flatMap((changed) => - changed - ? (network === "offline" ? Effect.void : forgetSilentRoutes).pipe( - Effect.andThen(signal({ _tag: "NetworkChanged", network })), - ) - : Effect.void, + changed ? signal({ _tag: "NetworkChanged", network }) : Effect.void, ), ), ), Effect.forkScoped, ); yield* wakeups.changes.pipe( - Stream.runForEach((reason) => - // A long suspension can hide a network change the platform never reported. - (reason === "network-changed" || reason === "application-active-long" - ? forgetSilentRoutes - : Effect.void - ).pipe(Effect.andThen(signal({ _tag: "Wakeup", reason }))), - ), - Effect.forkScoped, - ); - yield* Queue.take(betterRouteChecks).pipe( - Effect.flatMap(checkBetterRoutes), - Effect.forever, + Stream.runForEach((reason) => signal({ _tag: "Wakeup", reason })), Effect.forkScoped, ); yield* run().pipe(Effect.forkScoped); diff --git a/packages/client-runtime/src/connection/tailnet.test.ts b/packages/client-runtime/src/connection/tailnet.test.ts new file mode 100644 index 0000000000000..7ec096b0fa572 --- /dev/null +++ b/packages/client-runtime/src/connection/tailnet.test.ts @@ -0,0 +1,128 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + +import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; +import { BearerConnectionTarget, RelayConnectionTarget } from "./model.ts"; +import { discoveredMachineAction, tailnetSignInBaseUrl } from "./tailnet.ts"; + +const STUDIO = EnvironmentId.make("environment-studio"); + +function savedAt(httpBaseUrl: string): ConnectionCatalogEntry { + const target = new BearerConnectionTarget({ + environmentId: STUDIO, + label: "studio", + connectionId: "saved", + }); + return { + target, + profile: Option.some( + new BearerConnectionProfile({ + connectionId: "saved", + environmentId: STUDIO, + label: "studio", + httpBaseUrl, + wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), + }), + ), + enabled: true, + }; +} + +describe("tailnetSignInBaseUrl", () => { + it.each([ + ["studio", "http://studio:3773/"], + ["Studio.tail1234.ts.net", "http://studio.tail1234.ts.net:3773/"], + ["100.88.1.2", "http://100.88.1.2:3773/"], + ["100.88.1.2:4000", "http://100.88.1.2:4000/"], + ["100.88.1.2:80", "http://100.88.1.2:80/"], + // The `t3 up` QR holds a plain URL with no secret. + ["http://100.88.1.2:3773", "http://100.88.1.2:3773/"], + // A saved address with no port is on port 80, so parsing it again keeps it there. + ["http://100.88.1.2/", "http://100.88.1.2:80/"], + [" studio:3773 ", "http://studio:3773/"], + ])("signs in at %s", (input, expected) => { + expect(tailnetSignInBaseUrl(input)).toBe(expected); + }); + + it.each([ + "", + "192.168.1.10", + "backend.example.com", + "localhost", + // Serve arrives from loopback, so sign-in over HTTPS is refused. + "https://studio.tail1234.ts.net", + "http://100.88.1.2:3773/#token=PAIRCODE", + "http://100.88.1.2:3773/pair?token=PAIRCODE", + ])("leaves %j to the pairing-code flow", (input) => { + expect(tailnetSignInBaseUrl(input)).toBeNull(); + }); +}); + +describe("discoveredMachineAction", () => { + const machine = { + environmentId: STUDIO, + tailnetIpv4: "100.88.1.2", + magicDnsName: "studio.tail1234.ts.net", + }; + + it("adds a new machine and never adds one the user removed", () => { + expect(discoveredMachineAction({ machine, entries: new Map(), dismissed: new Set() })).toEqual({ + _tag: "Add", + }); + expect( + discoveredMachineAction({ machine, entries: new Map(), dismissed: new Set([STUDIO]) }), + ).toEqual({ _tag: "Skip" }); + }); + + it.each(["192.168.1.10", "100.63.0.1", "100.064.1.2", "studio"])( + "never signs in at %j, which a host reported as its tailnet address", + (tailnetIpv4) => { + expect( + discoveredMachineAction({ + machine: { ...machine, tailnetIpv4 }, + entries: new Map(), + dismissed: new Set(), + }), + ).toEqual({ _tag: "Skip" }); + }, + ); + + it("warns when a saved machine answers at an address it was not saved with", () => { + const entries = (entry: ConnectionCatalogEntry) => new Map([[STUDIO, entry]]); + const dismissed = new Set(); + expect( + discoveredMachineAction({ + machine, + entries: entries(savedAt("http://100.88.1.2:3773/")), + dismissed, + }), + ).toEqual({ _tag: "Skip" }); + expect( + discoveredMachineAction({ + machine, + entries: entries(savedAt("http://studio:3773/")), + dismissed, + }), + ).toEqual({ _tag: "Skip" }); + expect( + discoveredMachineAction({ + machine, + entries: entries(savedAt("http://192.168.1.10:3773/")), + dismissed, + }), + ).toEqual({ _tag: "Conflict", label: "studio" }); + // A T3 Connect machine has no direct address to compare. + expect( + discoveredMachineAction({ + machine, + entries: entries({ + target: new RelayConnectionTarget({ environmentId: STUDIO, label: "studio" }), + profile: Option.none(), + enabled: true, + }), + dismissed, + }), + ).toEqual({ _tag: "Skip" }); + }); +}); diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts new file mode 100644 index 0000000000000..1dcbfb6798229 --- /dev/null +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -0,0 +1,272 @@ +import { + type AuthClientPresentationMetadata, + type EnvironmentId, + TailnetIpv4, + type TailnetPeerEnvironment, + TAILNET_APP_CAPABILITY, + TAILNET_POLICY_URL, + type TailnetSignInRefusedReason, +} from "@t3tools/contracts"; +import { isSharedAddressSpaceHost, normalizeHostname } from "@t3tools/shared/hostClassification"; +import * as Schema from "effect/Schema"; + +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import { ConnectionBlockedError, ConnectionTransientError } from "./model.ts"; +import { connectionRoutes, routeHttpBaseUrl } from "./routes.ts"; + +/** Where `t3 up` and the desktop "Tailscale sign-in" switch listen. */ +export const TAILNET_SIGN_IN_PORT = 3773; + +const SINGLE_LABEL_HOST = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u; + +const isTailnetIpv4 = Schema.is(TailnetIpv4); + +function isTailnetName(hostname: string): boolean { + if (hostname.endsWith(".ts.net")) return true; + return SINGLE_LABEL_HOST.test(hostname) && !/^\d+$/u.test(hostname) && hostname !== "localhost"; +} + +/** + * The direct tailnet address to sign in at, from what a user typed or scanned: + * `studio`, `studio.tailnet.ts.net`, or `100.x.y.z`, each with an optional + * port, or a plain `http://` URL with no pairing code (the `t3 up` QR). Null + * for anything else, which goes to the pairing-code flow. + */ +export function tailnetSignInBaseUrl(input: string): string | null { + const trimmed = input.trim(); + if (trimmed === "") return null; + const hasScheme = /^[a-z][a-z\d+.-]*:\/\//iu.test(trimmed); + let url: URL; + try { + url = new URL(hasScheme ? trimmed : `http://${trimmed}`); + } catch { + return null; + } + if (url.protocol !== "http:" || url.username !== "" || url.password !== "") return null; + // A path, query, or fragment is a pairing link, not a bare address. + if (url.pathname !== "/" || url.search !== "" || url.hash !== "") return null; + const hostname = normalizeHostname(url.hostname); + if (!isSharedAddressSpaceHost(hostname) && !isTailnetName(hostname)) return null; + // A bare host means T3 Code's port. A URL with no port means port 80, as `http://` says, so a + // saved address parsed again keeps its port; URL drops a typed `:80` the same way. + const typedPort = /^(?:[a-z][a-z\d+.-]*:\/\/)?[^/?#]*:\d+(?:[/?#]|$)/iu.test(trimmed); + const port = + url.port !== "" ? url.port : hasScheme || typedPort ? "80" : String(TAILNET_SIGN_IN_PORT); + return `http://${hostname}:${port}/`; +} + +/** Hosts refuse browser origins, and a page on plain `http://100.x` cannot make a DPoP key. */ +export function supportsTailnetSignIn(metadata: AuthClientPresentationMetadata): boolean { + return metadata.surface === "desktop" || metadata.surface === "mobile"; +} + +// Off Tailscale, a pinned name can resolve to a LAN address the host refuses; it +// works again once Tailscale is back, like the other two. +const TRANSIENT_REFUSALS: ReadonlySet = new Set([ + "not_direct_tailnet", + "tailscale_unavailable", + "rate_limited", + // Ends on its own once the host can read the policy again. + "policy_unknown", +]); + +export function tailnetRefusalError( + reason: TailnetSignInRefusedReason, + traceId?: string, + suggestedGrant?: string, +) { + // Forms show the detail as is, before the machine has a label. + const { headline, steps } = tailnetRefusalVerdict("that machine", reason); + const sentence = `${headline.charAt(0).toUpperCase()}${headline.slice(1)}. ${steps.join(" ")}`; + const fields = { + detail: + suggestedGrant === undefined + ? sentence + : `${sentence}\n\nAdd this to your tailnet policy:\n${suggestedGrant}`, + hop: "server" as const, + via: "direct" as const, + tailnetRefusal: reason, + ...(traceId === undefined ? {} : { traceId }), + ...(suggestedGrant === undefined ? {} : { suggestedGrant }), + }; + return TRANSIENT_REFUSALS.has(reason) + ? new ConnectionTransientError({ reason: "remote-unavailable", ...fields }) + : new ConnectionBlockedError({ reason: "permission", ...fields }); +} + +export type DiscoveredMachineAction = + | { readonly _tag: "Add" } + | { readonly _tag: "Skip" } + /** A saved environment answered at an address it was never saved with. */ + | { readonly _tag: "Conflict"; readonly label: string }; + +function routeHosts(entry: ConnectionCatalogEntry): ReadonlySet { + return new Set( + connectionRoutes(entry).flatMap((route) => { + const url = routeHttpBaseUrl(route); + return url !== null && URL.canParse(url) ? [normalizeHostname(new URL(url).hostname)] : []; + }), + ); +} + +/** + * What to do with a machine found on the tailnet. A removed machine stays + * removed, and a found address never joins a saved environment's routes: the + * public descriptor does not prove which machine answered. + */ +export function discoveredMachineAction(input: { + readonly machine: Pick; + readonly entries: ReadonlyMap; + readonly dismissed: ReadonlySet; +}): DiscoveredMachineAction { + const { machine } = input; + // A connected host could name a LAN address; sign-in goes only to a tailnet one. + if (!isTailnetIpv4(machine.tailnetIpv4)) return { _tag: "Skip" }; + if (input.dismissed.has(machine.environmentId)) return { _tag: "Skip" }; + const saved = input.entries.get(machine.environmentId); + if (saved === undefined) return { _tag: "Add" }; + const hosts = routeHosts(saved); + const dnsName = machine.magicDnsName === null ? null : normalizeHostname(machine.magicDnsName); + const known = + hosts.has(normalizeHostname(machine.tailnetIpv4)) || + (dnsName !== null && (hosts.has(dnsName) || hosts.has(dnsName.split(".")[0]!))); + // Without a direct address to compare, T3 Connect and SSH routes cannot conflict. + if (known || hosts.size === 0) return { _tag: "Skip" }; + return { _tag: "Conflict", label: saved.target.label }; +} + +/** Machines with `tailnetSignIn` that `discoveredMachineAction` would add, once per environment. */ +export function machinesToAdd< + Machine extends Pick, +>(input: { + readonly machines: ReadonlyArray; + readonly entries: ReadonlyMap; + readonly dismissed: ReadonlySet; +}): { + readonly add: ReadonlyArray; + readonly conflicts: ReadonlyArray<{ readonly machine: Machine; readonly label: string }>; +} { + const seen = new Set(); + const add: Machine[] = []; + const conflicts: Array<{ readonly machine: Machine; readonly label: string }> = []; + for (const machine of input.machines) { + if (seen.has(machine.environmentId)) continue; + seen.add(machine.environmentId); + const action = discoveredMachineAction({ ...input, machine }); + if (action._tag === "Add") add.push(machine); + if (action._tag === "Conflict") conflicts.push({ machine, label: action.label }); + } + return { add, conflicts }; +} + +const PAIR_INSTEAD = "Or pair this device with a code instead."; + +/** Why a host refused this device's Tailscale sign-in, and the fix. */ +export function tailnetRefusalVerdict( + host: string, + reason: TailnetSignInRefusedReason, +): { readonly headline: string; readonly steps: ReadonlyArray } { + const grant = `Grant this device \`${TAILNET_APP_CAPABILITY}\` on ${host} in your tailnet policy at ${TAILNET_POLICY_URL}.`; + switch (reason) { + case "disabled": + return { + headline: `Tailscale sign-in is off on ${host}`, + steps: [ + `On ${host}, run \`npx t3 up --tailscale\`, or turn on Tailscale sign-in in Settings.`, + PAIR_INSTEAD, + ], + }; + case "browser_request": + return { + headline: `${host} accepts Tailscale sign-in only from the desktop and mobile apps`, + steps: ["Pair this browser with a code."], + }; + case "not_direct_tailnet": + return { + headline: `${host} did not see this device on its Tailscale address`, + steps: [ + "Turn on Tailscale on this device, and log in to it again if it asks. Saved machines retry on their own.", + `Add ${host} by its Tailscale name or 100.x address, not a LAN or HTTPS address.`, + ], + }; + case "tailscale_unavailable": + return { + headline: `${host} could not ask Tailscale who this device is`, + steps: [`On ${host}, check that Tailscale runs, or run \`t3 tailscale status\`.`], + }; + case "tailscale_outdated": + return { + headline: `${host} runs a Tailscale too old for sign-in`, + steps: [`Update Tailscale on ${host}.`], + }; + case "owner_unknown": + return { + headline: `${host} does not know who owns it`, + steps: [grant, `Or, on ${host}, run \`npx t3 up --owner \`.`], + }; + case "tagged_device": + return { + headline: `This device is tagged, and no grant lets it into ${host}`, + steps: [grant, PAIR_INSTEAD], + }; + case "shared_device": + return { + headline: `This device is shared in from another tailnet, and no grant lets it into ${host}`, + steps: [grant, PAIR_INSTEAD], + }; + case "other_user": + return { + headline: `This device belongs to another Tailscale user, and no grant lets it into ${host}`, + steps: [grant, PAIR_INSTEAD], + }; + case "own_node": + return { + headline: `This device is ${host}`, + steps: ["Use this device's own T3 Code instead."], + }; + case "routes_for_others": + return { + headline: "This device is a subnet router, so it cannot sign in", + steps: [ + "Traffic it forwards could arrive as this device, so no grant lets it in. Stop advertising routes on it.", + PAIR_INSTEAD, + ], + }; + case "no_grant": + return { + headline: `${host} lets in only devices its tailnet policy grants T3 Code`, + steps: [grant], + }; + case "grant_unusable": + return { + headline: `This device's T3 Code grant on ${host} names no scope T3 Code knows`, + steps: [ + `In your tailnet policy at ${TAILNET_POLICY_URL}, fix the scopes in that grant. The list takes "read", "standard", "admin", or single scopes such as "orchestration:read".`, + ], + }; + case "policy_unknown": + return { + headline: `${host} cannot tell whether your tailnet policy grants T3 Code, so it lets in only granted devices`, + steps: [ + `On ${host}, run \`t3 tailscale require-grant\` to see why. Saved machines retry on their own.`, + grant, + ], + }; + case "blocked": + return { + headline: `${host} blocked this device`, + steps: [ + `Unblock this device in Settings > Sharing & access on ${host}, or in Settings > Environments on another device that can manage access there.`, + `A device can manage access when it has access:write and orchestration:read on ${host}. Devices paired with a default t3 pair code have them, as do devices granted admin, and yours while nothing is granted and grants are not required.`, + `Then try again on this device: press Retry now (the reconnect button on mobile) for a saved machine, or submit the form again if you were adding it.`, + `Or run \`t3 pair --scope orchestration:read --scope access:write\` on ${host}. On this device, paste the link it prints into Add route under ${host} in Settings > Environments, or into Add environment if ${host} is not saved. If the link names a LAN address, enter its code at http://: instead (\`tailscale ip -4\` on ${host}, or its Tailscale menu, shows the address).`, + `Then unblock this device, and revoke that pairing in Settings > Sharing & access on ${host} so this device signs in through Tailscale again. On a headless host, run \`t3 auth session list\` and \`t3 auth session revoke \` there, then restart T3 Code on this device.`, + ], + }; + case "rate_limited": + return { + headline: `${host} asked this device to slow down`, + steps: ["Wait a minute, then try again. Saved machines retry on their own."], + }; + } +} diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index e043279d3e576..3abd17296035c 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -21,6 +21,7 @@ export class ConnectionPersistenceError extends Schema.TaggedError, ConnectionPersistenceError>; readonly listDisabled: Effect.Effect, ConnectionPersistenceError>; + /** Environments the user removed, which discovery must not add again. */ + readonly listDismissed: Effect.Effect, ConnectionPersistenceError>; } >()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {} @@ -67,8 +70,10 @@ export class ConnectionRegistrationStore extends Context.Service< environmentId: EnvironmentId, routes: ReadonlyArray, ) => Effect.Effect; + /** `dismiss` remembers the removal, so discovery does not add the environment again. */ readonly remove: ( environmentId: EnvironmentId, + options?: { readonly dismiss?: boolean }, ) => Effect.Effect; readonly setEnabled: ( environmentId: EnvironmentId, diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts index 60f561e3dae53..f02eb9bd9413c 100644 --- a/packages/client-runtime/src/platform/storageDocument.test.ts +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -335,6 +335,24 @@ describe("ConnectionCatalogDocument", () => { ); }); + it("remembers a removal so discovery skips it, until the user adds the machine again", () => { + const registration = new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }); + const registered = registerConnectionInCatalog(EMPTY_CONNECTION_CATALOG_DOCUMENT, registration); + + expect(removeConnectionFromCatalog(registered, ENVIRONMENT_ID).dismissedEnvironmentIds).toEqual( + [], + ); + const dismissed = removeConnectionFromCatalog(registered, ENVIRONMENT_ID, { dismiss: true }); + expect(dismissed.dismissedEnvironmentIds).toEqual([ENVIRONMENT_ID]); + expect(registerConnectionInCatalog(dismissed, registration).dismissedEnvironmentIds).toEqual( + [], + ); + }); + it("persists the normalized SSH profile and its paired token beside its target", () => { const target = new SshConnectionTarget({ environmentId: ENVIRONMENT_ID, diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts index 1af8e0e3d67aa..b3ef4d2f285ff 100644 --- a/packages/client-runtime/src/platform/storageDocument.ts +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -29,6 +29,10 @@ export const ConnectionCatalogDocument = Schema.Struct({ disabledEnvironmentIds: Schema.Array(EnvironmentId).pipe( Schema.withDecodingDefaultKey(Effect.succeed([])), ), + /** Removed by the user; discovery does not add these again until the user does. */ + dismissedEnvironmentIds: Schema.Array(EnvironmentId).pipe( + Schema.withDecodingDefaultKey(Effect.succeed([])), + ), }); export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; @@ -39,6 +43,7 @@ export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Obje credentials: [], remoteDpopTokens: [], disabledEnvironmentIds: [], + dismissedEnvironmentIds: [], }); export function replaceCatalogValue( @@ -129,12 +134,20 @@ export function registerConnectionInCatalog( registration: ConnectionRegistration, routes: ReadonlyArray = [registration.target], ): ConnectionCatalogDocument { - const next = setRoutesInCatalog(document, registration.target.environmentId, routes); + const next = { + ...setRoutesInCatalog(document, registration.target.environmentId, routes), + dismissedEnvironmentIds: removeCatalogValue( + document.dismissedEnvironmentIds, + (value) => value, + registration.target.environmentId, + ), + }; switch (registration._tag) { case "RelayConnectionRegistration": return next; case "BearerConnectionRegistration": + case "TailnetConnectionRegistration": case "SshConnectionRegistration": return { ...next, @@ -154,10 +167,17 @@ export function registerConnectionInCatalog( export function removeConnectionFromCatalog( document: ConnectionCatalogDocument, environmentId: EnvironmentId, + options?: { readonly dismiss?: boolean }, ): ConnectionCatalogDocument { const next = setRoutesInCatalog(document, environmentId, []); + const dismissed = removeCatalogValue( + next.dismissedEnvironmentIds, + (value) => value, + environmentId, + ); return { ...next, + dismissedEnvironmentIds: options?.dismiss === true ? [...dismissed, environmentId] : dismissed, remoteDpopTokens: removeCatalogValue( next.remoteDpopTokens, (value) => value.environmentId, diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index adfad46a41b89..7510024ad8c3b 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -448,16 +448,23 @@ it.effect( ), ); -it.effect("requires access management to answer a link request", () => +it.effect("requires access management to answer a link request or manage Tailscale devices", () => Effect.scoped( Effect.gen(function* () { const registry = yield* setup; + const commands = [ + WS_METHODS.peerLinksAnswerRequest, + WS_METHODS.tailnetDevicesList, + WS_METHODS.tailnetDevicesBlock, + WS_METHODS.tailnetDevicesUnblock, + ].map((method) => createCommandPermissions(runtime, method)); registry.set(sessions(env), AsyncResult.success(grant(true))); - const answer = createCommandPermissions(runtime, WS_METHODS.peerLinksAnswerRequest); - expect(registry.get(answer.permissionAtom(env))).toBe(false); - expect((yield* answer.authorize(registry, env).pipe(Effect.flip)).requiredPermission).toBe( - AuthAccessWriteScope, - ); + for (const command of commands) { + expect(registry.get(command.permissionAtom(env))).toBe(false); + expect((yield* command.authorize(registry, env).pipe(Effect.flip)).requiredPermission).toBe( + AuthAccessWriteScope, + ); + } registry.set( sessions(env), AsyncResult.success({ @@ -466,8 +473,10 @@ it.effect("requires access management to answer a link request", () => permissions: [AuthAccessWriteScope], }), ); - expect(registry.get(answer.permissionAtom(env))).toBe(true); - yield* answer.authorize(registry, env); + for (const command of commands) { + expect(registry.get(command.permissionAtom(env))).toBe(true); + yield* command.authorize(registry, env); + } }), ), ); diff --git a/packages/client-runtime/src/state/commandPermissions.ts b/packages/client-runtime/src/state/commandPermissions.ts index 078f7c47508bb..de171981c7fb8 100644 --- a/packages/client-runtime/src/state/commandPermissions.ts +++ b/packages/client-runtime/src/state/commandPermissions.ts @@ -6,6 +6,7 @@ import { type EnvironmentId, type AuthSessionState, type AuthEnvironmentScope, + tailnetScopeDenial, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -61,7 +62,10 @@ function makeCommandPermissions( if (missing !== undefined) return yield* new EnvironmentAuthorizationError({ ...authScopeRequiredResponse(missing), - message: `This connection requires ${missing}.`, + message: + Option.isSome(session) && session.value.signedInWithTailscale === true + ? tailnetScopeDenial(missing) + : `This connection requires ${missing}.`, }); }), ); diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index 62bfc4f229ad9..347dbd0a13f03 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -131,6 +131,9 @@ function makeHarness(reply: (requestNumber: number) => Response | Promise Effect.die("Unexpected bearer connection preparation."), authorizeDpop: () => Effect.die("HTTP requests must not prepare a WebSocket connection."), + signInTailnet: () => Effect.die("Unexpected Tailscale sign-in."), + authorizeTailnet: () => Effect.die("HTTP requests must not prepare a WebSocket connection."), + authorizeTailnetHttp: () => Effect.die("Unexpected Tailscale session."), authorizeDpopHttp: (input) => Effect.sync(() => { authorizations.push(input); diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts index e3fcbb5ffadfa..859cccba59df8 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.ts @@ -95,7 +95,9 @@ export const executeAuthenticatedEnvironmentHttpRequest = < >( input: Parameters>[0], ) => - input.prepared.httpAuthorization?._tag === "Dpop" + // A Tailscale session never touches T3 Connect, so it keeps its caller's trace. + input.prepared.httpAuthorization?._tag === "Dpop" && + input.prepared.httpAuthorization.tailnetCredentialId === undefined ? executeEnvironmentRequest(input).pipe( Effect.withSpan(ENVIRONMENT_REQUEST_SPAN, { root: true }), withRelayClientTracing, @@ -148,20 +150,26 @@ const executeEnvironmentRequest = Effect.fnUntraced(function* < cause: input.prepared.target._tag, }); } - const current = yield* remote.value - .authorizeDpopHttp({ - expectedEnvironmentId: input.prepared.environmentId, - ...(rejectedAccessToken === undefined ? {} : { rejectedAccessToken }), - }) - .pipe( - Effect.mapError( - (cause) => - new RemoteEnvironmentAuthFetchError({ - message: "Could not authorize the environment request.", - cause, - }), - ), - ); + const rejected = rejectedAccessToken === undefined ? {} : { rejectedAccessToken }; + const tailnetCredentialId = authorization.tailnetCredentialId; + const current = yield* ( + tailnetCredentialId === undefined + ? remote.value.authorizeDpopHttp({ + expectedEnvironmentId: input.prepared.environmentId, + ...rejected, + }) + : remote.value + .authorizeTailnetHttp({ credentialId: tailnetCredentialId, ...rejected }) + .pipe(Effect.map((httpAuthorization) => ({ httpBaseUrl, httpAuthorization }))) + ).pipe( + Effect.mapError( + (cause) => + new RemoteEnvironmentAuthFetchError({ + message: "Could not authorize the environment request.", + cause, + }), + ), + ); // A learned direct route keeps its own origin; only the token renews. if (input.prepared.target._tag === "RelayConnectionTarget") { httpBaseUrl = current.httpBaseUrl; diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index fde1790d338a3..f307d169ab254 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -899,6 +899,33 @@ export function createEnvironmentRpcQueryAtomFamily< }); } +/** + * A query whose method needs a grant, checked like a command's. Show it only + * where a command needing the same scope offers its `permissionAtom`. + */ +export function createGuardedEnvironmentRpcQueryAtomFamily< + R, + ER, + TTag extends EnvironmentUnaryRpcTag, +>( + runtime: Atom.AtomRuntime, + options: { readonly label: string; readonly tag: TTag; readonly staleTimeMs?: number }, +) { + return createEnvironmentQueryAtomFamily(runtime, { + label: options.label, + ...(options.staleTimeMs === undefined ? {} : { staleTimeMs: options.staleTimeMs }), + execute: Effect.fn("EnvironmentRpc.guardedQuery")(function* (input: EnvironmentRpcInput) { + const registry = yield* AtomRegistry.AtomRegistry; + return yield* requestGuarded(options.tag, input).pipe( + Effect.provideService(RpcPermissionGuard, { + authorize: (id, method, payload) => + createCommandPermissions(runtime, method).authorize(registry, id, payload), + }), + ); + }), + }); +} + export function createEnvironmentRpcSubscriptionAtomFamily< R, ER, diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 7f24f61c53b21..d9faf54393813 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -191,6 +191,7 @@ describe("environment shell synchronization", () => { generation: 1, lastFailure: null, retryAt: null, + routes: [], }); yield* Queue.offer(events, { kind: "snapshot", snapshot: batchSnapshot }); yield* Queue.offer(events, { kind: "synchronized" }); @@ -462,6 +463,7 @@ describe("environment shell synchronization", () => { generation: 0, lastFailure: null, retryAt: null, + routes: [], }); yield* Queue.offer(events, { kind: "snapshot", @@ -482,6 +484,7 @@ describe("environment shell synchronization", () => { generation: 1, lastFailure: null, retryAt: null, + routes: [], }); yield* SubscriptionRef.set(supervisorState, { desired: false, @@ -492,6 +495,7 @@ describe("environment shell synchronization", () => { generation: 2, lastFailure: null, retryAt: null, + routes: [], }); yield* Deferred.await(saveStarted); @@ -504,6 +508,7 @@ describe("environment shell synchronization", () => { generation: 3, lastFailure: null, retryAt: null, + routes: [], }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((state) => state.status === "synchronizing"), @@ -604,6 +609,7 @@ describe("environment shell synchronization", () => { generation: 0, lastFailure: null, retryAt: null, + routes: [], }); yield* Queue.offer(events, { kind: "snapshot", @@ -626,6 +632,7 @@ describe("environment shell synchronization", () => { generation: 1, lastFailure: null, retryAt: null, + routes: [], }); yield* Deferred.await(unenrichedSaveStarted); @@ -720,6 +727,7 @@ describe("environment shell synchronization", () => { generation: 0, lastFailure: null, retryAt: null, + routes: [], }); yield* Queue.offer(events, { kind: "snapshot", diff --git a/packages/client-runtime/src/state/tailnet.ts b/packages/client-runtime/src/state/tailnet.ts new file mode 100644 index 0000000000000..fd98040e8e46d --- /dev/null +++ b/packages/client-runtime/src/state/tailnet.ts @@ -0,0 +1,76 @@ +import { type EnvironmentId, type TailnetPeerEnvironment, WS_METHODS } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type { Atom, AtomRegistry } from "effect/reactivity"; + +import * as ConnectionOnboarding from "../connection/onboarding.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import { request } from "../rpc/client.ts"; +import { + createEnvironmentRpcCommand, + createGuardedEnvironmentRpcQueryAtomFamily, +} from "./runtime.ts"; + +const PEER_LIST_TIMEOUT = Duration.seconds(10); +const PEER_LIST_CONCURRENCY = 4; + +/** The devices that signed in to a host through Tailscale, with Block and Unblock. */ +export function createTailnetEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const devices = createGuardedEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:tailnet:devices", + tag: WS_METHODS.tailnetDevicesList, + staleTimeMs: 10_000, + }); + const refreshDevices = ( + { environmentId }: { readonly environmentId: EnvironmentId }, + registry: AtomRegistry.AtomRegistry, + ) => Effect.sync(() => registry.refresh(devices({ environmentId, input: {} }))); + return { + devices, + blockDevice: createEnvironmentRpcCommand(runtime, { + label: "environment-data:tailnet:block-device", + tag: WS_METHODS.tailnetDevicesBlock, + onSettled: refreshDevices, + }), + unblockDevice: createEnvironmentRpcCommand(runtime, { + label: "environment-data:tailnet:unblock-device", + tag: WS_METHODS.tailnetDevicesUnblock, + onSettled: refreshDevices, + }), + }; +} + +/** + * Adds the T3 Code machines on the tailnet that offer Tailscale sign-in: the + * ones this device found itself (`found`, from the desktop's peer list) and the + * ones each connected machine reports. Machines that cannot answer are skipped. + */ +export const discoverTailnetMachines = Effect.fn("clientRuntime.tailnet.discoverMachines")( + function* (found: ReadonlyArray) { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const onboarding = yield* ConnectionOnboarding.ConnectionOnboarding; + const environmentIds = [...(yield* SubscriptionRef.get(registry.entries)).keys()]; + const reported = yield* Effect.forEach( + environmentIds, + (environmentId) => + registry.state(environmentId).pipe( + Effect.flatMap((state) => + state.phase === "connected" + ? registry.run(environmentId, request(WS_METHODS.tailnetPeersList, {})) + : Effect.succeed({ peers: [] }), + ), + Effect.timeout(PEER_LIST_TIMEOUT), + // Older hosts and narrow sessions cannot list peers. + Effect.orElseSucceed(() => ({ peers: [] })), + ), + { concurrency: PEER_LIST_CONCURRENCY }, + ); + return yield* onboarding.addDiscoveredTailnetMachines([ + ...found, + ...reported.flatMap((answer) => answer.peers), + ]); + }, +); diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 23263d432444c..4c1f3d8a75a27 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -126,6 +126,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? new Map(), ), networkStatus: yield* SubscriptionRef.make("online"), + dismissedEnvironmentIds: yield* SubscriptionRef.make>(new Set()), start: Effect.void, register: () => Effect.die("Unexpected environment registration"), assertKnownAddress: () => Effect.die("Unexpected address check"), diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 555fa7e90b268..21b2753087d52 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -396,6 +396,8 @@ export const AuthSessionState = Schema.Struct({ scopes: Schema.optionalKey(AuthEnvironmentScopes), permissions: authScopeResponseFields.permissions, sessionMethod: Schema.optionalKey(ServerAuthSessionMethod), + /** The session came from Tailscale sign-in, so its tailnet grant sets its scopes. */ + signedInWithTailscale: Schema.optionalKey(Schema.Boolean), expiresAt: Schema.optionalKey(Schema.DateTimeUtc), }); export type AuthSessionState = typeof AuthSessionState.Type; diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index d3139f2eeff24..ce823fa052ff5 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -68,6 +68,10 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.peerLinksUnlink]: AuthAccessWriteScope, // A link lets every agent here reach the other environment: Settings' bar. [WS_METHODS.peerLinksAnswerRequest]: AuthAccessWriteScope, + // Blocking a device is access management, like revoking its session. + [WS_METHODS.tailnetDevicesList]: AuthAccessWriteScope, + [WS_METHODS.tailnetDevicesBlock]: AuthAccessWriteScope, + [WS_METHODS.tailnetDevicesUnblock]: AuthAccessWriteScope, } as const; export type ClientGuardedRpcTag = keyof typeof CLIENT_GUARDED_RPC_SCOPES; diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts index a77e14b555420..3caef44b8d799 100644 --- a/packages/contracts/src/desktopBootstrap.ts +++ b/packages/contracts/src/desktopBootstrap.ts @@ -19,6 +19,9 @@ export const DesktopBackendBootstrap = Schema.Struct({ shellEnvironmentPrepared: Schema.optionalKey(Schema.Boolean), tailscaleServeEnabled: Schema.Boolean, tailscaleServePort: PortSchema, + tailscaleSignInEnabled: Schema.optionalKey(Schema.Boolean), + // Accept only loopback and tailnet peers on a wildcard listener. + tailscaleOnly: Schema.optionalKey(Schema.Boolean), otlpTracesUrl: Schema.optional(Schema.String), otlpMetricsUrl: Schema.optional(Schema.String), otlpLogsUrl: Schema.optional(Schema.String), diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index f9cd98311d19d..73d6ce5aa34f6 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -163,6 +163,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ triage: Schema.optionalKey(Schema.Boolean), /** Server accepts `{ type: "once" }` scheduled tasks; older servers reject them on save. */ oneTimeSchedules: Schema.optionalKey(Schema.Boolean), + /** Sign-in is on, Tailscale runs, and the owner is known: the owner's own + untagged devices can sign in at `/api/auth/tailnet` over the direct tailnet address. */ + tailnetSignIn: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 160b82664885d..b9b1ed6caca3b 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -42,6 +42,7 @@ import { ORCHESTRATION_PROTOCOL_HEADER, ORCHESTRATION_PROTOCOL_VERSION_TEXT, } from "./environment.ts"; +import { AuthTailnetSignInResult, TailnetSignInRefusedReason } from "./tailnet.ts"; import { DpopFailureReason, AuthSessionId, @@ -171,6 +172,8 @@ export class EnvironmentScopeRequiredError extends Schema.TaggedError()( + "EnvironmentTailnetSignInRefusedError", + { + code: Schema.Literal("tailnet_sign_in_refused"), + reason: TailnetSignInRefusedReason, + traceId: TrimmedNonEmptyString, + /** A tailnet policy grant that would let this device in, when one would. */ + suggestedGrant: Schema.optionalKey(TrimmedNonEmptyString), + }, + { httpApiStatus: 403 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(EnvironmentTailnetSignInRefusedError)(this, { + status: 403, + }); + } + + override get message(): string { + return `The environment refused Tailscale sign-in (${this.reason}).`; + } +} + export const EnvironmentResourceNotFoundReason = Schema.Literals(["thread_not_found"]); export type EnvironmentResourceNotFoundReason = typeof EnvironmentResourceNotFoundReason.Type; @@ -603,6 +632,19 @@ class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") }).middleware(EnvironmentAuthenticatedAuth), ) {} +/** Tailscale sign-in: the owner's own devices get a grant to exchange at `/oauth/token`. */ +class EnvironmentTailnetHttpApi extends HttpApiGroup.make("tailnet").add( + HttpApiEndpoint.post("signIn", "/api/auth/tailnet", { + headers: OptionalDpopProofHeaders, + success: AuthTailnetSignInResult, + error: [ + EnvironmentTailnetSignInRefusedError, + EnvironmentAuthInvalidError, + EnvironmentInternalError, + ], + }), +) {} + class EnvironmentMcpOAuthHttpApi extends HttpApiGroup.make("mcpOAuth") .add( HttpApiEndpoint.get("protectedResource", "/.well-known/oauth-protected-resource", { @@ -840,6 +882,7 @@ class EnvironmentWebhooksHttpApi extends HttpApiGroup.make("webhooks") export class EnvironmentHttpApi extends HttpApi.make("environment") .add(EnvironmentMetadataHttpApi) .add(EnvironmentAuthHttpApi) + .add(EnvironmentTailnetHttpApi) .add(EnvironmentMcpOAuthHttpApi) .add(EnvironmentOrchestrationHttpApi) .add(EnvironmentPullRequestsHttpApi) diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 896733c5197e6..41f733c7307eb 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -43,6 +43,7 @@ export * from "./guestStream.ts"; export * from "./applicationEvent.ts"; export * from "./orchestratorMcp.ts"; export * from "./peerLink.ts"; +export * from "./tailnet.ts"; export * from "./threadMetadataMcp.ts"; export * from "./threadPullRequest.ts"; export * from "./threadSearch.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5aa9acf2773a7..84f100470e8f4 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -11,6 +11,7 @@ import type { } from "./browserImport.ts"; import { AuthAccessTokenResult, AuthSessionState } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; +import { type TailnetPeerEnvironment, TailscaleState } from "./tailnet.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts"; import type { EditorId } from "./editor.ts"; @@ -406,6 +407,27 @@ export const DesktopDiscoveredSshHostSchema = Schema.Struct({ source: DesktopSshHostSourceSchema, }); +/** `online` means connected to Tailscale's control plane, not reachable from here. */ +export const DesktopTailscalePeerSchema = Schema.Struct({ + tailnetAddresses: Schema.Array(Schema.String), + magicDnsName: Schema.NullOr(Schema.String), + online: Schema.Boolean, + lastSeen: Schema.NullOr(Schema.String), + active: Schema.Boolean, + curAddr: Schema.NullOr(Schema.String), + relay: Schema.NullOr(Schema.String), + peerRelay: Schema.NullOr(Schema.String), +}); +export type DesktopTailscalePeer = typeof DesktopTailscalePeerSchema.Type; + +/** This device's own Tailscale, read on demand for connection diagnosis. */ +export const DesktopTailscaleStatusSchema = Schema.Struct({ + state: TailscaleState, + checkedAt: Schema.String, + peers: Schema.Array(DesktopTailscalePeerSchema), +}); +export type DesktopTailscaleStatus = typeof DesktopTailscaleStatusSchema.Type; + export interface DesktopSshEnvironmentBootstrap { target: DesktopSshEnvironmentTarget; httpBaseUrl: string; @@ -501,6 +523,8 @@ export interface DesktopServerExposureState { advertisedHost: string | null; tailscaleServeEnabled: boolean; tailscaleServePort: number; + tailscaleSignInEnabled?: boolean; + port?: number; } export const DesktopServerExposureStateSchema = Schema.Struct({ @@ -509,6 +533,10 @@ export const DesktopServerExposureStateSchema = Schema.Struct({ advertisedHost: Schema.NullOr(Schema.String), tailscaleServeEnabled: Schema.Boolean, tailscaleServePort: Schema.Number, + /** Your own Tailscale devices sign in with no code; the backend listens on the tailnet. */ + tailscaleSignInEnabled: Schema.optionalKey(Schema.Boolean), + /** The backend's port. Other devices find this machine on 3773 only. */ + port: Schema.optionalKey(Schema.Number), }); export interface PickFolderOptions { @@ -1091,6 +1119,10 @@ export interface DesktopBridge { readonly port?: number; }) => Promise; getAdvertisedEndpoints: () => Promise; + getTailscaleStatus: () => Promise; + setTailscaleSignInEnabled: (enabled: boolean) => Promise; + /** T3 Code machines on this device's tailnet, probed from the main process. */ + discoverTailnetEnvironments: () => Promise>; getWslState: () => Promise; setWslBackendEnabled: (enabled: boolean) => Promise; setWslDistro: (distro: string | null) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 01989ff8a474f..392777e5c3b4f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -26,6 +26,12 @@ import { PeerLinkRequestError, PeerLinkSummary, } from "./peerLink.ts"; +import { + TailnetAccessError, + TailnetDeviceInput, + TailnetDeviceList, + TailnetPeerList, +} from "./tailnet.ts"; import { ChatGptReconnectProfileInput, ChatGptReconnectProfile, @@ -512,6 +518,10 @@ export const WS_METHODS = { peerLinksLink: "peerLinks.link", peerLinksUnlink: "peerLinks.unlink", peerLinksAnswerRequest: "peerLinks.answerRequest", + tailnetDevicesList: "tailnetDevices.list", + tailnetDevicesBlock: "tailnetDevices.block", + tailnetDevicesUnblock: "tailnetDevices.unblock", + tailnetPeersList: "tailnetPeers.list", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", serverSearchAcpRegistry: "server.searchAcpRegistry", @@ -819,6 +829,32 @@ const WsPeerLinksAnswerRequestRpc = Rpc.make(WS_METHODS.peerLinksAnswerRequest, error: Schema.Union([PeerLinkRequestError, EnvironmentAuthorizationError]), }); +const WsTailnetDevicesListRpc = Rpc.make(WS_METHODS.tailnetDevicesList, { + payload: Schema.Struct({}), + success: TailnetDeviceList, + error: Schema.Union([TailnetAccessError, EnvironmentAuthorizationError]), +}); + +/** Ends the device's sessions here and refuses its Tailscale sign-in until unblocked. */ +const WsTailnetDevicesBlockRpc = Rpc.make(WS_METHODS.tailnetDevicesBlock, { + payload: TailnetDeviceInput, + success: Schema.Struct({ revokedSessionCount: NonNegativeInt }), + error: Schema.Union([TailnetAccessError, EnvironmentAuthorizationError]), +}); + +const WsTailnetDevicesUnblockRpc = Rpc.make(WS_METHODS.tailnetDevicesUnblock, { + payload: TailnetDeviceInput, + success: Schema.Struct({ unblocked: Schema.Boolean }), + error: Schema.Union([TailnetAccessError, EnvironmentAuthorizationError]), +}); + +/** T3 Code environments on this host's tailnet, so a phone finds boxes it has not seen. */ +const WsTailnetPeersListRpc = Rpc.make(WS_METHODS.tailnetPeersList, { + payload: Schema.Struct({}), + success: TailnetPeerList, + error: EnvironmentAuthorizationError, +}); + const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { payload: Schema.Struct({ patch: ServerSettingsPatch, @@ -2013,6 +2049,10 @@ export const WsRpcGroup = RpcGroup.make( WsPeerLinksLinkRpc, WsPeerLinksUnlinkRpc, WsPeerLinksAnswerRequestRpc, + WsTailnetDevicesListRpc, + WsTailnetDevicesBlockRpc, + WsTailnetDevicesUnblockRpc, + WsTailnetPeersListRpc, WsServerDiscoverSourceControlRpc, WsServerSearchAcpRegistryRpc, WsServerPrepareAcpRegistryAgentRpc, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index a79c499afa3ac..ac9d385ca926e 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -5,6 +5,7 @@ import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { OtlpEndpointCheckInput, resolveEnvironmentMachineKind, + ServerConfig, ServerObservability, ServerProvider, ServerProviders, @@ -70,6 +71,27 @@ describe("server config forward compatibility", () => { { id: "primary", kind: "session", label: "Session", usedPercent: 12 }, ]); }); + + it("reads host Tailscale facts when present, and absent or unreadable ones as absent", () => { + const decodeTailscale = Schema.decodeUnknownSync( + Schema.Struct({ tailscale: ServerConfig.fields.tailscale }), + ); + const facts = { + state: "needs_login", + checkedAt: "2026-10-28T00:00:00.000Z", + magicDnsName: null, + keyExpiry: null, + issues: [ + { cause: "needs_login", fix: "Run `sudo tailscale up`." }, + { cause: "some_future_cause", fix: "?" }, + ], + }; + expect(decodeTailscale({ tailscale: facts }).tailscale?.issues).toEqual([ + { cause: "needs_login", fix: "Run `sudo tailscale up`." }, + ]); + expect(decodeTailscale({})).toEqual({}); + expect(decodeTailscale({ tailscale: { ...facts, state: "some_future_state" } })).toEqual({}); + }); }); describe("ServerObservability", () => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e027930c116ba..094de30a2b2b6 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -9,6 +9,7 @@ import { import { AuthClientPresentationMetadata, ServerAuthDescriptor } from "./auth.ts"; import { ForwardCompatibleArray, + ForwardCompatibleOptional, IsoDateTime, NonNegativeInt, PositiveInt, @@ -29,6 +30,7 @@ import { RuntimeMode } from "./providerPolicy.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts"; import { ServerSettings } from "./settings.ts"; +import { TailscaleState } from "./tailnet.ts"; import { DesktopUpdateChannelSchema } from "./ipc.ts"; const KeybindingsMalformedConfigIssue = Schema.Struct({ @@ -607,6 +609,105 @@ export const ServerEventLoopStall = Schema.Struct({ }); export type ServerEventLoopStall = typeof ServerEventLoopStall.Type; +export const ServerTailscaleIssueCause = Schema.Literals([ + "not_installed", + "not_running", + "needs_login", + "needs_machine_approval", + "stopped", + "starting", + "unreadable", + "key_expired", + "key_expiring", + "health_warning", + "https_off", + "magic_dns_off", + "userspace_networking", + "outdated", + "no_ipv4", +]); +export type ServerTailscaleIssueCause = typeof ServerTailscaleIssueCause.Type; + +export const ServerTailscaleIssue = Schema.Struct({ + cause: ServerTailscaleIssueCause, + /** What to do on the host, without naming it. */ + fix: Schema.String, + /** Tailscale's own text, for a health warning. */ + message: Schema.optionalKey(Schema.String), +}); +export type ServerTailscaleIssue = typeof ServerTailscaleIssue.Type; + +/** The host's own Tailscale as of `checkedAt`. Clients keep the last one they saw. */ +export const ServerTailscaleStatus = Schema.Struct({ + state: TailscaleState, + checkedAt: IsoDateTime, + magicDnsName: Schema.NullOr(Schema.String), + keyExpiry: Schema.NullOr(IsoDateTime), + /** Most urgent first. Empty when nothing needs fixing. */ + issues: ForwardCompatibleArray(ServerTailscaleIssue), +}); +export type ServerTailscaleStatus = typeof ServerTailscaleStatus.Type; + +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; + +/** "3 days", "5h", "12m": coarse, for key expiry and how old a fact is. */ +export function formatRoughDuration(ms: number): string { + if (ms >= 2 * DAY_MS) return `${Math.floor(ms / DAY_MS)} days`; + if (ms >= DAY_MS) return "1 day"; + if (ms >= HOUR_MS) return `${Math.floor(ms / HOUR_MS)}h`; + return `${Math.max(1, Math.floor(ms / MINUTE_MS))}m`; +} + +/** One line for an issue, e.g. "Tailscale on studio is signed out". The CLI passes "this machine". */ +export function tailscaleIssueHeadline( + host: string, + issue: ServerTailscaleIssue, + keyExpiry: string | null, + now: number, +): string { + switch (issue.cause) { + case "not_installed": + return `Tailscale is not installed on ${host}`; + case "not_running": + return `Tailscale is not running on ${host}`; + case "needs_login": + return `Tailscale on ${host} is signed out`; + case "needs_machine_approval": + return `Tailscale on ${host} is waiting for approval`; + case "stopped": + return `Tailscale is off on ${host}`; + case "starting": + return `Tailscale on ${host} is starting`; + case "unreadable": + return `T3 Code could not read the Tailscale status on ${host}`; + case "key_expired": + return `The Tailscale node key on ${host} has expired`; + case "key_expiring": { + const expiresAt = keyExpiry === null ? Number.NaN : Date.parse(keyExpiry); + // A cached status can outlive the expiry it warned about. + return Number.isNaN(expiresAt) + ? `The Tailscale node key on ${host} expires soon` + : expiresAt <= now + ? `The Tailscale node key on ${host} has expired` + : `The Tailscale node key on ${host} expires in ${formatRoughDuration(expiresAt - now)}`; + } + case "health_warning": + return `Tailscale on ${host} reports: ${issue.message ?? "a problem"}`; + case "https_off": + return `HTTPS certificates are off on the tailnet of ${host}`; + case "magic_dns_off": + return `MagicDNS is off on the tailnet of ${host}`; + case "userspace_networking": + return `Tailscale on ${host} uses userspace networking`; + case "outdated": + return `Tailscale on ${host} is too old for Tailscale sign-in`; + case "no_ipv4": + return `Tailscale on ${host} has no IPv4 address`; + } +} + export const ServerConfig = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, @@ -653,6 +754,8 @@ export const ServerConfig = Schema.Struct({ * answering. Clients do not cache it, since it describes the host at connect. */ lastEventLoopStall: Schema.optionalKey(ServerEventLoopStall), + /** Absent from older servers. A value this build cannot read decodes as absent. */ + tailscale: ForwardCompatibleOptional(ServerTailscaleStatus), /** * Never sent in a config snapshot: the theme stream emits the current set * before any change, so a snapshot carrying it too would hand every diff --git a/packages/contracts/src/tailnet.ts b/packages/contracts/src/tailnet.ts new file mode 100644 index 0000000000000..6d8681ba3d4f5 --- /dev/null +++ b/packages/contracts/src/tailnet.ts @@ -0,0 +1,191 @@ +import * as Schema from "effect/Schema"; + +import { + AuthAccessReadScope, + AuthAccessWriteScope, + AuthDiagnosticsReadScope, + type AuthEnvironmentScope, + AuthFilesystemReadScope, + AuthOrchestrationReadScope, + AuthRelayReadScope, + AuthRelayWriteScope, + AuthStandardClientScopes, + AuthTerminalReadScope, +} from "./auth.ts"; +import { EnvironmentId, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** Every grant and session a device gets through Tailscale sign-in has this subject prefix. */ +export const TAILNET_SUBJECT_PREFIX = "tailscale:"; + +/** The auth subject of the Tailscale node with this StableID. */ +export const tailnetSubject = (stableNodeId: string): string => + `${TAILNET_SUBJECT_PREFIX}${stableNodeId}`; + +/** A two-minute grant bound to the request's DPoP key. Exchange it at `/oauth/token`. */ +export const AuthTailnetSignInResult = Schema.Struct({ + credential: TrimmedNonEmptyString, + expiresAt: Schema.DateTimeUtc, +}); +export type AuthTailnetSignInResult = typeof AuthTailnetSignInResult.Type; + +/** The app capability a tailnet grant gives a device to sign in, with `{ "scopes": [...] }`. */ +export const TAILNET_APP_CAPABILITY = "t3.codes/cap/t3code"; + +export const TAILNET_POLICY_URL = "https://login.tailscale.com/admin/acls/file"; + +const READ_SCOPES: ReadonlyArray = [ + AuthOrchestrationReadScope, + AuthDiagnosticsReadScope, + AuthTerminalReadScope, + AuthFilesystemReadScope, + AuthAccessReadScope, + AuthRelayReadScope, +]; +// Pairing codes, sessions, blocks and the T3 Connect link are admin only. A standard +// device still has terminals, so this keeps T3 Code's access settings from it, not the box. +const ADMIN_ONLY: ReadonlySet = new Set([ + AuthAccessWriteScope, + AuthRelayWriteScope, +]); + +/** The scope presets a grant can name, smallest first. A Map, so `__proto__` names nothing. */ +export const TAILNET_GRANT_PRESETS: ReadonlyMap< + string, + ReadonlyArray +> = new Map([ + ["read", READ_SCOPES], + ["standard", AuthStandardClientScopes.filter((scope) => !ADMIN_ONLY.has(scope))], + ["admin", AuthStandardClientScopes], +]); + +/** Denial text for a Tailscale session that lacks `scope`: what to add to its grant, and where. */ +export const tailnetScopeDenial = (scope: string): string => { + const preset = [...TAILNET_GRANT_PRESETS].find(([, scopes]) => + scopes.some((granted) => granted === scope), + )?.[0]; + return `This device's tailnet grant does not include ${scope}. Add "${scope}"${preset === undefined ? "" : ` or "${preset}"`} to its ${TAILNET_APP_CAPABILITY} grant in your tailnet policy at ${TAILNET_POLICY_URL}.`; +}; + +/** + * The smallest tailnet policy grant that lets `src` (a login, tag, or address) + * sign in to `dst` (a tag or address) on `port` with standard scopes. A `note` + * names them, as addresses do not say whose they are. + */ +export const tailnetGrantSnippet = ( + src: string, + dst: string, + port: number, + note?: string, +): string => + [ + '"grants": [{', + ...(note === undefined ? [] : [` // ${note}`]), + ` "src": [${JSON.stringify(src)}],`, + ` "dst": [${JSON.stringify(dst)}],`, + ` "ip": ["tcp:${port}"],`, + ` "app": { "${TAILNET_APP_CAPABILITY}": [{ "scopes": ["standard"] }] }`, + "}]", + ].join("\n"); + +/** Why this host refused Tailscale sign-in. Each reason is a check from the security model. */ +export const TailnetSignInRefusedReason = Schema.Literals([ + "disabled", + "browser_request", + "not_direct_tailnet", + "tailscale_unavailable", + "tailscale_outdated", + "owner_unknown", + "tagged_device", + "shared_device", + "other_user", + "own_node", + "routes_for_others", + "no_grant", + "grant_unusable", + "policy_unknown", + "blocked", + "rate_limited", +]); +export type TailnetSignInRefusedReason = typeof TailnetSignInRefusedReason.Type; + +/** A device that signed in through Tailscale, or one this host blocks. */ +export const TailnetDevice = Schema.Struct({ + stableNodeId: TrimmedNonEmptyString, + label: Schema.NullOr(TrimmedNonEmptyString), + blockedAt: Schema.NullOr(Schema.DateTimeUtc), + activeSessionCount: NonNegativeInt, + /** The device asking, so blocking it cuts the asker off. */ + current: Schema.Boolean, +}); +export type TailnetDevice = typeof TailnetDevice.Type; + +export const TailnetDeviceInput = Schema.Struct({ + stableNodeId: TrimmedNonEmptyString, +}); +export type TailnetDeviceInput = typeof TailnetDeviceInput.Type; + +export const TailnetDeviceList = Schema.Struct({ + devices: Schema.Array(TailnetDevice), +}); +export type TailnetDeviceList = typeof TailnetDeviceList.Type; + +/** A dotted IPv4 address in 100.64.0.0/10, where Tailscale assigns node addresses. */ +export const TailnetIpv4 = Schema.String.check( + Schema.isPattern( + /^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){2}$/u, + ), +); + +/** Another T3 Code environment on this host's tailnet. Holds no route or credential. */ +export const TailnetPeerEnvironment = Schema.Struct({ + name: TrimmedNonEmptyString, + magicDnsName: Schema.NullOr(TrimmedNonEmptyString), + tailnetIpv4: TailnetIpv4, + environmentId: EnvironmentId, +}); +export type TailnetPeerEnvironment = typeof TailnetPeerEnvironment.Type; + +export const TailnetPeerList = Schema.Struct({ + peers: Schema.Array(TailnetPeerEnvironment), +}); +export type TailnetPeerList = typeof TailnetPeerList.Type; + +export class TailnetAccessError extends Schema.TaggedError()( + "TailnetAccessError", + { + message: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, +) {} + +/** A machine's own Tailscale. `unreadable` means `tailscale status` failed in another way. */ +export const TailscaleState = Schema.Literals([ + "running", + "not_installed", + "not_running", + "needs_login", + "needs_machine_approval", + "stopped", + "starting", + "unreadable", +]); +export type TailscaleState = typeof TailscaleState.Type; + +/** Maps tailscaled's `BackendState`. `NoState` is a daemon that has only just started. */ +export function tailscaleStateFromBackend(backendState: string | null): TailscaleState { + switch (backendState) { + case "Running": + return "running"; + case "NeedsLogin": + return "needs_login"; + case "NeedsMachineAuth": + return "needs_machine_approval"; + case "Stopped": + return "stopped"; + case "Starting": + case "NoState": + return "starting"; + default: + return "unreadable"; + } +} diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index d1b90208443a3..6b863ca4fe604 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -1,23 +1,35 @@ +import * as NodeNet from "node:net"; + import { assert, describe, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Result from "effect/Result"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { + claimTailscaleServe, ensureTailscaleServe, parseTailscaleExitNode, parseTailscaleServeRoutesTo, parseTailscaleSshEnabled, + parseTailscaleProfiles, parseTailscaleStatus, + parseTailscaleWhois, + probeTailscaleHttpsEndpoint, readTailscaleSshEnabled, readTailscaleStatus, + readTailscaleWhois, + releaseTailscaleServe, TailscaleCommandExitError, TailscaleCommandSpawnError, TailscaleStatusParseError, + TailscaleWhoisParseError, } from "./tailscale.ts"; const encoder = new TextEncoder(); @@ -50,7 +62,55 @@ function assertCarriesNoSecret(error: object, secret: string): void { walk(error, "error"); } -const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20"]}}`; +const tailscaleStatusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.100.100.100","fd7a:115c:a1e0::1","192.168.1.20","100.64.0.1abc","100.064.0.1"]}}`; + +// 9007199254740993 is 2^53 + 1, which a JS number rounds to 2^53. +const fullStatusJson = `{ + "Version": "1.102.2-t6cac91817-g1", + "BackendState": "NeedsLogin", + "TUN": false, + "Health": ["Tailscale could not connect to the relay server."], + "CertDomains": ["desktop.tail.ts.net"], + "CurrentTailnet": { "Name": "theo@", "MagicDNSEnabled": true }, + "Self": { + "ID": "nSelf", + "UserID": 9007199254740993, + "DNSName": "desktop.tail.ts.net.", + "TailscaleIPs": ["100.100.100.100"], + "KeyExpiry": "2026-12-23T01:09:33Z", + "CapMap": { "https": null } + }, + "Peer": { + "nodekey:a": { + "ID": "nOwn", "HostName": "studio", "DNSName": "studio.tail.ts.net.", "OS": "linux", + "UserID": 9007199254740993, "TailscaleIPs": ["100.88.1.2", "fd7a:115c:a1e0::2"], + "Online": true, "Active": true, "CurAddr": "192.168.1.5:41641", "Relay": "sfo", + "PeerRelay": "", "LastSeen": "0001-01-01T00:00:00Z" + }, + "nodekey:b": { + "ID": "nTagged", "UserID": 123, "Tags": ["tag:server"], "Online": false, + "LastSeen": "2026-10-27T10:00:00Z", "Expired": true + }, + "nodekey:c": { "ID": "nShared", "UserID": 456, "AltSharerUserID": 789 }, + "nodekey:d": { "ID": "nSharee", "UserID": 457, "ShareeNode": true }, + "nodekey:e": { "HostName": "no-id" }, + "nodekey:f": { "ID": "nOdd", "HostName": 7, "Online": "yes" } + }, + "User": { + "9007199254740993": { "ID": 9007199254740993, "LoginName": "theo@example.com" }, + "123": { "ID": 123, "LoginName": "tagged-devices" } + } +}`; + +// Shaped like `tailscale whois --json` on Tailscale 1.102.2 for an untagged owner node. +const whoisJson = (node: string = "", top: string = "") => `{ + "Node": { + "ID": 42, "StableID": "nOwn", "Name": "studio.tail.ts.net.", "User": 9007199254740993, + "Addresses": ["100.88.1.2/32"], "AllowedIPs": ["100.88.1.2/32"], + "ComputedName": "studio", "Hostinfo": { "OS": "linux" }${node} + }, + "UserProfile": { "ID": 9007199254740993, "LoginName": "theo@example.com", "DisplayName": "Theo" }${top} +}`; function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) { return ChildProcessSpawner.makeHandle({ @@ -94,6 +154,65 @@ function layerMockSpawner( ); } +/** + * A Serve daemon whose root a `serve --bg` write replaces. `racer` is another + * server's target written just after each of ours. + */ +function layerServeFixture( + rootProxy: string | null, + listener: object = { HTTPS: true }, + extra: object = {}, + racer?: string, +) { + const writes: Array> = []; + let root = rootProxy; + const layer = layerMockSpawner((_command, args) => { + if (args.join(" ") === "serve status --json") { + return { + stdout: JSON.stringify({ + TCP: { "443": listener }, + Web: { + "desktop.tail.ts.net:443": { + Handlers: { + ...(root === null ? {} : { "/": { Proxy: root } }), + "/keep/": { Proxy: "http://127.0.0.1:8081" }, + }, + }, + }, + ...extra, + }), + }; + } + writes.push(args); + if (args.includes("--bg")) root = racer ?? args.at(-1)!; + return {}; + }); + return { layer, writes }; +} + +/** A loopback port with a listener for the rest of the scope. */ +const listenOnLoopback = Effect.acquireRelease( + Effect.callback((resume) => { + const server = NodeNet.createServer(); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + }), +).pipe( + Effect.map((server) => { + const address = server.address(); + return typeof address === "object" && address !== null ? address.port : 0; + }), +); + +const layerEnvironmentAnswer = (response: () => Response) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response()))), + ); + describe("tailscale", () => { it("finds the Serve routes that forward to the bound address, and only those", () => { const status = JSON.stringify({ @@ -194,13 +313,131 @@ describe("tailscale", () => { Effect.gen(function* () { const status = yield* parseTailscaleStatus(tailscaleStatusJson); assert.deepEqual(status, { + version: null, magicDnsName: "desktop.tail.ts.net", tailnetIpv4Addresses: ["100.100.100.100"], httpsServeEnabled: null, + backendState: null, + selfNodeId: null, + selfUserId: null, + selfTags: [], + keyExpiry: null, + keyExpired: false, + health: [], + magicDnsEnabled: null, + tailnetName: null, + certDomains: [], + userspaceNetworking: null, + peers: [], + unidentifiedPeers: [], + users: [], + }); + }), + ); + + // `tailscale switch --list --json`, as Tailscale 1.94 prints it. + it.effect("parses the selected login profile and every profile's ID", () => + Effect.gen(function* () { + const profiles = yield* parseTailscaleProfiles( + JSON.stringify([ + { + id: "a1b2", + nickname: "", + tailnet: "example.com", + account: "me@example.com", + selected: false, + }, + { + id: "c3d4", + nickname: "", + tailnet: "me@gmail.com", + account: "me@gmail.com", + selected: true, + }, + ]), + ); + assert.deepEqual(profiles, { + selected: { id: "c3d4", tailnet: "me@gmail.com", account: "me@gmail.com" }, + ids: ["a1b2", "c3d4"], }); }), ); + // A policy scan must still count it, or a granted peer beside it would be hidden. + it.effect("keeps the other peers when one cannot be read, and counts it unreadable", () => + Effect.gen(function* () { + const status = yield* parseTailscaleStatus( + JSON.stringify({ + BackendState: "Running", + Peer: { + "nodekey:a": { ID: "nLaptop", TailscaleIPs: ["100.88.1.3"] }, + "nodekey:b": null, + }, + }), + ); + assert.deepEqual( + status.peers.map((peer) => peer.stableNodeId), + ["nLaptop"], + ); + assert.deepEqual(status.unidentifiedPeers, [{ key: "nodekey:b", address: null }]); + }), + ); + + it.effect("parses host facts and peers, keeping user IDs past 2^53 exact", () => + Effect.gen(function* () { + const status = yield* parseTailscaleStatus(fullStatusJson); + assert.deepInclude(status, { + version: "1.102.2-t6cac91817-g1", + backendState: "NeedsLogin", + selfNodeId: "nSelf", + selfUserId: "9007199254740993", + selfTags: [], + keyExpiry: "2026-12-23T01:09:33Z", + health: ["Tailscale could not connect to the relay server."], + magicDnsEnabled: true, + tailnetName: "theo@", + certDomains: ["desktop.tail.ts.net"], + userspaceNetworking: true, + httpsServeEnabled: true, + users: [ + { userId: "123", loginName: "tagged-devices" }, + { userId: "9007199254740993", loginName: "theo@example.com" }, + ], + }); + assert.deepEqual( + status.peers.map((peer) => [peer.stableNodeId, peer.userId, peer.tags, peer.sharedIn]), + [ + ["nOwn", "9007199254740993", [], false], + ["nTagged", "123", ["tag:server"], false], + ["nShared", "456", [], true], + ["nSharee", "457", [], true], + ["nOdd", null, [], false], + ], + ); + assert.deepEqual(status.peers[0], { + stableNodeId: "nOwn", + hostName: "studio", + magicDnsName: "studio.tail.ts.net", + os: "linux", + tailnetIpv4Addresses: ["100.88.1.2"], + tailnetIpv6Addresses: ["fd7a:115c:a1e0::2"], + userId: "9007199254740993", + tags: [], + sharedIn: false, + online: true, + lastSeen: null, + keyExpired: false, + active: true, + curAddr: "192.168.1.5:41641", + relay: "sfo", + peerRelay: null, + }); + assert.deepInclude(status.peers[1], { online: false, keyExpired: true, active: false }); + // A field of an unexpected type reads as absent instead of failing the whole status. + assert.deepInclude(status.peers[4], { hostName: null, online: false }); + }), + ); + it.effect("names the exit node in use", () => Effect.gen(function* () { const inUse = yield* parseTailscaleExitNode( @@ -271,7 +508,10 @@ describe("tailscale", () => { ); return Effect.gen(function* () { - const statusError = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + const statusError = yield* readTailscaleStatus("passive").pipe( + Effect.flip, + Effect.provide(layer), + ); assert.instanceOf(statusError, TailscaleCommandSpawnError); assert.equal(statusError.subcommand, "status"); assert.strictEqual(statusError.cause, defect); @@ -284,7 +524,7 @@ describe("tailscale", () => { assert.equal(serveError.subcommand, "serve"); assert.strictEqual(serveError.cause, defect); - const degraded = yield* readTailscaleStatus.pipe( + const degraded = yield* readTailscaleStatus("passive").pipe( Effect.orElseSucceed(() => null), Effect.provide(layer), ); @@ -299,7 +539,7 @@ describe("tailscale", () => { })); return Effect.gen(function* () { - const error = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + const error = yield* readTailscaleStatus("passive").pipe(Effect.flip, Effect.provide(layer)); assert.instanceOf(error, TailscaleCommandExitError); assert.equal(error.executable, "tailscale"); @@ -336,9 +576,311 @@ describe("tailscale", () => { ); return Effect.gen(function* () { - yield* readTailscaleStatus.pipe(Effect.provide(layer)); + yield* readTailscaleStatus("passive").pipe(Effect.provide(layer)); yield* readTailscaleSshEnabled.pipe(Effect.provide(layer)); assert.deepEqual(killGraces, [500, 500]); }); }); + + it.effect("reads whois facts and treats omitted optional fields as empty", () => { + const calls: Array> = []; + const layer = layerMockSpawner((_command, args) => { + calls.push(args); + return { stdout: whoisJson() }; + }); + + return Effect.gen(function* () { + assert.deepEqual(yield* readTailscaleWhois("100.88.1.2").pipe(Effect.provide(layer)), { + stableNodeId: "nOwn", + userId: "9007199254740993", + addresses: ["100.88.1.2/32"], + allowedIps: ["100.88.1.2/32"], + tags: [], + primaryRoutes: [], + sharerUserId: null, + computedName: "studio", + os: "linux", + loginName: "theo@example.com", + displayName: "Theo", + capabilities: {}, + }); + assert.deepEqual(calls, [["whois", "--json", "--", "100.88.1.2"]]); + + const granted = yield* parseTailscaleWhois( + whoisJson( + "", + `, "CapMap": { "t3.codes/cap/t3code": [{ "scopes": ["read"] }], "x/cap/y": null }`, + ), + ); + assert.deepEqual(granted.capabilities, { + "t3.codes/cap/t3code": [{ scopes: ["read"] }], + "x/cap/y": [], + }); + + const routerShared = yield* parseTailscaleWhois( + whoisJson(`, "Tags": ["tag:server"], "PrimaryRoutes": ["10.0.0.0/24"], "Sharer": 77`), + ); + assert.deepInclude(routerShared, { + tags: ["tag:server"], + primaryRoutes: ["10.0.0.0/24"], + sharerUserId: "77", + }); + }); + }); + + it.effect("refuses whois output missing a required node field or with a malformed one", () => + Effect.gen(function* () { + const node = (JSON.parse(whoisJson()) as { Node: Record }).Node; + for (const field of ["StableID", "User", "Addresses", "AllowedIPs"]) { + const { [field]: _removed, ...rest } = node; + const error = yield* parseTailscaleWhois(JSON.stringify({ Node: rest })).pipe(Effect.flip); + assert.instanceOf(error, TailscaleWhoisParseError, field); + } + // Reading a malformed Tags as "no tags" would pass a tagged node as untagged. + const malformedTags = yield* parseTailscaleWhois(whoisJson(`, "Tags": "tag:server"`)).pipe( + Effect.flip, + ); + assert.instanceOf(malformedTags, TailscaleWhoisParseError); + // Grants decide access, so a malformed CapMap fails rather than reads as no grants. + const malformedCapMap = yield* parseTailscaleWhois(whoisJson("", `, "CapMap": []`)).pipe( + Effect.flip, + ); + assert.instanceOf(malformedCapMap, TailscaleWhoisParseError); + }), + ); + + it.effect("finds the macOS CLI outside PATH and keeps paths out of errors", () => { + const appBundle = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; + const attempt = ( + platform: NodeJS.Platform, + installedAt: string | null, + use: "passive" | "active" = "active", + ) => { + const tried: Array<{ readonly command: string; readonly beCli: string | undefined }> = []; + const layer = Layer.merge( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (command._tag !== "StandardCommand") return Effect.die("unexpected pipeline"); + tried.push({ + command: command.command, + beCli: command.options.env?.["TS_BE_CLI"], + }); + return command.command === installedAt + ? Effect.succeed(mockHandle({ stdout: tailscaleStatusJson })) + : Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: command.command, + }), + ); + }), + ), + Layer.succeed(HostProcess.Platform, platform), + ); + return readTailscaleStatus(use).pipe( + Effect.result, + Effect.map((result) => ({ result, tried })), + Effect.provide(layer), + ); + }; + + return Effect.gen(function* () { + const found = yield* attempt("darwin", appBundle); + assert.isTrue(Result.isSuccess(found.result)); + assert.deepEqual(found.tried, [ + { command: "tailscale", beCli: undefined }, + { command: "/usr/local/bin/tailscale", beCli: undefined }, + { command: "/opt/homebrew/bin/tailscale", beCli: undefined }, + { command: appBundle, beCli: "1" }, + ]); + // A passive read never spawns the app bundle, which can raise a macOS data-access prompt. + const passive = yield* attempt("darwin", appBundle, "passive"); + assert.isTrue(Result.isFailure(passive.result)); + assert.notInclude( + passive.tried.map((entry) => entry.command), + appBundle, + ); + + const missing = yield* attempt("darwin", null); + assert.isTrue(Result.isFailure(missing.result)); + if (Result.isFailure(missing.result)) { + const error = missing.result.failure; + assert.instanceOf(error, TailscaleCommandSpawnError); + assert.equal(error.executable, "tailscale"); + assert.notInclude(error.message, "/"); + } + + const linux = yield* attempt("linux", null); + assert.deepEqual( + linux.tried.map((entry) => entry.command), + ["tailscale"], + ); + }); + }); + + it.effect("counts a Serve URL as reachable only when it answers as this environment", () => + Effect.gen(function* () { + const probe = (response: () => Response) => + probeTailscaleHttpsEndpoint({ + baseUrl: "https://desktop.tail.ts.net/", + environmentId: "this-server", + }).pipe(Effect.provide(layerEnvironmentAnswer(response))); + + const descriptor = (environmentId: string) => + new Response(JSON.stringify({ environmentId, label: "desktop" })); + assert.isTrue(yield* probe(() => descriptor("this-server"))); + assert.isFalse(yield* probe(() => descriptor("other-server"))); + assert.isFalse(yield* probe(() => new Response("proxy"))); + assert.isFalse(yield* probe(() => new Response(null, { status: 502 }))); + }), + ); + + it.live("leaves a Serve root that fronts another running server in place", () => + Effect.gen(function* () { + const runningPort = yield* listenOnLoopback; + const fixture = layerServeFixture(`http://127.0.0.1:${runningPort}`); + + const claimed = yield* claimTailscaleServe({ localPort: 3774 }).pipe( + Effect.result, + Effect.provide(fixture.layer), + ); + assert.deepEqual(fixture.writes, []); + assert.isTrue( + Result.isFailure(claimed) && claimed.failure._tag === "TailscaleServeOccupiedError", + ); + }).pipe(Effect.scoped), + ); + + it.effect("leaves Funnel, plain HTTP, foreground and ambiguous hostname mappings untouched", () => + Effect.gen(function* () { + for (const [listener, extra] of [ + [{ HTTP: true }, {}], + [{ HTTPS: true }, { AllowFunnel: { "desktop.tail.ts.net:443": true } }], + [ + { HTTPS: true }, + { + Web: { + "old.tail.ts.net:443": { Handlers: { "/": { Proxy: "http://127.0.0.1:3773" } } }, + "desktop.tail.ts.net:443": { + Handlers: { "/": { Proxy: "http://another-server:3774" } }, + }, + }, + }, + ], + [ + { HTTPS: true }, + { + Foreground: { + session: { + TCP: { "443": { HTTPS: true } }, + Web: { + "desktop.tail.ts.net:443": { + Handlers: { "/": { Proxy: "http://127.0.0.1:3773" } }, + }, + }, + }, + }, + }, + ], + ] as const) { + const fixture = layerServeFixture(null, listener, extra); + const claimed = yield* claimTailscaleServe({ localPort: 3773 }).pipe( + Effect.result, + Effect.provide(fixture.layer), + ); + assert.isTrue( + Result.isFailure(claimed) && claimed.failure._tag === "TailscaleServeOccupiedError", + ); + assert.isFalse( + yield* releaseTailscaleServe({ localPort: 3773 }).pipe(Effect.provide(fixture.layer)), + ); + assert.deepEqual(fixture.writes, []); + } + }), + ); + + it.effect("claims a free Serve port and does not write when Serve status is unreadable", () => + Effect.gen(function* () { + for (const [stdout, claims] of [ + ["null\n", true], + ["{}", true], + ["", false], + ["not-json", false], + ] as const) { + const writes: Array> = []; + let status: string = stdout; + const layer = layerMockSpawner((_command, args) => { + if (args.join(" ") === "serve status --json") return { stdout: status }; + writes.push(args); + status = JSON.stringify({ + TCP: { "443": { HTTPS: true } }, + Web: { "desktop.tail.ts.net:443": { Handlers: { "/": { Proxy: args.at(-1) } } } }, + }); + return {}; + }); + const claimed = yield* claimTailscaleServe({ localPort: 3773 }).pipe( + Effect.result, + Effect.provide(layer), + ); + if (claims) { + assert.isTrue(Result.isSuccess(claimed), stdout); + assert.deepEqual(writes, [["serve", "--bg", "--https=443", "http://127.0.0.1:3773"]]); + } else { + assert.isTrue( + Result.isFailure(claimed) && claimed.failure._tag === "TailscaleStatusParseError", + ); + const released = yield* releaseTailscaleServe({ localPort: 3773 }).pipe( + Effect.flip, + Effect.provide(layer), + ); + assert.instanceOf(released, TailscaleStatusParseError); + assert.deepEqual(writes, []); + } + } + }), + ); + + it.live("replaces a Serve root left behind by a stopped server", () => + Effect.gen(function* () { + const stoppedPort = yield* Effect.scoped(listenOnLoopback); + const fixture = layerServeFixture(`http://127.0.0.1:${stoppedPort}`); + + yield* claimTailscaleServe({ localPort: 3773 }).pipe(Effect.provide(fixture.layer)); + assert.deepEqual(fixture.writes, [["serve", "--bg", "--https=443", "http://127.0.0.1:3773"]]); + }), + ); + + it.effect("reports a Serve port another server wrote at the same time as occupied", () => + Effect.gen(function* () { + const fixture = layerServeFixture(null, { HTTPS: true }, {}, "http://127.0.0.1:3774"); + const claimed = yield* claimTailscaleServe({ localPort: 3773 }).pipe( + Effect.result, + Effect.provide(fixture.layer), + ); + assert.isTrue( + Result.isFailure(claimed) && claimed.failure._tag === "TailscaleServeOccupiedError", + ); + }), + ); + + it.effect("removes only its own Serve root on release", () => { + const fixture = layerServeFixture("http://127.0.0.1:3773"); + + return Effect.gen(function* () { + const releasedOther = yield* releaseTailscaleServe({ localPort: 3774 }).pipe( + Effect.provide(fixture.layer), + ); + assert.deepEqual(fixture.writes, []); + assert.isFalse(releasedOther); + + const releasedOwn = yield* releaseTailscaleServe({ localPort: 3773 }).pipe( + Effect.provide(fixture.layer), + ); + assert.deepEqual(fixture.writes, [["serve", "--https=443", "--set-path=/", "off"]]); + assert.isTrue(releasedOwn); + }); + }); }); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index cb5748d21e7b0..18d4d35a745f2 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,11 +1,13 @@ +import * as NodeNet from "node:net"; + import * as HostProcess from "@t3tools/shared/HostProcess"; -import { normalizeHostname } from "@t3tools/shared/hostClassification"; +import { isSharedAddressSpaceHost, normalizeHostname } from "@t3tools/shared/hostClassification"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { HttpClient, HttpClientRequest } from "effect/http"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; @@ -26,15 +28,32 @@ const TAILSCALE_KILL_GRACE = Duration.millis(500); const TAILSCALE_SERVE_TIMEOUT = Duration.seconds(10); const TAILSCALE_PROBE_TIMEOUT = Duration.millis(2_500); +/** + * macOS GUI apps start with a minimal PATH, and the App Store build ships its + * CLI only inside the app bundle. Tailscale documents aliasing the bundle + * binary as `tailscale`. + */ +const DARWIN_CLI_PATHS = ["/usr/local/bin/tailscale", "/opt/homebrew/bin/tailscale"]; +const DARWIN_APP_BUNDLE_CLI = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; + +/** + * Passive reads run without the user turning on a Tailscale feature or + * command, so they never spawn the macOS app bundle: that can raise a + * data-access prompt. + */ +export type TailscaleCliUse = "passive" | "active"; + const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | "tailscale.exe" => platform === "win32" ? "tailscale.exe" : "tailscale"; const TailscaleCommandContext = { executable: Schema.Literals(["tailscale", "tailscale.exe"]), - subcommand: Schema.Literals(["status", "serve", "debug"]), + subcommand: Schema.Literals(["status", "serve", "debug", "whois", "switch"]), argumentCount: Schema.Number, }; +type TailscaleSubcommand = typeof TailscaleCommandContext.subcommand.Type; + /** * Failure kinds we can name without quoting the CLI. Anything unrecognized * becomes "unknown" rather than falling back to raw text — stderr can contain @@ -137,19 +156,124 @@ export class TailscaleStatusParseError extends Schema.TaggedError()( + "TailscaleWhoisParseError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to decode tailscale whois JSON."; + } +} + +/** Another server or app holds the Serve port, so T3 Code leaves it alone. */ +export class TailscaleServeOccupiedError extends Schema.TaggedError()( + "TailscaleServeOccupiedError", + { servePort: Schema.Number }, +) { + override get message(): string { + return `Tailscale Serve HTTPS port ${this.servePort} is in use by another server; leaving it in place.`; + } +} + +/** Go omits empty fields, and one field of an unexpected type must not hide the rest. */ +const lenient = (schema: S) => + Schema.optional( + Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeedSome(undefined))), + ); + +// Tailscale user IDs are int64; keep the digits so IDs past 2^53 still compare exactly. +const TAILSCALE_INT64_KEYS = new Set(["UserID", "User", "Sharer", "AltSharerUserID", "ID"]); + +const parseTailscaleJson = (raw: string): unknown => + JSON.parse(raw, (key, value: unknown, context?: { readonly source?: string }) => + typeof value === "number" && TAILSCALE_INT64_KEYS.has(key) + ? (context?.source ?? (Number.isSafeInteger(value) ? String(value) : undefined)) + : value, + ); + +const decodeTailscaleJson = ( + schema: S, + onError: (cause: unknown) => E, +) => { + const decode = Schema.decodeUnknownEffect(schema); + return (raw: string): Effect.Effect => + Effect.try({ try: () => parseTailscaleJson(raw), catch: onError }).pipe( + Effect.flatMap((json) => decode(json).pipe(Effect.mapError(onError))), + ); +}; + +const TailscaleStatusNodeFields = { + ID: lenient(Schema.String), + HostName: lenient(Schema.String), + DNSName: lenient(Schema.String), + OS: lenient(Schema.String), + UserID: lenient(Schema.String), + TailscaleIPs: lenient(Schema.Array(Schema.String)), + Tags: lenient(Schema.Array(Schema.String)), + KeyExpiry: lenient(Schema.String), + Expired: lenient(Schema.Boolean), +}; + +const TailscaleStatusPeerJson = Schema.Struct({ + ...TailscaleStatusNodeFields, + Online: lenient(Schema.Boolean), + LastSeen: lenient(Schema.String), + AltSharerUserID: lenient(Schema.String), + ShareeNode: lenient(Schema.Boolean), + CurAddr: lenient(Schema.String), + Relay: lenient(Schema.String), + PeerRelay: lenient(Schema.String), + Active: lenient(Schema.Boolean), }); +const decodeTailscaleStatusPeer = Schema.decodeUnknownOption(TailscaleStatusPeerJson); const TailscaleStatusJson = Schema.Struct({ - Self: Schema.optional(TailscaleStatusSelf), + Version: lenient(Schema.String), + BackendState: lenient(Schema.String), + TUN: lenient(Schema.Boolean), + // Keyed by user ID; JSON keys are strings, so the IDs stay exact. + User: lenient(Schema.Record(Schema.String, Schema.Struct({ LoginName: lenient(Schema.String) }))), + Health: lenient(Schema.Array(Schema.String)), + CertDomains: lenient(Schema.Array(Schema.String)), + CurrentTailnet: lenient( + Schema.Struct({ Name: lenient(Schema.String), MagicDNSEnabled: lenient(Schema.Boolean) }), + ), + Self: lenient( + Schema.Struct({ + ...TailscaleStatusNodeFields, + CapMap: lenient(Schema.Record(Schema.String, Schema.Unknown)), + }), + ), + // Read per peer below, so one malformed peer cannot hide the rest. + Peer: Schema.optional(Schema.Unknown), }); -export type TailscaleStatusJson = typeof TailscaleStatusJson.Type; +export interface TailscalePeer { + readonly stableNodeId: string; + readonly hostName: string | null; + readonly magicDnsName: string | null; + readonly os: string | null; + readonly tailnetIpv4Addresses: readonly string[]; + /** For `whois` on a peer with no IPv4 address. */ + readonly tailnetIpv6Addresses: readonly string[]; + readonly userId: string | null; + readonly tags: readonly string[]; + /** Shared in from another tailnet, or owned by a user this tailnet shares to. */ + readonly sharedIn: boolean; + /** Connected to the control plane. A hint, not proof this device can reach it. */ + readonly online: boolean; + readonly lastSeen: string | null; + readonly keyExpired: boolean; + /** The path is direct (`curAddr`), DERP (`relay`) or a peer relay only while `active`. */ + readonly active: boolean; + readonly curAddr: string | null; + readonly relay: string | null; + readonly peerRelay: string | null; +} export interface TailscaleStatus { + /** The daemon's version, such as `1.102.2-t6cac91817-g…`. */ + readonly version: string | null; readonly magicDnsName: string | null; readonly tailnetIpv4Addresses: readonly string[]; /** @@ -158,9 +282,38 @@ export interface TailscaleStatus { * CLI reports no capability map (older CLI), so callers still attempt serve. */ readonly httpsServeEnabled: boolean | null; + /** `Running`, `NeedsLogin`, `NeedsMachineAuth`, `Stopped`, `Starting` or `NoState`. */ + readonly backendState: string | null; + readonly selfNodeId: string | null; + readonly selfUserId: string | null; + /** Non-empty on a tagged node, which has no user identity. */ + readonly selfTags: readonly string[]; + readonly keyExpiry: string | null; + readonly keyExpired: boolean; + readonly health: readonly string[]; + readonly magicDnsEnabled: boolean | null; + /** The tailnet this node is on, such as `example.com` or `you@gmail.com`. */ + readonly tailnetName: string | null; + readonly certDomains: readonly string[]; + /** + * Userspace networking hands tailnet connections to the server from + * loopback, so `whois` cannot name the peer. `null` on older CLIs. + */ + readonly userspaceNetworking: boolean | null; + readonly peers: readonly TailscalePeer[]; + /** + * Each peer with no ID: its key in the status (its node key) and first tailnet address, or + * null with none. It cannot be tracked, but a scan of the tailnet policy must still read it. + */ + readonly unidentifiedPeers: ReadonlyArray<{ + readonly key: string; + readonly address: string | null; + }>; + /** Users this node can see, by user ID. */ + readonly users: ReadonlyArray<{ readonly userId: string; readonly loginName: string | null }>; } -const collectStdout = (stream: Stream.Stream): Effect.Effect => +const collectOutput = (stream: Stream.Stream): Effect.Effect => stream.pipe( Stream.decodeText(), Stream.runFold( @@ -169,70 +322,127 @@ const collectStdout = (stream: Stream.Stream): Effect.Effect 0 ? normalized : null; +} -const decodeTailscaleStatusJson = Schema.decodeEffect(Schema.fromJsonString(TailscaleStatusJson)); +const nonEmptyOrNull = (value: string | undefined): string | null => + value !== undefined && value.trim().length > 0 ? value : null; -function normalizeMagicDnsName(status: TailscaleStatusJson): string | null { - const dnsName = status.Self?.DNSName; - if (typeof dnsName !== "string") { - return null; - } +// Go writes an unset `time.Time` as its zero value rather than omitting it. +const timeOrNull = (value: string | undefined): string | null => + value === undefined || value.startsWith("0001-01-01") ? null : value; - const normalized = dnsName.trim().replace(/\.$/u, ""); - return normalized.length > 0 ? normalized : null; -} +/** A dotted IPv4 address in 100.64/10, where Tailscale assigns from. Other VPNs share the range. */ +export const isTailscaleIpv4Address = (address: string): boolean => + NodeNet.isIPv4(address) && isSharedAddressSpaceHost(address); -export function isTailscaleIpv4Address(address: string): boolean { - const parts = address.split("."); - if (parts.length !== 4) { - return false; - } - const [first, second, third, fourth] = parts.map((part) => Number.parseInt(part, 10)); - if ( - first === undefined || - second === undefined || - third === undefined || - fourth === undefined || - [first, second, third, fourth].some((part) => !Number.isInteger(part) || part < 0 || part > 255) - ) { - return false; - } - return first === 100 && second >= 64 && second <= 127; -} +const tailnetIpv4AddressesOf = (addresses: ReadonlyArray | undefined) => + (addresses ?? []).filter(isTailscaleIpv4Address); + +// Tailscale assigns IPv6 addresses from fd7a:115c:a1e0::/48. +const tailnetIpv6AddressesOf = (addresses: ReadonlyArray | undefined) => + (addresses ?? []).filter( + (address) => NodeNet.isIPv6(address) && address.toLowerCase().startsWith("fd7a:115c:a1e0:"), + ); + +const decodeTailscaleStatusJson = decodeTailscaleJson( + TailscaleStatusJson, + (cause) => new TailscaleStatusParseError({ cause }), +); export const parseTailscaleStatus = ( rawStatusJson: string, ): Effect.Effect => decodeTailscaleStatusJson(rawStatusJson).pipe( - Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), - Effect.map((parsed) => { - const rawIps = parsed.Self?.TailscaleIPs; - const tailnetIpv4Addresses: Array = []; - if (Array.isArray(rawIps)) { - for (const address of rawIps) { - if (typeof address === "string" && isTailscaleIpv4Address(address)) { - tailnetIpv4Addresses.push(address); - } - } - } - - const capMap = parsed.Self?.CapMap; + Effect.map((parsed): TailscaleStatus => { + const self = parsed.Self; + const rawPeers: ReadonlyArray = + parsed.Peer === undefined || parsed.Peer === null + ? [] + : typeof parsed.Peer === "object" && !Array.isArray(parsed.Peer) + ? Object.entries(parsed.Peer) + : [["", parsed.Peer]]; + const decodedPeers = rawPeers.map( + ([key, peer]) => [key, decodeTailscaleStatusPeer(peer)] as const, + ); + const peerEntries = decodedPeers.flatMap(([, peer]) => Option.toArray(peer)); + const unidentifiedPeers = decodedPeers.flatMap(([key, peer]) => + // A peer that cannot be read has no ID or address to look up, so a scan counts it unreadable. + Option.isNone(peer) + ? [{ key, address: null }] + : nonEmptyOrNull(peer.value.ID) === null + ? [ + { + key, + address: + tailnetIpv4AddressesOf(peer.value.TailscaleIPs)[0] ?? + tailnetIpv6AddressesOf(peer.value.TailscaleIPs)[0] ?? + null, + }, + ] + : [], + ); + const peers = peerEntries.flatMap((peer): TailscalePeer[] => { + const stableNodeId = nonEmptyOrNull(peer.ID); + if (stableNodeId === null) return []; + return [ + { + stableNodeId, + hostName: nonEmptyOrNull(peer.HostName), + magicDnsName: normalizeMagicDnsName(peer.DNSName), + os: nonEmptyOrNull(peer.OS), + tailnetIpv4Addresses: tailnetIpv4AddressesOf(peer.TailscaleIPs), + tailnetIpv6Addresses: tailnetIpv6AddressesOf(peer.TailscaleIPs), + userId: nonEmptyOrNull(peer.UserID), + tags: peer.Tags ?? [], + sharedIn: + (peer.AltSharerUserID !== undefined && peer.AltSharerUserID !== "0") || + peer.ShareeNode === true, + online: peer.Online === true, + lastSeen: timeOrNull(peer.LastSeen), + keyExpired: peer.Expired === true, + active: peer.Active === true, + curAddr: nonEmptyOrNull(peer.CurAddr), + relay: nonEmptyOrNull(peer.Relay), + peerRelay: nonEmptyOrNull(peer.PeerRelay), + }, + ]; + }); return { - magicDnsName: normalizeMagicDnsName(parsed), - tailnetIpv4Addresses, - httpsServeEnabled: - typeof capMap === "object" && capMap !== null && !Array.isArray(capMap) - ? Object.hasOwn(capMap, "https") - : null, + version: nonEmptyOrNull(parsed.Version), + magicDnsName: normalizeMagicDnsName(self?.DNSName), + tailnetIpv4Addresses: tailnetIpv4AddressesOf(self?.TailscaleIPs), + httpsServeEnabled: self?.CapMap === undefined ? null : Object.hasOwn(self.CapMap, "https"), + backendState: nonEmptyOrNull(parsed.BackendState), + selfNodeId: nonEmptyOrNull(self?.ID), + selfUserId: nonEmptyOrNull(self?.UserID), + selfTags: self?.Tags ?? [], + keyExpiry: timeOrNull(self?.KeyExpiry), + keyExpired: self?.Expired === true, + health: parsed.Health ?? [], + magicDnsEnabled: parsed.CurrentTailnet?.MagicDNSEnabled ?? null, + tailnetName: nonEmptyOrNull(parsed.CurrentTailnet?.Name), + certDomains: parsed.CertDomains ?? [], + userspaceNetworking: parsed.TUN === undefined ? null : !parsed.TUN, + peers, + unidentifiedPeers, + users: Object.entries(parsed.User ?? {}).map(([userId, user]) => ({ + userId, + loginName: nonEmptyOrNull(user.LoginName), + })), }; }), ); -const readTailscaleOutput = Effect.fn("tailscale.readTailscaleOutput")(function* ( - subcommand: "status" | "serve" | "debug", +/** Spawns the CLI and returns its stdout. Errors carry lengths and a diagnostic, never output. */ +const runTailscale = Effect.fn("tailscale.runTailscale")(function* ( + subcommand: TailscaleSubcommand, args: readonly string[], -) { + timeout: Duration.Duration, + use: TailscaleCliUse, +): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const hostPlatform = yield* HostProcess.Platform; const executable = tailscaleCommandForPlatform(hostPlatform); @@ -241,11 +451,32 @@ const readTailscaleOutput = Effect.fn("tailscale.readTailscaleOutput")(function* subcommand, argumentCount: args.length, }; - const command = ChildProcess.make(executable, [...args], { - forceKillAfter: TAILSCALE_KILL_GRACE, - }); + const spawnAt = (path: string) => + spawner.spawn( + ChildProcess.make(path, [...args], { + forceKillAfter: TAILSCALE_KILL_GRACE, + // tailscaled reads TS_BE_CLI to act as the CLI (cmd/tailscaled/tailscaled.go). + ...(path === DARWIN_APP_BUNDLE_CLI ? { env: { TS_BE_CLI: "1" }, extendEnv: true } : {}), + }), + ); + const fallbacks = + hostPlatform !== "darwin" + ? [] + : use === "active" + ? [...DARWIN_CLI_PATHS, DARWIN_APP_BUNDLE_CLI] + : DARWIN_CLI_PATHS; + const spawnFirstInstalled = fallbacks.reduce( + (attempt, path) => + attempt.pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => spawnAt(path), + ), + ), + spawnAt(executable), + ); return yield* Effect.gen(function* () { - const child = yield* spawner.spawn(command).pipe( + const child = yield* spawnFirstInstalled.pipe( Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), // Spawning can also fail as a defect rather than a typed error - a // non-directory entry on PATH makes node throw ENOTDIR synchronously. @@ -256,8 +487,8 @@ const readTailscaleOutput = Effect.fn("tailscale.readTailscaleOutput")(function* ); const [stdout, stderr, exitCode] = yield* Effect.all( [ - collectStdout(child.stdout), - collectStderr(child.stderr), + collectOutput(child.stdout), + collectOutput(child.stderr), child.exitCode.pipe(Effect.map(Number)), ], { concurrency: "unbounded" }, @@ -265,26 +496,25 @@ const readTailscaleOutput = Effect.fn("tailscale.readTailscaleOutput")(function* Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), ); if (exitCode !== 0) { + const stderrDiagnostic = stderrDiagnosticOf(stderr); return yield* new TailscaleCommandExitError({ ...commandContext, exitCode, stdoutLength: stdout.length, stderrLength: stderr.length, - ...(stderrDiagnosticOf(stderr) !== undefined - ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } - : {}), + ...(stderrDiagnostic !== undefined ? { stderrDiagnostic } : {}), }); } return stdout; }).pipe( Effect.scoped, - Effect.timeout(TAILSCALE_STATUS_TIMEOUT), + Effect.timeout(timeout), Effect.catchTags({ TimeoutError: (cause) => Effect.fail( new TailscaleCommandTimeoutError({ ...commandContext, - timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), + timeoutMs: Duration.toMillis(timeout), cause, }), ), @@ -292,9 +522,33 @@ const readTailscaleOutput = Effect.fn("tailscale.readTailscaleOutput")(function* ); }); -export const readTailscaleStatus = readTailscaleOutput("status", ["status", "--json"]).pipe( - Effect.flatMap(parseTailscaleStatus), -); +const readTailscaleStatusOutput = (use: TailscaleCliUse) => + runTailscale("status", ["status", "--json"], TAILSCALE_STATUS_TIMEOUT, use); + +export const readTailscaleStatus = (use: TailscaleCliUse) => + readTailscaleStatusOutput(use).pipe(Effect.flatMap(parseTailscaleStatus)); + +/** What a failed `tailscale status` read says about Tailscale on this machine. */ +export const tailscaleStateOfReadError = ( + error: TailscaleCommandError | TailscaleStatusParseError, +): "not_installed" | "not_running" | "needs_login" | "unreadable" => { + switch (error._tag) { + case "TailscaleCommandSpawnError": + return "not_installed"; + case "TailscaleCommandExitError": + // The CLI exits non-zero mostly when it cannot reach tailscaled. + switch (error.stderrDiagnostic) { + case "not-logged-in": + return "needs_login"; + case "permission-denied": + return "unreadable"; + default: + return "not_running"; + } + default: + return "unreadable"; + } +}; const TailscaleExitNodeJson = Schema.Struct({ ExitNodeStatus: Schema.optional(Schema.Struct({ Online: Schema.optional(Schema.Unknown) })), @@ -343,7 +597,7 @@ export const parseTailscaleExitNode = ( }), ); -export const readTailscaleExitNode = readTailscaleOutput("status", ["status", "--json"]).pipe( +export const readTailscaleExitNode = readTailscaleStatusOutput("passive").pipe( Effect.flatMap(parseTailscaleExitNode), ); @@ -366,9 +620,142 @@ export const parseTailscaleSshEnabled = ( Effect.map((prefs) => prefs.RunSSH === true && prefs.WantRunning !== false), ); -export const readTailscaleSshEnabled = readTailscaleOutput("debug", ["debug", "prefs"]).pipe( - Effect.flatMap(parseTailscaleSshEnabled), +export const readTailscaleSshEnabled = runTailscale( + "debug", + ["debug", "prefs"], + TAILSCALE_STATUS_TIMEOUT, + "passive", +).pipe(Effect.flatMap(parseTailscaleSshEnabled)); + +// The node fields that decide trust are strict: missing required ones, or a +// wrong type on an optional one, fail rather than read as empty. +const TailscaleWhoisJson = Schema.Struct({ + Node: Schema.Struct({ + StableID: Schema.NonEmptyString, + User: Schema.NonEmptyString, + Addresses: Schema.Array(Schema.String), + AllowedIPs: Schema.Array(Schema.String), + Tags: Schema.optional(Schema.Array(Schema.String)), + PrimaryRoutes: Schema.optional(Schema.Array(Schema.String)), + Sharer: Schema.optional(Schema.String), + ComputedName: lenient(Schema.String), + Hostinfo: lenient(Schema.Struct({ OS: lenient(Schema.String) })), + }), + UserProfile: lenient( + Schema.Struct({ LoginName: lenient(Schema.String), DisplayName: lenient(Schema.String) }), + ), + // The app capabilities tailnet grants give this peer toward this node, by name. + CapMap: Schema.optional( + Schema.NullOr(Schema.Record(Schema.String, Schema.NullOr(Schema.Array(Schema.Unknown)))), + ), +}); + +export interface TailscaleWhois { + readonly stableNodeId: string; + readonly userId: string; + /** CIDRs, such as `100.64.1.2/32`. */ + readonly addresses: readonly string[]; + /** Wider than `addresses` on a subnet router or exit node. */ + readonly allowedIps: readonly string[]; + readonly tags: readonly string[]; + readonly primaryRoutes: readonly string[]; + /** Set when the node is shared in from another tailnet. */ + readonly sharerUserId: string | null; + readonly computedName: string | null; + readonly os: string | null; + readonly loginName: string | null; + readonly displayName: string | null; + /** Each granted app capability's values, one per matching grant. */ + readonly capabilities: Readonly>>; +} + +const decodeTailscaleWhoisJson = decodeTailscaleJson( + TailscaleWhoisJson, + (cause) => new TailscaleWhoisParseError({ cause }), +); + +/** Tailscale omits empty fields, so a missing `Tags`, `PrimaryRoutes` or `Sharer` means none. */ +export const parseTailscaleWhois = ( + rawWhoisJson: string, +): Effect.Effect => + decodeTailscaleWhoisJson(rawWhoisJson).pipe( + Effect.map(({ Node: node, UserProfile: profile, CapMap: capMap }) => ({ + stableNodeId: node.StableID, + userId: node.User, + addresses: node.Addresses, + allowedIps: node.AllowedIPs, + tags: node.Tags ?? [], + primaryRoutes: node.PrimaryRoutes ?? [], + sharerUserId: node.Sharer === undefined || node.Sharer === "0" ? null : node.Sharer, + computedName: nonEmptyOrNull(node.ComputedName), + os: nonEmptyOrNull(node.Hostinfo?.OS), + loginName: nonEmptyOrNull(profile?.LoginName), + displayName: nonEmptyOrNull(profile?.DisplayName), + capabilities: Object.fromEntries( + Object.entries(capMap ?? {}).map(([name, values]) => [name, values ?? []]), + ), + })), + ); + +/** Asks the local daemon which node and user own a tailnet address. Only sign-in and `t3 up` ask. */ +export const readTailscaleWhois = Effect.fn("tailscale.readTailscaleWhois")(function* ( + address: string, +) { + // `--` keeps an address that starts with `-` from reading as a flag. + const stdout = yield* runTailscale( + "whois", + ["whois", "--json", "--", address], + TAILSCALE_STATUS_TIMEOUT, + "active", + ); + return yield* parseTailscaleWhois(stdout); +}); + +const TailscaleProfilesJson = Schema.fromJsonString( + Schema.Array( + Schema.Struct({ + id: Schema.String, + tailnet: lenient(Schema.String), + account: lenient(Schema.String), + selected: lenient(Schema.Boolean), + }), + ), ); +const decodeTailscaleProfiles = Schema.decodeUnknownEffect(TailscaleProfilesJson); + +/** `tailscale switch --list --json`: the selected login profile, and every profile's ID. */ +export const parseTailscaleProfiles = (stdout: string) => + decodeTailscaleProfiles(stdout).pipe( + Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), + Effect.map((profiles) => { + const selected = profiles.find((profile) => profile.selected === true); + return { + selected: + selected === undefined + ? null + : { + id: selected.id, + tailnet: selected.tailnet ?? null, + account: selected.account ?? null, + }, + ids: profiles.map((profile) => profile.id), + }; + }), + ); + +/** + * The login profiles Tailscale has: one account on one tailnet each. Tailscale keeps Serve + * config per profile and deletes it with the profile on `tailscale logout`. + */ +export const readTailscaleProfiles = Effect.fn("tailscale.readTailscaleProfiles")(function* () { + const stdout = yield* runTailscale( + "switch", + ["switch", "--list", "--json"], + TAILSCALE_STATUS_TIMEOUT, + "active", + ); + return yield* parseTailscaleProfiles(stdout); +}); export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; @@ -383,67 +770,21 @@ export function buildTailscaleHttpsBaseUrl(input: { return url.toString(); } -const runTailscaleCommand = Effect.fn("tailscale.runTailscaleCommand")(function* ( - args: readonly string[], - timeoutInput: Duration.Input, -): Effect.fn.Return { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcess.Platform; - const executable = tailscaleCommandForPlatform(hostPlatform); - const commandContext = { - executable, - subcommand: "serve" as const, - argumentCount: args.length, - }; - const timeout = Duration.fromInputUnsafe(timeoutInput); - return yield* Effect.gen(function* () { - const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), - Effect.catchDefect((cause) => - Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), - ), - ); - const [stderr, exitCode] = yield* Effect.all( - [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), - ); - if (exitCode !== 0) { - return yield* new TailscaleCommandExitError({ - ...commandContext, - exitCode, - stderrLength: stderr.length, - ...(stderrDiagnosticOf(stderr) !== undefined - ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } - : {}), - }); - } - }).pipe( - Effect.scoped, - Effect.timeout(timeout), - Effect.catchTags({ - TimeoutError: (cause) => - Effect.fail( - new TailscaleCommandTimeoutError({ - ...commandContext, - timeoutMs: Duration.toMillis(timeout), - cause, - }), - ), - }), - ); -}); - -export const ensureTailscaleServe = (input: { +interface TailscaleServeTarget { readonly localPort: number; readonly servePort?: number; readonly localHost?: string; -}): Effect.Effect => { +} + +const serveProxyTarget = (input: TailscaleServeTarget): string => + `http://${input.localHost ?? "127.0.0.1"}:${input.localPort}`; + +export const ensureTailscaleServe = ( + input: TailscaleServeTarget, +): Effect.Effect => { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; - const localHost = input.localHost ?? "127.0.0.1"; - const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`]; - return runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT); + const args = ["serve", "--bg", `--https=${servePort}`, serveProxyTarget(input)]; + return runTailscale("serve", args, TAILSCALE_SERVE_TIMEOUT, "active").pipe(Effect.asVoid); }; /** A Serve route: a web handler at `mount` on `servePort`, or a TCP forward without one. */ @@ -484,22 +825,36 @@ export const disableTailscaleServe = Effect.fn("tailscale.disableTailscaleServe" readonly mount?: string; } = {}, ): Effect.fn.Return { - return yield* runTailscaleCommand( + yield* runTailscale( + "serve", tailscaleServeOffArgs({ servePort: input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT, mount: input.mount ?? "/", })!, TAILSCALE_SERVE_TIMEOUT, + "active", ); }); /** Removes a TCP forward on `servePort`; one port holds one forward, so nothing else goes. */ export const disableTailscaleServeTcp = (servePort: number) => - runTailscaleCommand(tailscaleServeOffArgs({ servePort })!, TAILSCALE_SERVE_TIMEOUT); + runTailscale( + "serve", + tailscaleServeOffArgs({ servePort })!, + TAILSCALE_SERVE_TIMEOUT, + "active", + ).pipe(Effect.asVoid); -const TailscaleServeConfigJson = Schema.Struct({ +const TailscaleServeConfigFields = { + AllowFunnel: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), TCP: Schema.optional( - Schema.Record(Schema.String, Schema.Struct({ TCPForward: Schema.optional(Schema.String) })), + Schema.Record( + Schema.String, + Schema.Struct({ + HTTPS: Schema.optional(Schema.Boolean), + TCPForward: Schema.optional(Schema.String), + }), + ), ), Web: Schema.optional( Schema.Record( @@ -511,12 +866,14 @@ const TailscaleServeConfigJson = Schema.Struct({ }), ), ), -}); +}; + +const TailscaleServeScopeJson = Schema.Struct(TailscaleServeConfigFields); const TailscaleServeStatusJson = Schema.Struct({ - ...TailscaleServeConfigJson.fields, + ...TailscaleServeConfigFields, // A Tailscale Service's routes, and those of each `tailscale serve` still running in the foreground. - Services: Schema.optional(Schema.Record(Schema.String, TailscaleServeConfigJson)), - Foreground: Schema.optional(Schema.Record(Schema.String, TailscaleServeConfigJson)), + Services: Schema.optional(Schema.Record(Schema.String, TailscaleServeScopeJson)), + Foreground: Schema.optional(Schema.Record(Schema.String, TailscaleServeScopeJson)), }); // A node without any Serve configuration prints `null`. const decodeTailscaleServeStatusJson = Schema.decodeUnknownOption( @@ -593,7 +950,7 @@ export const parseTailscaleServeRoutesTo = ( const status = decodeTailscaleServeStatusJson(rawServeStatusJson); if (Option.isNone(status)) return undefined; const routesIn = ( - config: typeof TailscaleServeConfigJson.Type | undefined, + config: typeof TailscaleServeScopeJson.Type | undefined, owner?: string, ): ReadonlyArray => { const scope = owner === undefined ? {} : { owner }; @@ -639,7 +996,7 @@ export const readTailscaleServeRoutesTo = (target: { readonly host: string; readonly port: number; }) => - readTailscaleOutput("serve", ["serve", "status", "--json"]).pipe( + runTailscale("serve", ["serve", "status", "--json"], TAILSCALE_STATUS_TIMEOUT, "passive").pipe( Effect.flatMap((status) => { const routes = parseTailscaleServeRoutesTo(status, target); return routes === undefined @@ -648,19 +1005,180 @@ export const readTailscaleServeRoutesTo = (target: { }), ); -export const probeTailscaleHttpsEndpoint = Effect.fn("tailscale.probeTailscaleHttpsEndpoint")( +// The CLI prints `null` when nothing is served. +const TailscaleServeConfigJson = Schema.NullOr( + Schema.Struct({ + ...TailscaleServeConfigFields, + Foreground: Schema.optional( + Schema.Record(Schema.String, Schema.Struct(TailscaleServeConfigFields)), + ), + }), +); + +type TailscaleServeConfig = NonNullable; + +const decodeTailscaleServeConfigJson = Schema.decodeEffect( + Schema.fromJsonString(TailscaleServeConfigJson), +); + +const readTailscaleServeConfig = runTailscale( + "serve", + ["serve", "status", "--json"], + TAILSCALE_STATUS_TIMEOUT, + "active", +).pipe( + Effect.flatMap((stdout) => + decodeTailscaleServeConfigJson(stdout).pipe( + Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), + ), + ), +); + +/** Who holds an HTTPS Serve port: nobody, a root proxy to `target`, or something T3 must not replace. */ +type TailscaleServePortHolder = + | { readonly _tag: "free" } + | { readonly _tag: "proxy"; readonly target: string } + | { readonly _tag: "other" }; + +const tailscaleServePortHolder = ( + config: TailscaleServeConfig | null, + servePort: number, +): TailscaleServePortHolder => { + if (config === null) return { _tag: "free" }; + for (const scope of [...Object.values(config.Foreground ?? {}), config]) { + // Serve clears Funnel on this port; foreground mappings belong to another CLI session. + if ( + Object.entries(scope.AllowFunnel ?? {}).some( + ([key, enabled]) => enabled && key.endsWith(`:${servePort}`), + ) + ) { + return { _tag: "other" }; + } + const listener = scope.TCP?.[String(servePort)]; + // `serve --https` switches the whole port to HTTPS, so a plain HTTP or TCP listener is taken. + if ( + listener !== undefined && + (scope !== config || listener.HTTPS !== true || listener.TCPForward !== undefined) + ) { + return { _tag: "other" }; + } + const webEntries = Object.entries(scope.Web ?? {}).filter(([hostPort]) => + hostPort.endsWith(`:${servePort}`), + ); + if (webEntries.length > 1 || (scope !== config && webEntries.length > 0)) { + return { _tag: "other" }; + } + for (const [, web] of webEntries) { + const root = web.Handlers?.["/"]; + if (root !== undefined) { + return root.Proxy !== undefined ? { _tag: "proxy", target: root.Proxy } : { _tag: "other" }; + } + } + } + return { _tag: "free" }; +}; + +const LOOPBACK_PROXY_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]); + +// Only a refused loopback connection proves a target stopped. A timeout or a +// remote target may be a live server that is slow or out of reach from here. +const proxyTargetIsLive = Effect.fn("tailscale.proxyTargetIsLive")(( + target: string, +): Effect.Effect => { + const url = URL.canParse(target) ? new URL(target) : undefined; + if (url === undefined || !LOOPBACK_PROXY_HOSTS.has(url.hostname)) { + return Effect.succeed(true); + } + const port = Number(url.port || (url.protocol === "http:" ? 80 : 443)); + const host = url.hostname.replace(/^\[(.*)\]$/u, "$1"); + return Effect.callback((resume) => { + const socket = NodeNet.createConnection({ host, port }); + const settle = (live: boolean) => { + socket.destroy(); + resume(Effect.succeed(live)); + }; + socket.setTimeout(Duration.toMillis(TAILSCALE_PROBE_TIMEOUT)); + socket.once("connect", () => settle(true)); + socket.once("timeout", () => settle(true)); + socket.once("error", (error: NodeJS.ErrnoException) => settle(error.code !== "ECONNREFUSED")); + return Effect.sync(() => socket.destroy()); + }); +}); + +/** + * Points the Serve port at this server unless another live server holds it. + * A root that already points here, or at a stopped loopback server, is replaced. + */ +export const claimTailscaleServe = Effect.fn("tailscale.claimTailscaleServe")(function* ( + input: TailscaleServeTarget, +) { + const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; + const holder = tailscaleServePortHolder(yield* readTailscaleServeConfig, servePort); + if ( + holder._tag === "other" || + (holder._tag === "proxy" && + holder.target !== serveProxyTarget(input) && + (yield* proxyTargetIsLive(holder.target))) + ) { + return yield* new TailscaleServeOccupiedError({ servePort }); + } + yield* ensureTailscaleServe(input); + // Serve has no conditional write, so a server that checked at the same time + // may have written after us. The caller retries and finds it live. + const after = tailscaleServePortHolder(yield* readTailscaleServeConfig, servePort); + if (after._tag !== "proxy" || after.target !== serveProxyTarget(input)) { + return yield* new TailscaleServeOccupiedError({ servePort }); + } +}); + +/** + * Removes the Serve root only while it still points at this server, and keeps + * other handlers on the port. Returns whether it removed anything. + */ +export const releaseTailscaleServe = Effect.fn("tailscale.releaseTailscaleServe")(function* ( + input: TailscaleServeTarget, +) { + const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; + const holder = tailscaleServePortHolder(yield* readTailscaleServeConfig, servePort); + if (holder._tag !== "proxy" || holder.target !== serveProxyTarget(input)) { + return false; + } + yield* runTailscale( + "serve", + ["serve", `--https=${servePort}`, "--set-path=/", "off"], + TAILSCALE_SERVE_TIMEOUT, + "active", + ); + return true; +}); + +const T3EnvironmentIdentity = Schema.Struct({ environmentId: Schema.String }); + +/** The environment ID the T3 server at `baseUrl` reports, or none when nothing answers as one. */ +export const readT3EnvironmentId = Effect.fn("tailscale.readT3EnvironmentId")( function* (input: { readonly baseUrl: string; readonly timeout?: Duration.Input }) { const client = yield* HttpClient.HttpClient; - const response = yield* Effect.gen(function* () { - const url = new URL("/.well-known/t3/environment", input.baseUrl); - const request = HttpClientRequest.get(url.toString()); - return yield* client.execute(request); - }).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT)); - - return Option.match(response, { - onNone: () => false, - onSome: (httpResponse) => httpResponse.status >= 200 && httpResponse.status < 300, - }); + const url = new URL("/.well-known/t3/environment", input.baseUrl); + const identity = yield* client + .execute(HttpClientRequest.get(url.toString())) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(T3EnvironmentIdentity)), + Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT), + ); + return Option.map(identity, (body) => body.environmentId); + }, + Effect.orElseSucceed(() => Option.none()), +); + +/** True only when `baseUrl` answers as `environmentId`, so a Serve URL another server holds does not count. */ +export const probeTailscaleHttpsEndpoint = Effect.fn("tailscale.probeTailscaleHttpsEndpoint")( + function* (input: { + readonly baseUrl: string; + readonly environmentId: string; + readonly timeout?: Duration.Input; + }) { + const served = yield* readT3EnvironmentId(input); + return Option.contains(served, input.environmentId); }, - Effect.orElseSucceed(() => false), ); diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index 7814432145900..bf9d43b1a4d9e 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -125,7 +125,7 @@ export const shareDevServer = Effect.fn("devShare.shareDevServer")(function* (in readonly webPort: number; readonly localTarget?: { readonly port: number; readonly host: string }; }) { - const status = yield* readTailscaleStatus.pipe( + const status = yield* readTailscaleStatus("active").pipe( Effect.mapError((error) => new TailscaleUnavailableError({ cause: error })), ); if (status.magicDnsName === null) {