From 8149aa7f9960ac718de0c7cb1fb11147bc19cb93 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 00:26:05 -0700 Subject: [PATCH 001/114] feat(tailscale): your own devices find and connect to your boxes with no pairing code Co-Authored-By: Claude Opus 5.5 --- .../src/app/DesktopConnectionCatalogStore.ts | 1 + .../DesktopBackendConfiguration.test.ts | 3 + .../backend/DesktopBackendConfiguration.ts | 2 + .../src/backend/DesktopServerExposure.test.ts | 98 ++- .../src/backend/DesktopServerExposure.ts | 124 ++- .../backend/DesktopTailscaleStatus.test.ts | 122 +++ .../src/backend/DesktopTailscaleStatus.ts | 181 +++++ .../backend/tailscaleEndpointProvider.test.ts | 48 +- .../src/backend/tailscaleEndpointProvider.ts | 30 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 6 + apps/desktop/src/ipc/channels.ts | 3 + .../desktop/src/ipc/methods/serverExposure.ts | 40 + apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 5 + .../src/settings/DesktopAppSettings.test.ts | 3 + .../src/settings/DesktopAppSettings.ts | 24 + .../desktop/src/updates/updatesTestHarness.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 4 + .../desktop/src/wsl/DesktopWslBackend.test.ts | 3 + apps/mobile/src/App.tsx | 2 + apps/mobile/src/connection/onboarding.ts | 24 +- apps/mobile/src/connection/storage.ts | 9 +- .../connection/ConnectionDiagnosisDetails.tsx | 41 + .../connection/ConnectionEnvironmentRow.tsx | 13 +- .../connection/ConnectionsNewRouteScreen.tsx | 18 +- .../connection/ConnectionsRouteScreen.tsx | 18 +- .../connection/TailnetDevicesSection.tsx | 103 +++ .../src/features/connection/pairing.test.ts | 6 + .../features/connection/tailnetDiscovery.ts | 90 +++ .../settings/EnvironmentRoutesSection.tsx | 41 +- .../SettingsEnvironmentDetailRouteScreen.tsx | 8 + apps/mobile/src/state/tailnet.ts | 5 + .../state/use-remote-environment-registry.ts | 37 +- apps/server/src/auth/EnvironmentAuth.ts | 21 +- .../server/src/auth/PairingGrantStore.test.ts | 12 +- apps/server/src/auth/PairingGrantStore.ts | 64 +- apps/server/src/auth/RpcAuthorization.test.ts | 47 ++ apps/server/src/auth/RpcAuthorization.ts | 18 +- apps/server/src/auth/SessionStore.test.ts | 10 +- apps/server/src/auth/SessionStore.ts | 62 +- apps/server/src/auth/TailnetSignIn.test.ts | 741 ++++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 657 ++++++++++++++++ apps/server/src/auth/http.test.ts | 13 + apps/server/src/auth/http.ts | 67 +- apps/server/src/auth/tailnetGrants.ts | 63 ++ apps/server/src/binCli.ts | 2 + apps/server/src/cli/config.test.ts | 37 +- apps/server/src/cli/config.ts | 58 +- apps/server/src/cli/connect.ts | 4 + apps/server/src/cli/environment.ts | 8 +- apps/server/src/cli/pair.ts | 5 +- apps/server/src/cli/tailscale.ts | 147 ++++ apps/server/src/cli/up.test.ts | 95 ++- apps/server/src/cli/up.ts | 612 +++++++++++++-- apps/server/src/config.ts | 6 + .../src/environment/DirectEndpoints.test.ts | 46 +- .../server/src/environment/DirectEndpoints.ts | 26 +- .../src/environment/RemoteOpenTargets.ts | 2 +- .../src/environment/ServerEnvironment.test.ts | 84 +- .../src/environment/ServerEnvironment.ts | 25 +- .../src/environment/TailnetPeers.test.ts | 105 +++ apps/server/src/environment/TailnetPeers.ts | 88 +++ .../src/environment/TailscaleHealth.test.ts | 171 ++++ .../server/src/environment/TailscaleHealth.ts | 189 +++++ apps/server/src/http.ts | 2 +- .../src/observability/RpcInstrumentation.ts | 4 + .../src/persistence/AuthPairingLinks.ts | 45 +- apps/server/src/persistence/AuthSessions.ts | 34 + .../persistence/AuthTailnetDeviceBlocks.ts | 123 +++ apps/server/src/persistence/Migrations.ts | 2 + .../Migrations/077_AuthTailnetDeviceBlocks.ts | 16 + .../reconcileV2PreviewMigration.test.ts | 2 + apps/server/src/server.ts | 105 +-- .../src/serverRuntimeStartup.autoPull.test.ts | 2 + apps/server/src/serverRuntimeStartup.test.ts | 2 + apps/server/src/serverRuntimeStartup.ts | 3 + apps/server/src/serverRuntimeState.ts | 4 +- apps/server/src/startupAccess.test.ts | 132 ++++ apps/server/src/startupAccess.ts | 119 ++- .../src/tailscale/TailscaleHost.test.ts | 83 ++ apps/server/src/tailscale/TailscaleHost.ts | 157 ++++ apps/server/src/tailscaleServe.test.ts | 252 ++++++ apps/server/src/tailscaleServe.ts | 119 +++ apps/server/src/ws.test.ts | 49 ++ apps/server/src/ws.ts | 116 ++- .../web/src/components/BranchToolbar.logic.ts | 2 + .../BranchToolbarEnvironmentSelector.tsx | 17 +- .../src/components/ConnectionStatusDot.tsx | 34 +- .../chat/useSystemComposerBanners.tsx | 26 +- .../desktop/TailnetDiscoveryCoordinator.tsx | 90 +++ .../settings/AddEnvironmentDialog.tsx | 37 +- .../settings/ConnectionDiagnosisDetails.tsx | 78 +- .../ConnectionsSettings.logic.test.ts | 87 +- .../settings/ConnectionsSettings.logic.ts | 43 +- .../settings/ConnectionsSettings.tsx | 63 +- .../settings/EnvironmentRoutesList.tsx | 33 + .../settings/SavedEnvironmentRows.tsx | 3 + .../settings/TailnetDevicesSettings.tsx | 114 +++ .../settings/TailscaleSignInRow.tsx | 85 ++ .../settings/settingsSearch.test.ts | 1 + .../src/components/settings/settingsSearch.ts | 9 + .../components/sidebar/SidebarMachineIcon.tsx | 32 + .../components/sidebar/SidebarThreadRow.tsx | 3 +- apps/web/src/connection/onboarding.ts | 24 +- apps/web/src/connection/storage.test.ts | 1 + apps/web/src/connection/storage.ts | 17 +- .../hooks/useLogicalProjectEnvironments.ts | 1 + apps/web/src/routes/__root.tsx | 3 + apps/web/src/state/desktopTailscale.ts | 29 + apps/web/src/state/tailnet.ts | 5 + docs/internals/connection-runtime.md | 4 + docs/user/background-service.md | 15 +- docs/user/remote-access.md | 144 +++- packages/client-runtime/package.json | 4 + .../src/authorization/layer.test.ts | 158 +++- .../src/authorization/service.ts | 111 ++- .../src/authorization/tailnet.ts | 160 ++++ .../client-runtime/src/connection/catalog.ts | 36 +- .../src/connection/diagnosis.test.ts | 239 +++++- .../src/connection/diagnosis.ts | 288 ++++++- .../client-runtime/src/connection/driver.ts | 71 +- .../client-runtime/src/connection/errors.ts | 6 +- .../client-runtime/src/connection/index.ts | 1 + .../client-runtime/src/connection/model.ts | 49 +- .../src/connection/onboarding.test.ts | 193 ++++- .../src/connection/onboarding.ts | 299 ++++++- .../src/connection/presentation.test.ts | 1 + .../src/connection/presentation.ts | 26 +- .../src/connection/registry.test.ts | 185 ++++- .../client-runtime/src/connection/registry.ts | 107 ++- .../src/connection/resolver.test.ts | 262 ++++++- .../client-runtime/src/connection/resolver.ts | 116 ++- .../src/connection/routes.test.ts | 44 ++ .../client-runtime/src/connection/routes.ts | 43 +- .../src/connection/supervisor.test.ts | 422 ++++++---- .../src/connection/supervisor.ts | 260 ++---- .../src/connection/tailnet.test.ts | 126 +++ .../client-runtime/src/connection/tailnet.ts | 267 +++++++ .../src/platform/persistence.ts | 5 + .../src/platform/storageDocument.test.ts | 18 + .../src/platform/storageDocument.ts | 22 +- .../src/state/commandPermissions.test.ts | 25 +- .../src/state/commandPermissions.ts | 6 +- .../src/state/environmentHttpAuth.test.ts | 3 + .../src/state/environmentHttpAuth.ts | 38 +- packages/client-runtime/src/state/runtime.ts | 27 + .../src/state/shell-sync.test.ts | 7 + packages/client-runtime/src/state/tailnet.ts | 76 ++ .../src/state/threads-atoms.test.ts | 1 + packages/contracts/src/auth.ts | 2 + .../contracts/src/clientRpcPermissions.ts | 4 + packages/contracts/src/desktopBootstrap.ts | 3 + packages/contracts/src/environment.ts | 3 + packages/contracts/src/environmentHttp.ts | 45 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 32 + packages/contracts/src/rpc.ts | 40 + packages/contracts/src/server.test.ts | 22 + packages/contracts/src/server.ts | 97 +++ packages/contracts/src/tailnet.ts | 184 +++++ packages/tailscale/src/tailscale.test.ts | 500 +++++++++++- packages/tailscale/src/tailscale.ts | 735 +++++++++++++---- scripts/lib/dev-share.ts | 2 +- 163 files changed, 11271 insertions(+), 1072 deletions(-) create mode 100644 apps/desktop/src/backend/DesktopTailscaleStatus.test.ts create mode 100644 apps/desktop/src/backend/DesktopTailscaleStatus.ts create mode 100644 apps/mobile/src/features/connection/TailnetDevicesSection.tsx create mode 100644 apps/mobile/src/features/connection/tailnetDiscovery.ts create mode 100644 apps/mobile/src/state/tailnet.ts create mode 100644 apps/server/src/auth/TailnetSignIn.test.ts create mode 100644 apps/server/src/auth/TailnetSignIn.ts create mode 100644 apps/server/src/auth/tailnetGrants.ts create mode 100644 apps/server/src/cli/tailscale.ts create mode 100644 apps/server/src/environment/TailnetPeers.test.ts create mode 100644 apps/server/src/environment/TailnetPeers.ts create mode 100644 apps/server/src/environment/TailscaleHealth.test.ts create mode 100644 apps/server/src/environment/TailscaleHealth.ts create mode 100644 apps/server/src/persistence/AuthTailnetDeviceBlocks.ts create mode 100644 apps/server/src/persistence/Migrations/077_AuthTailnetDeviceBlocks.ts create mode 100644 apps/server/src/tailscale/TailscaleHost.test.ts create mode 100644 apps/server/src/tailscale/TailscaleHost.ts create mode 100644 apps/server/src/tailscaleServe.test.ts create mode 100644 apps/server/src/tailscaleServe.ts create mode 100644 apps/web/src/components/desktop/TailnetDiscoveryCoordinator.tsx create mode 100644 apps/web/src/components/settings/TailnetDevicesSettings.tsx create mode 100644 apps/web/src/components/settings/TailscaleSignInRow.tsx create mode 100644 apps/web/src/components/sidebar/SidebarMachineIcon.tsx create mode 100644 apps/web/src/state/desktopTailscale.ts create mode 100644 apps/web/src/state/tailnet.ts create mode 100644 packages/client-runtime/src/authorization/tailnet.ts create mode 100644 packages/client-runtime/src/connection/tailnet.test.ts create mode 100644 packages/client-runtime/src/connection/tailnet.ts create mode 100644 packages/client-runtime/src/state/tailnet.ts create mode 100644 packages/contracts/src/tailnet.ts 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 7464d8fbe9e2f..6cedcc84fd146 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -35,10 +35,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"]); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 492903139547b..583396bdda192 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -507,6 +507,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 67ac6a51e65c7..5bc1b9203f687 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -9,12 +9,14 @@ import * as FileSystem from "effect/FileSystem"; 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 DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopConfig from "../app/DesktopConfig.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(); @@ -90,6 +92,7 @@ function layer(input: { readonly env?: Record; readonly spawnerLayer?: Layer.Layer; readonly desktopSettingsLayer?: Layer.Layer; + readonly httpClientLayer?: Layer.Layer; }) { const env = { T3CODE_HOME: input.baseDir, ...input.env }; const layerEnvironment = layerEnvironmentFor(input.baseDir, env); @@ -98,9 +101,10 @@ function layer(input: { }); return DesktopServerExposure.layer.pipe( + Layer.provideMerge(DesktopTailscaleStatus.layer), Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer), 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)), @@ -122,6 +126,7 @@ const withHarness = ( env: Record = {}, spawnerLayer?: Layer.Layer, desktopSettingsLayer?: Layer.Layer, + httpClientLayer?: Layer.Layer, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -136,6 +141,7 @@ const withHarness = ( env, ...(spawnerLayer ? { spawnerLayer } : {}), ...(desktopSettingsLayer ? { desktopSettingsLayer } : {}), + ...(httpClientLayer ? { httpClientLayer } : {}), }), ), ); @@ -209,6 +215,8 @@ describe("DesktopServerExposure", () => { advertisedHost: "192.168.1.20", tailscaleServeEnabled: false, tailscaleServePort: 443, + tailscaleSignInEnabled: false, + port: 4173, }); const backendConfig = yield* serverExposure.backendConfig; @@ -297,6 +305,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(); @@ -448,6 +497,53 @@ 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, + 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 288d969d6626c..1f67175e85971 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"; @@ -25,10 +26,9 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; 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"; @@ -244,6 +244,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, @@ -254,6 +266,7 @@ export const DesktopServerExposureError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, DesktopServerExposureModePersistenceError, DesktopTailscaleServePersistenceError, + DesktopTailscaleSignInPersistenceError, ]); export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; @@ -263,6 +276,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 { @@ -285,6 +301,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") {} @@ -301,6 +320,8 @@ interface RuntimeState { readonly advertisedHost: Option.Option; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + readonly tailscaleSignInEnabled: boolean; + readonly tailscaleOnly: boolean; } interface ResolvedRuntimeState { @@ -326,6 +347,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 => ({ @@ -334,6 +357,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 => ({ @@ -351,11 +376,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), @@ -363,6 +392,8 @@ function runtimeStateFromResolvedExposure(input: { advertisedHost: Option.fromNullishOr(input.exposure.advertisedHost), tailscaleServeEnabled: input.settings.tailscaleServeEnabled, tailscaleServePort: input.settings.tailscaleServePort, + tailscaleSignInEnabled: input.settings.tailscaleSignInEnabled, + tailscaleOnly, }; } @@ -433,29 +464,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* () { @@ -463,22 +476,24 @@ 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; @@ -588,6 +603,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: result.settings.serverExposureMode, + settings: 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); @@ -598,7 +640,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; } @@ -606,11 +652,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), @@ -624,6 +669,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 35333ac3d33a4..e2fdeacddf2f8 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, @@ -129,6 +132,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 538dd06b0b42a..15358028d6f43 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -69,6 +69,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 9a3c327e6b86b..f72b809c7109f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -52,6 +52,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"; @@ -157,6 +158,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 950ed972c6453..fb8f4fd8b0c4d 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 d8d16b7dbfd62..2bb6d99f712a9 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -208,6 +208,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 0dcb2b85ac2c5..49731ef99d69a 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..a0729c5acacce --- /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 access to ${props.environmentLabel} until another device unblocks it. ` : ""}Its sessions on ${props.environmentLabel} end now, and it cannot sign in through Tailscale until you unblock it. 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..938f844c1d606 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -33,6 +33,12 @@ 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"); + }); + it("reads hosted pairing links into backend host fields", () => { expect( parsePairingUrl( 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 5f83979197bbc..f03dea9a09ff1 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -26,6 +26,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"; @@ -225,6 +226,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..17c1e1b18eac8 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,37 @@ 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 code 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. Enter its pairing code.", + }; + } + 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 ffc2a0a1ebd57..1bb8ddbc5b0d2 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 { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Context from "effect/Context"; @@ -34,6 +35,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"; @@ -707,6 +709,7 @@ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const descriptor = yield* policy.getDescriptor(); const config = yield* ServerConfig.ServerConfig; + const sql = yield* SqlClient.SqlClient; const devAuth = resolveReusableDevAuth(config); const authenticateToken = ( @@ -821,6 +824,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, ), @@ -942,6 +948,9 @@ export const make = Effect.gen(function* () { ); }; + // Consuming the grant (which checks the Tailscale block list) and creating the + // session share one write transaction. Blocking a device takes the same writer, + // so a block either lands before the grant is spent or revokes the new session. const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => { return resolveBootstrapGrant(credential, { @@ -954,7 +963,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 @@ -981,6 +995,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( 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 6049121b41a5d..1e337aa531e57 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"; @@ -86,6 +87,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, @@ -221,9 +224,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), }); @@ -280,11 +288,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..1e29b568b5a96 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; } @@ -1006,20 +1022,10 @@ const make = Effect.gen(function* () { }, ); - 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 +1046,33 @@ 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); + }); + + 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); + }); + const awaitInvalidation = Effect.fn(function* ( sessionId: AuthSessionId, proofKeyThumbprint?: string, @@ -1113,6 +1146,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..fedf0e9afb2d2 --- /dev/null +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -0,0 +1,741 @@ +// @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 SqlitePersistence from "../persistence/Sqlite.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; + 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 whois for `address` after it reads the tailnet, until `release`. */ + hold?: { + readonly address: string; + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + }; +} + +/** A whois `CapMap` with these T3 Code grant values. */ +const granted = (...values: ReadonlyArray): JsonObject => ({ + [TAILNET_APP_CAPABILITY]: values, +}); + +const layerTailscaleHost = (tailnet: Tailnet) => + Layer.succeed( + TailscaleHost.TailscaleHost, + TailscaleHost.TailscaleHost.of({ + inUse: Effect.succeed(true), + status: Effect.suspend(() => parseTailscaleStatus(tailscaleJson(tailnet.status))), + whois: (address) => + Effect.gen(function* () { + const device = tailnet.devices?.[address] ?? { + node: tailnet.node, + capMap: tailnet.capMap, + }; + const json = + device.node === null + ? "{}" + : tailscaleJson({ + Node: device.node, + CapMap: device.capMap ?? null, + ...(tailnet.loginName === undefined || tailnet.devices?.[address] !== undefined + ? {} + : { UserProfile: { LoginName: tailnet.loginName } }), + }); + const hold = tailnet.hold; + if (hold?.address === address) { + delete tailnet.hold; + yield* Deferred.succeed(hold.started, undefined); + yield* Deferred.await(hold.release); + } + return yield* parseTailscaleWhois(json); + }), + }), + ); + +const layerTest = ( + tailnet: Tailnet, + overrides: Partial = {}, +) => + TailnetSignIn.layer.pipe( + Layer.provideMerge(EnvironmentAuth.layer), + 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); + +/** 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 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 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("a shared-in device's suggested grant names its address, not its login", () => + Effect.gen(function* () { + yield* requireGrantOn; + const error = yield* Effect.flip(signIn(makeDpopKey())); + expect(isRefusal(error) ? error.suggestedGrant : error).toContain( + `"src": ["${PHONE_ADDRESS}"]`, + ); + }).pipe( + Effect.provide( + layerTest({ + status: hostStatus(), + node: phoneNode({ Sharer: "int64:555" }), + loginName: "friend@other.example", + }), + ), + ), + ); + + 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, and after that grants decide here. + tailnet.capMap = granted({ scopes: ["standard"] }); + yield* signIn(key); + delete tailnet.capMap; + expect(yield* refusalOf(signIn(key))).toBe("no_grant"); + 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, + }), + ); + 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 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); + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["standard"] }) }, + }; + const widened = yield* signInLaptop(laptopKey); + yield* service.recheckSessions; + expect(yield* revoked(read.access_token)).toBe(true); + expect(yield* revoked(widened.access_token)).toBe(false); + + // 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, but a Tailscale that is starting proves nothing. + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + 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: { phone: peers.phone } }); + yield* service.recheckSessions; + expect(yield* revoked(last.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(), + 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("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))); + }); + + 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", () => + 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"); + expect(yield* service.listDevices("tailscale:nPhone")).toMatchObject([ + { stableNodeId: "nPhone", label: "phone", activeSessionCount: 0, current: true }, + ]); + + expect(yield* service.unblockDevice("nPhone")).toBe(true); + 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 }, + ]); + }).pipe(Effect.provide(layerTest({ status: hostStatus(), node: phoneNode() }))), + ); +}); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts new file mode 100644 index 0000000000000..34ec065f28220 --- /dev/null +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -0,0 +1,657 @@ +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 SqlClient from "effect/sql/SqlClient"; + +import * as ServerConfig from "../config.ts"; +import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts"; +import * as AuthPairingLinks from "../persistence/AuthPairingLinks.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 { 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 + >; + /** `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. */ +const GRANT_FIXES: ReadonlySet = new Set([ + "no_grant", + "grant_unusable", + "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; +}; + +/** 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; +}; + +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 sql = yield* SqlClient.SqlClient; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; + const requireGrantPath = path.join(config.stateDir, REQUIRE_GRANT_FILE); + + // Sign-ins through a grant since the last scan that found none. The first stops the owner + // rule before the next scan. A scan that finds no grants clears it, unless one came meanwhile. + const grantSignIns = yield* Ref.make(0); + // 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. + const lastCleanScan = yield* Ref.make<{ readonly granted: boolean; readonly at: number } | null>( + null, + ); + const scanPolicy = yield* Effect.cachedWithTTL( + Effect.gen(function* () { + const signInsBefore = yield* Ref.get(grantSignIns); + const status = yield* host.status; + const { granted, unreadable } = yield* policyGrantedPeers(status, (address) => + host.whois(address, { fromServer: true }), + ); + 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, { granted: found, at: now }); + if (!found) + yield* Ref.update(grantSignIns, (count) => (count === signInsBefore ? 0 : count)); + return { granted: found, ttl: ttlOf(found) }; + } + const last = yield* Ref.get(lastCleanScan); + const left = last === null ? 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 { + granted: last.granted, + ttl: Duration.min(ttlOf(last.granted), Duration.millis(left)), + }; + }), + (exit) => (exit._tag === "Failure" ? FAILED_SCAN_TTL : exit.value.ttl), + ); + /** + * 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.gen(function* () { + if (yield* fs.exists(requireGrantPath).pipe(Effect.orElseSucceed(() => true))) { + return "no_grant" as const; + } + // Scanned first, so a scan that finds no grants can clear the sign-in count. + const scan = yield* scanPolicy.pipe(Effect.option); + if (Option.isSome(scan) && scan.value.granted) return "no_grant" as const; + if ((yield* Ref.get(grantSignIns)) > 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; + 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); + return refusal === null + ? ({ + _tag: "Allowed", + scopes: AuthStandardClientScopes, + via: "owner", + } satisfies AccessDecision) + : refused(refusal); + }); + + 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 }), + 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 status = yield* host.status.pipe( + Effect.catch((cause) => refuse("tailscale_unavailable", cause)), + ); + if (status.backendState !== "Running" || status.userspaceNetworking === true) { + return yield* refuse("tailscale_unavailable"); + } + if (!TailscaleHost.isSupportedTailscaleVersion(status.version)) { + return yield* refuse("tailscale_outdated"); + } + + const socket = requestSocket(request); + if ( + socket.localPort === undefined || + !isOwnHost(request.headers.host, status, socket.localPort) + ) { + return yield* refuse("browser_request"); + } + const remoteAddress = plainIpv4(socket.remoteAddress); + const localAddress = plainIpv4(socket.localAddress); + if ( + remoteAddress === undefined || + localAddress === undefined || + !isTailscaleIpv4Address(remoteAddress) || + !status.tailnetIpv4Addresses.includes(localAddress) + ) { + return yield* refuse("not_direct_tailnet"); + } + + const proofKeyThumbprint = yield* verifyRequestDpopProof({ request }).pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), + Effect.provideService(Crypto.Crypto, crypto), + ); + + // 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 decision = yield* decide(node, status); + if (decision._tag === "Refused") { + return yield* refuse( + decision.reason, + undefined, + GRANT_FIXES.has(decision.reason) + ? tailnetGrantSnippet( + // A login from another tailnet is not one this policy can name. + node.tags[0] ?? + (node.sharerUserId === null ? node.loginName : null) ?? + remoteAddress, + status.selfTags[0] ?? localAddress, + socket.localPort, + ) + : undefined, + ); + } + const { scopes, via } = decision; + + const blocked = yield* blocks + .isBlocked({ stableNodeId: node.stableNodeId }) + .pipe(Effect.mapError((cause) => new TailnetDeviceBlockLookupError({ cause }))); + if (blocked) return yield* refuse("blocked"); + + 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 }), + }); + 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(" "), + }), + ); + // The policy names who gets in here now; owner-rule sessions end on the next check. + if (via === "grant" && (yield* Ref.getAndUpdate(grantSignIns, (count) => count + 1)) === 0) { + yield* recheckSessions.pipe(Effect.forkDetach); + } + 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, + ) { + // 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; + const lookup = yield* host.whois(address, { fromServer: true }).pipe(Effect.option); + if (Option.isNone(lookup) || lookup.value.stableNodeId !== stableNodeId) { + 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 === "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 first grant sign-in starts share the state above. + const recheckLock = yield* Semaphore.make(1); + + const recheckSessions: TailnetSignIn["Service"]["recheckSessions"] = Effect.gen(function* () { + // 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<{ id: AuthSessionId; scopes: ReadonlyArray }>; + links: Array>; + /** The sign-in label, "name" or "name (login)", for logs once Tailscale forgets the device. */ + 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; + }; + for (const session of yield* sessions.listActive()) { + if (session.subject.startsWith(TAILNET_SUBJECT_PREFIX)) { + deviceOf(session.subject, session.client.label ?? null).sessions.push({ + id: session.sessionId, + scopes: session.scopes, + }); + } + } + for (const link of yield* pairingLinks.listActive({ now })) { + if (link.subject.startsWith(TAILNET_SUBJECT_PREFIX)) + deviceOf(link.subject, link.label).links.push(link.scopes); + } + 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; + } + // A daemon that is starting or logged out lists no peers, which proves nothing for a + // while. Past the grace period nothing vouches for these devices, so their access ends. + const status = yield* host.status.pipe(Effect.option); + if (status._tag === "None" || status.value.backendState !== "Running") { + const nowMs = DateTime.toEpochMillis(now); + statusFailingSince ??= nowMs; + if (nowMs - statusFailingSince < Duration.toMillis(LOOKUP_GRACE)) return; + yield* Effect.forEach( + devices.keys(), + (id) => endAccess(id, "tailscale_unavailable", nameOf(id)), + { discard: true }, + ); + return; + } + statusFailingSince = null; + yield* Effect.forEach( + devices, + Effect.fnUntraced(function* ([stableNodeId, held]) { + const result = yield* recheckDevice(stableNodeId, status.value); + 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 stale = held.sessions.filter( + (session) => !sameScopes(session.scopes, result.allowed), + ); + yield* Effect.forEach(stale, (session) => sessions.revoke(session.id), { discard: true }); + const links = held.links.some((scopes) => !sameScopes(scopes, result.allowed)) + ? yield* pairingLinks.revokeUnusedBySubject({ + subject: tailnetSubject(stableNodeId), + revokedAt: now, + }) + : 0; + 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 }, + ); + }).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; + // Shares the writer with the grant exchange, so no session for this device survives. + return yield* blocks + .block({ stableNodeId, label, blockedAt }) + .pipe( + Effect.andThen(sessions.revokeBySubject(subject)), + sql.withTransaction, + 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, + ); + } + + 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 325f0f991e9c2..924dbba54c0b8 100644 --- a/apps/server/src/auth/http.test.ts +++ b/apps/server/src/auth/http.test.ts @@ -155,3 +155,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 e570be1b4793a..e5bf03d61266c 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -14,8 +14,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"; @@ -32,6 +35,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"; @@ -118,13 +122,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, }), ), @@ -178,7 +186,7 @@ export const authenticateMediaRequest = ), ); if (!session.scopes.includes(requiredScope)) { - return yield* failEnvironmentScopeRequired(requiredScope); + return yield* failEnvironmentScopeRequired(requiredScope, session.subject); } return session; }); @@ -219,7 +227,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; }); @@ -453,7 +461,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); @@ -514,3 +522,54 @@ 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), + }), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("pairing_credential_issuance_failed", error), + ), + ), + ); + }), +); diff --git a/apps/server/src/auth/tailnetGrants.ts b/apps/server/src/auth/tailnetGrants.ts new file mode 100644 index 0000000000000..369abfc529bea --- /dev/null +++ b/apps/server/src/auth/tailnetGrants.ts @@ -0,0 +1,63 @@ +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]; +}; + +/** + * 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. + */ +export const policyGrantedPeers = Effect.fn("tailnetGrants.policyGrantedPeers")(function* ( + status: TailscaleStatus, + whois: (address: string) => Effect.Effect, +) { + const results = yield* Effect.forEach( + status.peers, + (peer) => { + const address = peer.tailnetIpv4Addresses[0] ?? peer.tailnetIpv6Addresses[0]; + const name = peer.hostName ?? address ?? peer.stableNodeId; + if (address === undefined) return Effect.succeed({ name, granted: null }); + return whois(address).pipe( + Effect.map((node) => ({ + name, + granted: Object.hasOwn(node.capabilities, TAILNET_APP_CAPABILITY) as boolean | null, + })), + Effect.orElseSucceed(() => ({ name, granted: null })), + ); + }, + { 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 7c136ae53268c..3ee48b0443946 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -27,6 +27,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"; @@ -92,6 +93,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 e0aea76ef04c2..165af259cc68b 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"; @@ -519,6 +521,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 2b023f6562eb3..a1c5ae8357a29 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 SqlitePersistence from "../persistence/Sqlite.ts"; @@ -41,7 +42,12 @@ const runWithPeerLinks = Effect.fn("environment.runWithPeerLinks")(function* ()( @@ -359,7 +360,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) { @@ -514,6 +515,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..31aad0aef2e81 --- /dev/null +++ b/apps/server/src/cli/tailscale.ts @@ -0,0 +1,147 @@ +import * as NodeUtil from "node:util"; + +import { ServerTailscaleStatus, TAILNET_APP_CAPABILITY } from "@t3tools/contracts"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import { readTailscaleStatus, readTailscaleWhois } 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 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 { policyGrantedPeers } from "../auth/tailnetGrants.ts"; +import { formatTailscaleHealth, readTailscaleHealth } 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`, + ); +}); + +/** Peers the tailnet policy grants T3 Code here, and whether this host is tagged. Null when Tailscale cannot say. */ +const readPolicyGrantedPeers = readTailscaleStatus("active").pipe( + Effect.flatMap((status) => + policyGrantedPeers(status, readTailscaleWhois).pipe( + Effect.map((scan) => ({ ...scan, tagged: status.selfTags.length > 0 })), + ), + ), + Effect.option, + Effect.map(Option.getOrNull), +); + +/** 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 scan = yield* readPolicyGrantedPeers; + const granted = scan?.granted ?? []; + const lines = + 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(", ")}.`, + "Remove those grants to let your own untagged devices in without one.", + ] + : scan === null + ? ["Grant required: yes, because Tailscale is not answering."] + : scan.unreadable.length > 0 + ? [ + `Grant required: probably. Tailscale could not read ${scan.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.", + ] + : scan.tagged && !(yield* hasOwner(config.baseDir, config.tailscaleOwnerUserId)) + ? [ + "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,", + "so 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, + "Devices with a grant get the scopes it names: read, standard, admin, or single scopes.", + ].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..e087d15d1d79a 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -17,40 +17,41 @@ import { afterEach, vi } from "vite-plus/test"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import { upsertServiceEnv } from "./service.ts"; -import { connectEnabled, planRoutes, upCommand } from "./up.ts"; +import { connectEnabled, planRoutes, resolveOwner, upCommand, upPortCheck } from "./up.ts"; 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 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: "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 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); @@ -354,3 +355,51 @@ 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* () { + const status = { + 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, + certDomains: [], + userspaceNetworking: false, + peers: [], + users: [{ userId: "123", loginName: "me@example.com" }], + }; + // Not interactive, as with --yes, so asking would fail with "Pass --owner". + expect(yield* resolveOwner(status, 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 7f307aec4b3a3..a2ab5f8e28ea8 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -1,21 +1,36 @@ +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 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"; import { hasCloudPublicConfig } from "../cloud/publicConfig.ts"; import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; import * as ServerConfig from "../config.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { renderTerminalQrCode } from "../startupAccess.ts"; +import { + isSupportedTailscaleVersion, + MINIMUM_TAILSCALE_VERSION, +} from "../tailscale/TailscaleHost.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; import { connectSubcommands, @@ -27,9 +42,42 @@ import { } from "./connect.ts"; import { resolveHostCliCommand } from "./invocation.ts"; import * as CliService from "./service.ts"; +import { 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 { @@ -38,9 +86,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; } @@ -58,13 +107,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)); @@ -80,48 +127,61 @@ 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, ) => { - 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); + 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: "Tailnet 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", @@ -131,6 +191,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 ? [] : [ @@ -149,18 +217,21 @@ 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 + ...(setup.host === undefined && settings.lan ? ["Anyone on your LAN or tailnet can reach the port. A pairing code is the only lock."] : []), ...(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 [ ...rows.map(([label, value]) => ` ${label.padEnd(15)}${value}`), @@ -174,7 +245,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 @@ -185,34 +256,300 @@ const formatNextSteps = (routes: Routes, serviceInstalled: boolean) => 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 status = yield* readTailscaleStatus("active").pipe(Effect.option); + if (Option.isNone(status)) { + return { + ok: false, + reason: "Tailscale did not answer. Install it and run `sudo tailscale up`.", + } as const; + } + const { value } = status; + if (value.backendState !== "Running") { + return { + ok: false, + reason: `Tailscale is ${value.backendState ?? "not running"}. Run \`sudo tailscale up\`.`, + } as const; + } + if (value.userspaceNetworking === true) { + return { + ok: false, + reason: + "Tailscale sign-in needs device addresses, and userspace networking hides them. Give the container /dev/net/tun and run tailscaled without --tun=userspace-networking.", + } as const; + } + if (!isSupportedTailscaleVersion(value.version)) { + return { + ok: false, + reason: `Tailscale ${value.version ?? "(unknown version)"} is too old for Tailscale sign-in. Update to ${MINIMUM_TAILSCALE_VERSION} or newer.`, + } as const; + } + const address = value.tailnetIpv4Addresses[0]; + if (address === undefined) { + return { ok: false, reason: "Tailscale reported no IPv4 address for this host." } 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. + const owner = requireGrant + ? 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.`, + ); + } + return { + owner, + 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" : undefined }), + }), + }, + }; +}); + +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)] : []), + "Tailnet devices find this machine automatically.", + "", + ...(requireGrant + ? [ + "Add this grant to your tailnet policy:", + indent(tailnetGrantSnippet(src, dst, port)), + "Let your untagged devices in without a grant: `t3 tailscale require-grant off`.", + ] + : [ + "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 asks = [flags.tailscale, flags.connect, flags.lan].every(Option.isNone); + 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, + ); const interactive = !flags.yes && interactiveTerminal(); let routes = plan.routes; if (plan.ask.length > 0 && interactive) { 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:", @@ -227,36 +564,65 @@ const setUp = Effect.fn("cli.up")(function* (flags: { }), ).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\`.`, + ); } // 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 ? { T3CODE_TAILSCALE_SIGN_IN: undefined } : {}), + }); return yield* Console.log(formatNextSteps(routes, false)); } + if (routes.tailscale && !tailscale.ok) { + return yield* upError(`${tailscale.reason} Then run t3 up again.`); + } + + const requireGrant = flags.requireGrant || before.requireGrant; + const signIn = + routes.tailscale && tailscale.ok + ? yield* tailscaleEntries(tailscale, flags, requireGrant) + : null; + const writes: Readonly> = { + T3CODE_LAN: String(routes.lan), + ...(signIn?.entries ?? (current.tailscale ? { T3CODE_TAILSCALE_SIGN_IN: undefined } : {})), + }; + 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 restart = before.service.installed && (before.service.current || newer) && - (needsRestart(current, routes) || stopped); + (!sameSettings(before.settings, serviceSettings(planned, true)) || + (routes.connect && !current.connect) || + 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) { @@ -271,6 +637,10 @@ const setUp = Effect.fn("cli.up")(function* (flags: { 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, @@ -286,15 +656,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 @@ -311,18 +683,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"] + : []), ]); 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( @@ -393,32 +774,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. 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. Turn off with --no-tailscale-serve. Implies --tailscale.", ), 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), ), @@ -430,13 +837,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: "Allow only Tailscale devices your policy grants.", + }, + { + 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.isSome(flags.tailscaleServe) ? ["--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", { @@ -447,7 +881,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 }, ), ), @@ -463,10 +906,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, }, @@ -503,7 +947,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..20a64ef2ee6a5 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,14 @@ 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 +246,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 +276,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 +301,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..bb92718213297 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,30 @@ 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), + 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 +118,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 +256,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 +290,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..9f518915728ec 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -22,6 +22,7 @@ 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"; @@ -186,6 +187,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 +284,30 @@ 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) => + status.backendState === "Running" && + status.userspaceNetworking !== true && + TailscaleHost.isSupportedTailscaleVersion(status.version), + ), + 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..671dfd5b90fe9 --- /dev/null +++ b/apps/server/src/environment/TailnetPeers.test.ts @@ -0,0 +1,105 @@ +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, + 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..d6df2bc1b5d48 --- /dev/null +++ b/apps/server/src/environment/TailscaleHealth.test.ts @@ -0,0 +1,171 @@ +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, + certDomains: ["studio.tail1234.ts.net"], + userspaceNetworking: false, + peers: [], +}; + +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", + ["health_warning", "https_off", "magic_dns_off", "userspace_networking"], + ], + [ + "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"), + 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..59d43b6bf8b11 --- /dev/null +++ b/apps/server/src/environment/TailscaleHealth.ts @@ -0,0 +1,189 @@ +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 +>; + +/** 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"; + +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."; + } +} + +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) }); + + // 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}.` }); + } + if (status.userspaceNetworking === true) { + issues.push({ + cause: "userspace_networking", + fix: "Run tailscaled with a TUN device, so T3 Code can see which device connects.", + }); + } + } + 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 1e3388e4136ad..f3447e4adaba7 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -62,6 +62,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 e13c2b449d9e7..f331dfcca08d4 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -79,6 +79,7 @@ import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts"; import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; import Migration0076 from "./Migrations/076_ProjectionProjectsPinArchive.ts"; +import Migration0077 from "./Migrations/077_AuthTailnetDeviceBlocks.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -158,6 +159,7 @@ const migrationEntries = [ [74, "ExcludeAttachmentPlaceholdersFromSearch", Migration0074], [75, "SubagentDeliveryState", Migration0075], [76, "ProjectionProjectsPinArchive", Migration0076], + [77, "AuthTailnetDeviceBlocks", Migration0077], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/077_AuthTailnetDeviceBlocks.ts b/apps/server/src/persistence/Migrations/077_AuthTailnetDeviceBlocks.ts new file mode 100644 index 0000000000000..7e9ba8a390342 --- /dev/null +++ b/apps/server/src/persistence/Migrations/077_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 08c5094bf49c8..6ad018ca0d0c0 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -56,6 +56,7 @@ describe("V2 preview upgrade", () => { [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], + [77, "AuthTailnetDeviceBlocks"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -155,6 +156,7 @@ describe("V2 preview upgrade", () => { [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], + [77, "AuthTailnetDeviceBlocks"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 452addfb4a667..6c9dc2e6a54fa 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"; @@ -130,6 +131,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"; @@ -149,6 +151,9 @@ import { RELAY_URL_SECRET, } from "./cloud/config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.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"; @@ -197,12 +202,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 } from "@t3tools/tailscale"; import * as ServerActivation from "./serverActivation.ts"; // Escaped MCP handoff provenance can exceed find-my-way's 100-character segment default. @@ -305,14 +314,20 @@ const layerRelayClient = Layer.unwrap( const layerHttpServer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + 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(); } }); @@ -479,7 +494,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), @@ -704,7 +722,8 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provideMerge(AnalyticsService.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), ); @@ -721,6 +740,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), @@ -772,6 +792,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), @@ -788,6 +814,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; @@ -827,57 +854,12 @@ 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; - return yield* ensureTailscaleServe({ - localPort, - servePort: config.tailscaleServePort, - localHost: loopbackUrlHostForAddress(address), - }).pipe( - Effect.as({ localPort, 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 - ? disableTailscaleServe({ servePort: configured.servePort }).pipe( - Effect.tap(() => - Effect.logInfo("Tailscale Serve disabled", { - servePort: configured.servePort, - }), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to disable Tailscale Serve", { - cause, - servePort: configured.servePort, - }), - ), - ) - : Effect.void, - ), - ) + ? TailscaleServe.layer({ + servePort: config.tailscaleServePort, + parked: tailscaleParked, + settled: tailscaleSettled, + awaitActivation, + }) : Layer.empty; const layerCloudDesiredLinkReconcile = Layer.effectDiscard( Effect.gen(function* () { @@ -1072,6 +1054,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)), { @@ -1093,11 +1078,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), Layer.provideMerge(layerHttpServer), 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 aca62ebb01ecf..5f94c57a8edd3 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`. @@ -54,7 +55,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; @@ -64,6 +65,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..158f2664519e3 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,112 @@ 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("picks up Tailscale coming up after the server started", () => { + 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); + }); +}); + +// 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..95541d06ee444 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,85 @@ 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. A refused connection reads them again at most every few seconds, + * so Tailscale coming up after the server starts is picked up. + */ +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 (acceptsTailnetConnection(socket, addresses)) return true; + if (options.now() - readAt < TAILSCALE_ADDRESS_REFRESH_MS) return false; + 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 +164,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 +178,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 +215,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}`; }; @@ -197,11 +285,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..1809dcf8906c8 --- /dev/null +++ b/apps/server/src/tailscale/TailscaleHost.test.ts @@ -0,0 +1,83 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { expect, it } from "@effect/vitest"; +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 { 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: [] }, +}); + +const layerTest = (whoisSpawns: { count: number }) => + TailscaleHost.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + whoisSpawns.count += 1; + return Effect.succeed( + 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(WHOIS_JSON)), + 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("keeps whois answers for 3 s 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"; + + yield* host.whois(address); + yield* TestClock.adjust("2999 millis"); + yield* host.whois(address); + expect(whoisSpawns.count).toBe(1); + yield* TestClock.adjust("1 millis"); + yield* host.whois(address); + expect(whoisSpawns.count).toBe(2); + + for (let lookup = 3; lookup <= 10; lookup += 1) { + yield* TestClock.adjust("3 seconds"); + yield* host.whois(address); + } + expect(whoisSpawns.count).toBe(10); + + yield* TestClock.adjust("3 seconds"); + 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..252ac6f071fd6 --- /dev/null +++ b/apps/server/src/tailscale/TailscaleHost.ts @@ -0,0 +1,157 @@ +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 Cache from "effect/Cache"; +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); +// Short, so a renewal reads a node that was tagged or removed seconds ago. +const WHOIS_TTL = Duration.seconds(3); +const WHOIS_CACHE_CAPACITY = 256; +// Lookups that miss the cache, per address. 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; + /** + * Who owns a tailnet address. Single-flight per address, kept for a few + * seconds at most, and rate limited per address. Policy scans and rechecks + * (`fromServer`) skip the cache and 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)), + ), + ); + const status = yield* Effect.cachedWithTTL( + readTailscaleStatus(featureOn ? "active" : "passive").pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + 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; + }); + }); + + const whoisCache = yield* Cache.makeWith( + Effect.fn("TailscaleHost.whoisLookup")(function* (address: string) { + if (!(yield* takeLookup(address))) { + return yield* new TailscaleWhoisRateLimitedError({}); + } + return yield* readTailscaleWhois(address).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + }), + { capacity: WHOIS_CACHE_CAPACITY, timeToLive: () => WHOIS_TTL }, + ); + + const whois: TailscaleHost["Service"]["whois"] = (address, options) => + options?.fromServer === true + ? readTailscaleWhois(address).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ) + : Cache.get(whoisCache, address); + + return TailscaleHost.of({ inUse, status, 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..d9dd29bbb9625 --- /dev/null +++ b/apps/server/src/tailscaleServe.test.ts @@ -0,0 +1,252 @@ +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 Layer from "effect/Layer"; +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 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; +} + +// Stands in for the tailscale CLI and keeps the Serve root like the daemon does. +const makeFakeTailscale = (options: FakeOptions) => { + const state = { + serveAttempts: 0, + releases: 0, + root: options.heldBy ?? null, + }; + // Completes when the daemon takes the mapping, before the CLI exits. + const mappingApplied = Deferred.makeUnsafe(); + const serveStatus = () => + state.root === null + ? "null\n" + : JSON.stringify({ + TCP: { [SERVE_PORT]: { HTTPS: true } }, + Web: { + [`desktop.tail.ts.net:${SERVE_PORT}`]: { Handlers: { "/": { Proxy: state.root } } }, + }, + }); + const handle = ( + args: ReadonlyArray, + ): { code: number; stdout?: string; stderr?: string; ackDelay?: Duration.Input } | undefined => { + const command = args.join(" "); + if (command === "serve status --json") { + 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; + Deferred.doneUnsafe(mappingApplied, Effect.void); + return { code: 0, ...(options.ackDelay === undefined ? {} : { ackDelay: options.ackDelay }) }; + } + 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 }; +}; + +const startTailscaleServe = Effect.fn(function* (options: FakeOptions = {}) { + const fake = makeFakeTailscale(options); + const parked = yield* Deferred.make(); + const settled = yield* Deferred.make(); + const scope = yield* Scope.make(); + yield* Layer.buildWithScope( + TailscaleServe.layer({ + servePort: SERVE_PORT, + parked, + settled, + awaitActivation: Effect.void, + }).pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, fake.spawner), + Layer.succeed(HostProcess.Platform, "linux"), + Layer.mock(HttpServer.HttpServer)({ + address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 18638), + }), + ), + ), + ), + scope, + ); + return { state: fake.state, mappingApplied: fake.mappingApplied, scope, settled }; +}); + +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 } = yield* startTailscaleServe({ refusedServeAttempts: 1 }); + yield* TestClock.adjust("1 hour"); + + yield* Scope.close(scope, Exit.void); + + assert.equal(state.releases, 1); + assert.equal(state.root, null); + }), + ); + + 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("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..cdda6dbae0ff1 --- /dev/null +++ b/apps/server/src/tailscaleServe.ts @@ -0,0 +1,119 @@ +import { claimTailscaleServe, releaseTailscaleServe } from "@t3tools/tailscale"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import { HttpServer } from "effect/http"; + +import { loopbackUrlHostForAddress } from "./startupAccess.ts"; + +type ClaimError = Effect.Error>; + +// 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"), +]); + +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."; + } + 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 log in."; + default: + return `Run \`${manualCommand}\` by hand to see why.`; + } +}; + +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 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}`; + const configured = yield* Ref.make(false); + + // Added before the retry fiber is forked, so it runs after that fiber + // is interrupted and sees the final state. + yield* Effect.addFinalizer(() => + Ref.get(configured).pipe( + Effect.flatMap((wasConfigured) => + wasConfigured + ? releaseTailscaleServe(target).pipe( + Effect.tap((released) => + Effect.logInfo( + released + ? "Tailscale Serve disabled" + : "Tailscale Serve no longer points at this server; leaving it in place", + logContext, + ), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to disable Tailscale Serve", { + cause, + ...logContext, + }), + ), + ) + : Effect.void, + ), + ), + ); + + 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 = claimTailscaleServe(target).pipe( + Effect.tap(() => Ref.set(configured, true)), + Effect.uninterruptible, + Effect.tap(() => Effect.logInfo("Tailscale Serve configured", logContext)), + Effect.tapError((cause) => { + const key = failureKey(cause); + const repeated = key === lastFailure; + lastFailure = key; + const log = repeated ? Effect.logDebug : Effect.logWarning; + return log( + `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); + }), + ); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index ca899d4ef04ae..09f355218b7fb 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -59,6 +59,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"; @@ -76,6 +78,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"; @@ -131,6 +134,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; @@ -234,6 +245,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), ); @@ -554,6 +593,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)({}), @@ -660,6 +701,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(), @@ -854,6 +899,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 abc7c71f4b8b2..76dccca4b01a6 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, @@ -225,6 +229,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"; @@ -314,11 +319,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( @@ -345,8 +364,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] => { @@ -365,6 +390,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": @@ -1355,6 +1387,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; @@ -1382,6 +1415,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; @@ -1430,7 +1465,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, @@ -1769,6 +1809,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 @@ -1792,6 +1839,7 @@ export const layerWsRpc = ( : remoteOpenTargets.resolveTargets(), directEndpoints: directEndpoints.resolve(), }); + const tailscale = yield* Fiber.join(tailscaleFiber); return { environment, @@ -1846,6 +1894,7 @@ export const layerWsRpc = ( }), newProjectsRoot: managedFolders.namedProjectsRoot, ...(lastEventLoopStall === undefined ? {} : { lastEventLoopStall }), + ...(tailscale === undefined ? {} : { tailscale }), }; }); @@ -2556,6 +2605,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 }) { @@ -3248,9 +3311,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, + ), ); }), ), @@ -3338,6 +3405,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", @@ -3378,21 +3448,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, ), ), @@ -3421,6 +3495,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..7859994d6cf39 --- /dev/null +++ b/apps/web/src/components/settings/TailscaleSignInRow.tsx @@ -0,0 +1,85 @@ +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, +}: { + readonly state: DesktopServerExposureState | null; + readonly wslOnly: boolean; +}) { + 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, 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 within an hour. 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" + /> + } + /> + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index ee22671ee1f6d..067555ed8a919 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 dda759f860e5b..e7ad95be00a9d 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1081,6 +1081,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 fc47fd8e21adc..4bfb2e261beb5 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..d0189451fcd26 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -45,13 +45,14 @@ 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. +Tailscale or 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 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/remote-access.md b/docs/user/remote-access.md index c7e69989e0493..7dbb8a8f71ec0 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -41,8 +41,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 +71,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: @@ -132,13 +134,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 +169,117 @@ 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, the owner rule returns within five minutes. +- **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. + +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. +- **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 and 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 any +device connected to that machine, and **Block** it under **Tailscale devices on +**. Its sessions there end now, and **Unblock** undoes it. A device that +rejoins Tailscale gets a new ID, so block it again or remove it from your tailnet. + +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 does not work with userspace networking, which is common in +LXC and other containers; `t3 up` tells you when it finds it. + ### Tailscale HTTPS Join both devices to the same tailnet. In the desktop app, enable **Tailscale @@ -195,6 +308,23 @@ tailscale serve --https=443 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 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..0084cba8e5de4 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,6 +114,23 @@ 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; @@ -116,6 +138,8 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( readonly beforeBootstrap?: Effect.Effect; readonly beforePut?: Effect.Effect; readonly clerkToken?: ClientCapabilities.CloudSession["Service"]["clerkToken"]; + readonly credentials?: ReadonlyArray; + readonly surface?: "desktop" | "mobile"; }) { const tokens = yield* Ref.make( new Map( @@ -141,6 +165,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 +238,20 @@ 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) => + 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 +259,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( label: "T3 Code Test", deviceType: "mobile", os: "test", + ...(input.surface === undefined ? {} : { surface: input.surface }), }, }), ), @@ -226,6 +268,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( ); return { layer, + credentials, tokens, bootstrapCalls, proofInputs, @@ -1104,4 +1147,117 @@ 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("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..44153361fbfbf 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -17,6 +17,8 @@ import { mapRemoteDpopEnvironmentError, mapRemoteEnvironmentError, } from "../connection/errors.ts"; +import type { TailnetConnectionCredential } from "../connection/catalog.ts"; +import * as ConnectionCredentialStore from "../connection/credentialStore.ts"; import { ConnectionBlockedError, ConnectionTransientError, @@ -26,6 +28,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 +55,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< @@ -90,6 +95,23 @@ export class RemoteEnvironmentAuthorization extends Context.Service< readonly expectedEnvironmentId: EnvironmentId; readonly expiresAtEpochMs: number; }) => 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") {} @@ -119,7 +141,15 @@ export const make = Effect.gen(function* () { const deviceIdentity = yield* ClientCapabilities.RelayDeviceIdentity; const presentation = yield* ClientCapabilities.ClientPresentation; const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore; + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; const httpClient = yield* HttpClient.HttpClient; + const tailnet = makeTailnetSessions({ + credentials, + signer, + httpClient, + clientMetadata: presentation.metadata, + scope: serviceScope, + }); const tokenLock = yield* Semaphore.make(1); const tokenOwners = new Map< EnvironmentId, @@ -194,14 +224,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 +244,7 @@ export const make = Effect.gen(function* () { ), ), clientMetadata: presentation.metadata, - connectionMethod: "relay", + connectionMethod, ...(timeoutMs === undefined ? {} : { timeoutMs }), }); }, @@ -488,9 +519,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 +541,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 +557,67 @@ 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, + signer, + clientMetadata: presentation.metadata, + }).pipe(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); }), diff --git a/packages/client-runtime/src/authorization/tailnet.ts b/packages/client-runtime/src/authorization/tailnet.ts new file mode 100644 index 0000000000000..4128ebc660129 --- /dev/null +++ b/packages/client-runtime/src/authorization/tailnet.ts @@ -0,0 +1,160 @@ +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 type * 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 type { 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 signer: ManagedRelayDpopSigner["Service"]; + readonly clientMetadata?: AuthClientPresentationMetadata; + }) { + const thumbprint = yield* input.signer.thumbprint.pipe(Effect.mapError(proofError)); + const signInUrl = environmentEndpointUrl(input.httpBaseUrl, "/api/auth/tailnet"); + const signInProof = yield* input.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* input.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 = (input: { + readonly credentials: ConnectionCredentialStore.ConnectionCredentialStore["Service"]; + readonly signer: ManagedRelayDpopSigner["Service"]; + readonly httpClient: HttpClient.HttpClient; + readonly clientMetadata: AuthClientPresentationMetadata; + readonly scope: Scope.Scope; +}) => { + 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, + signer: input.signer, + clientMetadata: input.clientMetadata, + }).pipe( + Effect.tap((fresh) => input.credentials.put(credentialId, fresh)), + Effect.provideService(HttpClient.HttpClient, input.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* input.credentials.get(credentialId)); + if (stored === undefined || !isTailnetCredential(stored)) { + return yield* credentialMissingError(credentialId); + } + const thumbprint = yield* input.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,216 @@ 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: [ + "On another device connected to Mac mini, open Settings > Environments and unblock this device.", + "Or run `t3 pair` on Mac mini, pair this device with the code, then unblock it.", + ], + 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)", + ); + }); +}); + +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..ccbb36febc9b3 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,163 @@ 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", +]); + +/** " (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 +509,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 +542,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 +552,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 +600,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..b9d5ae657a727 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({ @@ -214,26 +242,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..b93c74801ce83 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,11 @@ 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())); + expect(tailnet.profile.httpBaseUrl).toBe("http://100.88.1.2:3773/"); 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 +330,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 +698,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..a877e8c152d46 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,8 +117,11 @@ export class ConnectionOnboarding extends Context.Service< const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.resolvePairingTarget")( function* (input: PairingConnectionInput) { + // A bare tailnet name or 100.x address means T3 Code's port, with a code as without one. + const tailnetHost = input.host === undefined ? null : tailnetSignInBaseUrl(input.host); return yield* Effect.try({ - try: () => resolveRemotePairingTarget(input), + try: () => + resolveRemotePairingTarget(tailnetHost === null ? input : { ...input, host: tailnetHost }), catch: (cause) => new ConnectionBlockedError({ reason: "configuration", @@ -169,8 +213,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 +236,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 +411,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 +446,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 +538,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 +574,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 4f42d0fb0aa36..0cc92dbcf7d07 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; @@ -2090,6 +2093,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 013548eb0dad4..855a8df24140e 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -241,6 +241,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 180954864e443..ed49dae63bf2d 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -213,15 +213,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) { @@ -256,12 +258,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 && @@ -351,6 +361,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..58b83400d28e1 --- /dev/null +++ b/packages/client-runtime/src/connection/tailnet.test.ts @@ -0,0 +1,126 @@ +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/"], + [" 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..45429ea732334 --- /dev/null +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -0,0 +1,267 @@ +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; + // URL drops the default port, so a typed `:80` would read as no port at all. + const typedPort = /^(?:[a-z][a-z\d+.-]*:\/\/)?[^/?#]*:\d+(?:[/?#]|$)/iu.test(trimmed); + const port = url.port !== "" ? url.port : 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. T3 Code retries on its 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}, set the grant's value to \`{ "scopes": ["standard"] }\`. 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. T3 Code retries on its own.`, + grant, + ], + }; + case "blocked": + return { + headline: `${host} blocked this device`, + steps: [ + `On another device connected to ${host}, open Settings > Environments and unblock this device.`, + `Or run \`t3 pair\` on ${host}, pair this device with the code, then unblock it.`, + ], + }; + case "rate_limited": + return { + headline: `${host} asked this device to slow down`, + steps: ["Wait a minute. T3 Code retries on its 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 9d1df33d1f70b..a699ca9977deb 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -278,6 +278,7 @@ describe("environment shell synchronization", () => { generation: 0, lastFailure: null, retryAt: null, + routes: [], }); yield* Queue.offer(events, { kind: "snapshot", @@ -298,6 +299,7 @@ describe("environment shell synchronization", () => { generation: 1, lastFailure: null, retryAt: null, + routes: [], }); yield* SubscriptionRef.set(supervisorState, { desired: false, @@ -308,6 +310,7 @@ describe("environment shell synchronization", () => { generation: 2, lastFailure: null, retryAt: null, + routes: [], }); yield* Deferred.await(saveStarted); @@ -320,6 +323,7 @@ describe("environment shell synchronization", () => { generation: 3, lastFailure: null, retryAt: null, + routes: [], }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((state) => state.status === "synchronizing"), @@ -420,6 +424,7 @@ describe("environment shell synchronization", () => { generation: 0, lastFailure: null, retryAt: null, + routes: [], }); yield* Queue.offer(events, { kind: "snapshot", @@ -442,6 +447,7 @@ describe("environment shell synchronization", () => { generation: 1, lastFailure: null, retryAt: null, + routes: [], }); yield* Deferred.await(unenrichedSaveStarted); @@ -536,6 +542,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 4037e6cc128bf..cd825c3ad7ed6 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -395,6 +395,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 30c33deebd55c..a572c86c3a0df 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; @@ -601,6 +630,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", { @@ -838,6 +880,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 f00976d243b37..93f2f8a16760f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -42,6 +42,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 0466f245844a2..e7e18ec0aa795 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"; @@ -404,6 +405,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; @@ -499,6 +521,8 @@ export interface DesktopServerExposureState { advertisedHost: string | null; tailscaleServeEnabled: boolean; tailscaleServePort: number; + tailscaleSignInEnabled?: boolean; + port?: number; } export const DesktopServerExposureStateSchema = Schema.Struct({ @@ -507,6 +531,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 { @@ -1082,6 +1110,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 37ea5866ae733..a56f75eb6de2a 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, @@ -511,6 +517,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", @@ -818,6 +828,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, @@ -2003,6 +2039,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 c36f724b8239e..cf1e3e9bf7ae1 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, @@ -28,6 +29,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({ @@ -606,6 +608,99 @@ 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", +]); +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`; + } +} + export const ServerConfig = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, @@ -650,6 +745,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..c808c409ab099 --- /dev/null +++ b/packages/contracts/src/tailnet.ts @@ -0,0 +1,184 @@ +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. + */ +export const tailnetGrantSnippet = (src: string, dst: string, port: number): string => + [ + '"grants": [{', + ` "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 0b83cb3008d78..1f0ec00f2d7fe 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -1,22 +1,33 @@ +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, parseTailscaleSshEnabled, parseTailscaleStatus, + parseTailscaleWhois, + probeTailscaleHttpsEndpoint, readTailscaleSshEnabled, readTailscaleStatus, + readTailscaleWhois, + releaseTailscaleServe, TailscaleCommandExitError, TailscaleCommandSpawnError, TailscaleStatusParseError, + TailscaleWhoisParseError, } from "./tailscale.ts"; const encoder = new TextEncoder(); @@ -49,7 +60,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({ @@ -93,18 +152,144 @@ 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.effect("parses status facts", () => 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, + certDomains: [], + userspaceNetworking: null, + peers: [], + users: [], }); }), ); + 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, + 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( @@ -175,7 +360,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); @@ -188,7 +376,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), ); @@ -203,7 +391,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"); @@ -240,9 +428,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 1c803b27f1eb3..c0a6ca55f1049 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,10 +1,13 @@ +import * as NodeNet from "node:net"; + import * as HostProcess from "@t3tools/shared/HostProcess"; +import { isSharedAddressSpaceHost } 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; @@ -25,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"]), 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 @@ -129,19 +149,123 @@ 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 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({ MagicDNSEnabled: lenient(Schema.Boolean) })), + Self: lenient( + Schema.Struct({ + ...TailscaleStatusNodeFields, + CapMap: lenient(Schema.Record(Schema.String, Schema.Unknown)), + }), + ), + Peer: lenient( + Schema.Record( + Schema.String, + 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), + }), + ), + ), }); -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[]; /** @@ -150,9 +274,28 @@ 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; + 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[]; + /** 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( @@ -161,70 +304,99 @@ 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 peers = Object.values(parsed.Peer ?? {}).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, + certDomains: parsed.CertDomains ?? [], + userspaceNetworking: parsed.TUN === undefined ? null : !parsed.TUN, + peers, + users: Object.entries(parsed.User ?? {}).map(([userId, user]) => ({ + userId, + loginName: nonEmptyOrNull(user.LoginName), + })), }; }), ); -const readTailscaleOutput = Effect.fn("tailscale.readTailscaleOutput")(function* ( - subcommand: "status" | "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); @@ -233,11 +405,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. @@ -248,8 +441,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" }, @@ -257,26 +450,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, }), ), @@ -284,9 +476,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) })), @@ -335,7 +551,7 @@ export const parseTailscaleExitNode = ( }), ); -export const readTailscaleExitNode = readTailscaleOutput("status", ["status", "--json"]).pipe( +export const readTailscaleExitNode = readTailscaleStatusOutput("passive").pipe( Effect.flatMap(parseTailscaleExitNode), ); @@ -358,10 +574,97 @@ 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); +}); + export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; readonly servePort?: number; @@ -375,67 +678,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); }; export const disableTailscaleServe = Effect.fn("tailscale.disableTailscaleServe")(function* ( @@ -444,25 +701,211 @@ export const disableTailscaleServe = Effect.fn("tailscale.disableTailscaleServe" } = {}, ): Effect.fn.Return { const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT; - return yield* runTailscaleCommand( + yield* runTailscale( + "serve", ["serve", `--https=${servePort}`, "off"], TAILSCALE_SERVE_TIMEOUT, + "active", ); }); -export const probeTailscaleHttpsEndpoint = Effect.fn("tailscale.probeTailscaleHttpsEndpoint")( +const TailscaleServeConfigFields = { + AllowFunnel: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + TCP: Schema.optional( + Schema.Record( + Schema.String, + Schema.Struct({ + HTTPS: Schema.optional(Schema.Boolean), + TCPForward: Schema.optional(Schema.String), + }), + ), + ), + Web: Schema.optional( + Schema.Record( + Schema.String, + Schema.Struct({ + Handlers: Schema.optional( + Schema.Record(Schema.String, Schema.Struct({ Proxy: Schema.optional(Schema.String) })), + ), + }), + ), + ), +}; + +// 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 9f4ac5212e284..9d70d8281c2bb 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -124,7 +124,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) { From 1dd1a87e8aa1375b024037b925b1f96389f8f4f2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 01:25:01 -0700 Subject: [PATCH 002/114] fix(cli): t3 up's fallback serve command states --no-tailscale-serve Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.test.ts | 2 ++ apps/server/src/cli/up.ts | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index e087d15d1d79a..fdbfcdd8bd46b 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -252,6 +252,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: "", diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index a2ab5f8e28ea8..03dd3195eaac0 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -698,9 +698,9 @@ const setUp = Effect.fn("cli.up")(function* ( ...(routes.tailscale && planned.T3CODE_TAILSCALE_OWNER !== undefined ? ["--tailscale-owner", planned.T3CODE_TAILSCALE_OWNER] : []), - ...(routes.tailscale && parseBoolean(planned.T3CODE_TAILSCALE_SERVE) === true - ? ["--tailscale-serve"] - : []), + 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 these flags, as it does in service.env. From aa0890abe6f0d45fa2bdf218f52a68b6da9e912f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 01:31:30 -0700 Subject: [PATCH 003/114] fix(tailscale): t3 up keeps Tailscale when it is down, stops Serve with it, and saves a requested owner Also takes the tailnet sign-in services from the Effect environment. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.test.ts | 24 ++++ apps/server/src/cli/up.ts | 32 +++-- .../src/environment/DirectEndpoints.test.ts | 8 +- .../src/authorization/service.ts | 13 +- .../src/authorization/tailnet.ts | 134 +++++++++--------- 5 files changed, 125 insertions(+), 86 deletions(-) diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index fdbfcdd8bd46b..4a31401b47c08 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -14,6 +14,12 @@ import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/cli"; 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"), +})); + import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; import { upsertServiceEnv } from "./service.ts"; @@ -262,6 +268,24 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => restarts: 0, envAfter: "T3CODE_LAN=true\n", }, + { + 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", + 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\n", + }, { label: "the old default is kept", installed: true, diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 03dd3195eaac0..b2156f857a4a8 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -254,6 +254,9 @@ const formatNextSteps = (routes: Routes, serviceInstalled: boolean) => ]), ].join("\n"); +// Serve only runs with Tailscale on in `t3 up`, so turning Tailscale off stops it too. +const TAILSCALE_OFF = { T3CODE_TAILSCALE_SIGN_IN: undefined, T3CODE_TAILSCALE_SERVE: undefined }; + const interactiveTerminal = () => Boolean(process.stdin.isTTY && process.stdout.isTTY); interface Owner { @@ -410,14 +413,16 @@ const tailscaleEntries = Effect.fn("cli.up.tailscale_entries")(function* ( 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. - const owner = requireGrant - ? null - : yield* resolveOwner( - tailscale.status, - flags.owner, - entries.T3CODE_TAILSCALE_OWNER, - !flags.yes && interactiveTerminal(), - ); + // 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; @@ -586,13 +591,18 @@ const setUp = Effect.fn("cli.up")(function* ( } yield* CliService.writeServiceEnv(config.baseDir, { ...(current.lan ? { T3CODE_LAN: "false" } : {}), - ...(current.tailscale ? { T3CODE_TAILSCALE_SIGN_IN: undefined } : {}), + ...(current.tailscale ? TAILSCALE_OFF : {}), }); return yield* Console.log(formatNextSteps(routes, false)); } - if (routes.tailscale && !tailscale.ok) { + // 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} Tailscale settings stay as they are.`); + } const requireGrant = flags.requireGrant || before.requireGrant; const signIn = @@ -601,7 +611,7 @@ const setUp = Effect.fn("cli.up")(function* ( : null; const writes: Readonly> = { T3CODE_LAN: String(routes.lan), - ...(signIn?.entries ?? (current.tailscale ? { T3CODE_TAILSCALE_SIGN_IN: undefined } : {})), + ...(routes.tailscale ? (signIn?.entries ?? {}) : current.tailscale ? TAILSCALE_OFF : {}), }; const planned = Object.fromEntries( Object.entries({ ...before.entries, ...writes }).flatMap(([key, value]) => diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 20a64ef2ee6a5..ff52e57020ff4 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -204,7 +204,13 @@ describe("resolveBoundEndpoints", () => { "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 }), + resolveBoundEndpoints({ + platform: "linux", + host: "0.0.0.0", + port: 3773, + interfaces: INTERFACES, + ...mode, + }), ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }]); }, ); diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 44153361fbfbf..1072a7d05c5d4 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -18,7 +18,6 @@ import { mapRemoteEnvironmentError, } from "../connection/errors.ts"; import type { TailnetConnectionCredential } from "../connection/catalog.ts"; -import * as ConnectionCredentialStore from "../connection/credentialStore.ts"; import { ConnectionBlockedError, ConnectionTransientError, @@ -141,12 +140,8 @@ export const make = Effect.gen(function* () { const deviceIdentity = yield* ClientCapabilities.RelayDeviceIdentity; const presentation = yield* ClientCapabilities.ClientPresentation; const tokenStore = yield* TokenStore.RemoteDpopAccessTokenStore; - const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; const httpClient = yield* HttpClient.HttpClient; - const tailnet = makeTailnetSessions({ - credentials, - signer, - httpClient, + const tailnet = yield* makeTailnetSessions({ clientMetadata: presentation.metadata, scope: serviceScope, }); @@ -607,9 +602,11 @@ export const make = Effect.gen(function* () { signInTailnet: Effect.fn("clientRuntime.connection.remote.signInTailnet")(function* (input) { return yield* signInOverTailnet({ httpBaseUrl: input.httpBaseUrl, - signer, clientMetadata: presentation.metadata, - }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + }).pipe( + Effect.provideService(ManagedRelay.ManagedRelayDpopSigner, signer), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); }), authorizeTailnet, authorizeTailnetHttp: Effect.fn("clientRuntime.connection.remote.authorizeTailnetHttp")( diff --git a/packages/client-runtime/src/authorization/tailnet.ts b/packages/client-runtime/src/authorization/tailnet.ts index 4128ebc660129..55c7846a490ff 100644 --- a/packages/client-runtime/src/authorization/tailnet.ts +++ b/packages/client-runtime/src/authorization/tailnet.ts @@ -8,7 +8,7 @@ import type * as Scope from "effect/Scope"; import * as HttpClient from "effect/http/HttpClient"; import { TailnetConnectionCredential } from "../connection/catalog.ts"; -import type * as ConnectionCredentialStore from "../connection/credentialStore.ts"; +import * as ConnectionCredentialStore from "../connection/credentialStore.ts"; import { credentialMissingError, mapRemoteEnvironmentError } from "../connection/errors.ts"; import { ConnectionBlockedError, @@ -17,7 +17,7 @@ import { } from "../connection/model.ts"; import { tailnetRefusalError } from "../connection/tailnet.ts"; import { environmentEndpointUrl } from "../environment/endpoint.ts"; -import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiGroupClient } from "../rpc/http.ts"; import { exchangeRemoteDpopAccessToken } from "./remote.ts"; @@ -39,12 +39,12 @@ const proofError = () => export const signInOverTailnet = Effect.fn("clientRuntime.authorization.signInOverTailnet")( function* (input: { readonly httpBaseUrl: string; - readonly signer: ManagedRelayDpopSigner["Service"]; readonly clientMetadata?: AuthClientPresentationMetadata; }) { - const thumbprint = yield* input.signer.thumbprint.pipe(Effect.mapError(proofError)); + const signer = yield* ManagedRelayDpopSigner; + const thumbprint = yield* signer.thumbprint.pipe(Effect.mapError(proofError)); const signInUrl = environmentEndpointUrl(input.httpBaseUrl, "/api/auth/tailnet"); - const signInProof = yield* input.signer + const signInProof = yield* signer .createProof({ method: "POST", url: signInUrl }) .pipe(Effect.mapError(proofError)); const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "tailnet"); @@ -67,7 +67,7 @@ export const signInOverTailnet = Effect.fn("clientRuntime.authorization.signInOv answer.refused.suggestedGrant, ); } - const tokenProof = yield* input.signer + const tokenProof = yield* signer .createProof({ method: "POST", url: environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), @@ -94,67 +94,69 @@ export const signInOverTailnet = Effect.fn("clientRuntime.authorization.signInOv * 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 = (input: { - readonly credentials: ConnectionCredentialStore.ConnectionCredentialStore["Service"]; - readonly signer: ManagedRelayDpopSigner["Service"]; - readonly httpClient: HttpClient.HttpClient; - readonly clientMetadata: AuthClientPresentationMetadata; - readonly scope: Scope.Scope; -}) => { - const renewals = new Map< - string, - { - readonly id: object; - readonly fiber: Fiber.Fiber; - } - >(); +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, - signer: input.signer, - clientMetadata: input.clientMetadata, - }).pipe( - Effect.tap((fresh) => input.credentials.put(credentialId, fresh)), - Effect.provideService(HttpClient.HttpClient, input.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); - }); + 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* input.credentials.get(credentialId)); - if (stored === undefined || !isTailnetCredential(stored)) { - return yield* credentialMissingError(credentialId); - } - const thumbprint = yield* input.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 + /** 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, ) { - return stored; - } - return yield* renew(credentialId, stored); - }); + 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 }; -}; + return { session }; + }, +); From 66d02cb55ff9676a386dc4730e87255fb3c49879 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:08:00 -0700 Subject: [PATCH 004/114] fix(tailscale): live Tailscale sockets renew by signing in again, so they no longer drop every hour The socket renewal added for T3 Connect asked the relay for every DPoP session. A Tailscale session has no relay link, so its socket closed at the hour. Co-Authored-By: Claude Opus 5.5 --- .../src/authorization/layer.test.ts | 46 ++++++++++++++++++- .../src/authorization/service.ts | 19 +++++--- .../client-runtime/src/connection/driver.ts | 3 ++ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index 0084cba8e5de4..e0439d4e3c34c 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -137,6 +137,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( 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"; @@ -244,7 +245,13 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), ), put: (connectionId, credential) => - Ref.update(credentials, (current) => new Map(current).set(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); @@ -1195,6 +1202,43 @@ describe("RemoteEnvironmentAuthorization", () => { }), ); + 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({ diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 1072a7d05c5d4..767e45438fb40 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -93,6 +93,8 @@ 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: { @@ -637,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/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts index b9d5ae657a727..d1db6c5a8deec 100644 --- a/packages/client-runtime/src/connection/driver.ts +++ b/packages/client-runtime/src/connection/driver.ts @@ -232,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) => From 83805c5eda41f95b7c1aae6a139152f25e6ca264 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:17:21 -0700 Subject: [PATCH 005/114] fix(tailscale): a peer with no ID still counts in the policy scan, and t3 up's off switches stay off - A granted peer that Tailscale lists without an ID was dropped from the scan, so a grant could go unseen and the owner rule stay on. - --no-tailscale writes the Tailscale settings as false, so an inherited value cannot turn them back on, and --no-tailscale-serve no longer implies --tailscale. - A failed update of an installed service now exits with an error. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 12 +++++++++ apps/server/src/auth/tailnetGrants.ts | 16 +++++++++--- apps/server/src/cli/up.test.ts | 25 ++++++++++++++++--- apps/server/src/cli/up.ts | 14 ++++++----- .../src/environment/TailscaleHealth.test.ts | 1 + packages/tailscale/src/tailscale.test.ts | 1 + packages/tailscale/src/tailscale.ts | 17 ++++++++++++- 7 files changed, 71 insertions(+), 15 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index fedf0e9afb2d2..c26620bf58031 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -561,6 +561,18 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).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("a device that loses or narrows its grant, or leaves, is cut off", () => { const tailnet = withLaptop(); return Effect.gen(function* () { diff --git a/apps/server/src/auth/tailnetGrants.ts b/apps/server/src/auth/tailnetGrants.ts index 369abfc529bea..c2d4f82077be0 100644 --- a/apps/server/src/auth/tailnetGrants.ts +++ b/apps/server/src/auth/tailnetGrants.ts @@ -40,11 +40,19 @@ export const policyGrantedPeers = Effect.fn("tailnetGrants.policyGrantedPeers")( status: TailscaleStatus, whois: (address: string) => Effect.Effect, ) { - const results = yield* Effect.forEach( - status.peers, - (peer) => { + const peers = [ + ...status.peers.map((peer) => { const address = peer.tailnetIpv4Addresses[0] ?? peer.tailnetIpv6Addresses[0]; - const name = peer.hostName ?? address ?? peer.stableNodeId; + return { address, name: peer.hostName ?? address ?? peer.stableNodeId }; + }), + ...status.unidentifiedPeerAddresses.map((address) => ({ + address: address ?? undefined, + name: address ?? "a peer with no ID", + })), + ]; + const results = yield* Effect.forEach( + peers, + ({ address, name }) => { if (address === undefined) return Effect.succeed({ name, granted: null }); return whois(address).pipe( Effect.map((node) => ({ diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index 4a31401b47c08..c70da7637e3aa 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -167,6 +167,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => readonly status?: Partial; readonly installFails?: boolean; readonly restartFails?: 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; @@ -225,7 +227,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: "Nothing changed. Fix that, then run `t3 up` again.", + fails: true, installed: true, status: { current: false }, installFails: true, @@ -278,12 +281,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => envAfter: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=true\n", }, { - label: "turning Tailscale off stops Serve too", + 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. + label: "--no-tailscale-serve leaves Tailscale off", + installed: true, + env: "T3CODE_LAN=false\n", + args: ["--no-tailscale-serve"], + installs: 0, + restarts: 0, envAfter: "T3CODE_LAN=false\n", }, { @@ -362,9 +375,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, @@ -420,6 +436,7 @@ it.effect("a rerun on a tagged host keeps the owner saved before, with no prompt certDomains: [], userspaceNetworking: false, peers: [], + unidentifiedPeerAddresses: [], users: [{ userId: "123", loginName: "me@example.com" }], }; // Not interactive, as with --yes, so asking would fail with "Pass --owner". diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index b2156f857a4a8..dcc2bb6b84b03 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -255,7 +255,8 @@ const formatNextSteps = (routes: Routes, serviceInstalled: boolean) => ].join("\n"); // Serve only runs with Tailscale on in `t3 up`, so turning Tailscale off stops it too. -const TAILSCALE_OFF = { T3CODE_TAILSCALE_SIGN_IN: undefined, T3CODE_TAILSCALE_SERVE: undefined }; +// 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); @@ -461,7 +462,7 @@ const tailscaleEntries = Effect.fn("cli.up.tailscale_entries")(function* ( ...(owner === null ? {} : { T3CODE_TAILSCALE_OWNER: tagged ? owner.userId : undefined }), ...Option.match(flags.tailscaleServe, { onNone: () => ({}), - onSome: (on) => ({ T3CODE_TAILSCALE_SERVE: on ? "true" : undefined }), + onSome: (on) => ({ T3CODE_TAILSCALE_SERVE: on ? "true" : "false" }), }), }, }; @@ -498,7 +499,7 @@ const formatTailscale = ( "Let your untagged devices in without a grant: `t3 tailscale require-grant off`.", ] : [ - "Your untagged devices can sign in, agents on them included.", + "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"); @@ -743,8 +744,9 @@ const setUp = Effect.fn("cli.up")(function* ( 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} Nothing changed. Fix that, then run \`t3 up\` again.`, ); } else if ("failed" in result) { yield* printServeCommand(`Service setup failed: ${result.failed}`); @@ -866,7 +868,7 @@ export const upCommand = Command.make("up", { const tailscaleOnly = [ ...(Option.isSome(flags.owner) ? ["--owner"] : []), ...(flags.requireGrant ? ["--require-grant"] : []), - ...(Option.isSome(flags.tailscaleServe) ? ["--tailscale-serve"] : []), + ...(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(", ")}.`)); diff --git a/apps/server/src/environment/TailscaleHealth.test.ts b/apps/server/src/environment/TailscaleHealth.test.ts index d6df2bc1b5d48..c2c9737b265d3 100644 --- a/apps/server/src/environment/TailscaleHealth.test.ts +++ b/apps/server/src/environment/TailscaleHealth.test.ts @@ -36,6 +36,7 @@ const running: TailscaleStatus = { certDomains: ["studio.tail1234.ts.net"], userspaceNetworking: false, peers: [], + unidentifiedPeerAddresses: [], }; const context = { executable: "tailscale", subcommand: "status", argumentCount: 2 } as const; diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 1f0ec00f2d7fe..673bf823bece9 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -231,6 +231,7 @@ describe("tailscale", () => { certDomains: [], userspaceNetworking: null, peers: [], + unidentifiedPeerAddresses: [], users: [], }); }), diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index c0a6ca55f1049..92973ef628f21 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -291,6 +291,11 @@ export interface TailscaleStatus { */ readonly userspaceNetworking: boolean | null; readonly peers: readonly TailscalePeer[]; + /** + * The first tailnet address of each peer with no ID, or null with none. It cannot be + * tracked, but a scan of the tailnet policy must still read it. + */ + readonly unidentifiedPeerAddresses: ReadonlyArray; /** Users this node can see, by user ID. */ readonly users: ReadonlyArray<{ readonly userId: string; readonly loginName: string | null }>; } @@ -340,7 +345,16 @@ export const parseTailscaleStatus = ( decodeTailscaleStatusJson(rawStatusJson).pipe( Effect.map((parsed): TailscaleStatus => { const self = parsed.Self; - const peers = Object.values(parsed.Peer ?? {}).flatMap((peer): TailscalePeer[] => { + const peerEntries = Object.values(parsed.Peer ?? {}); + const unidentifiedPeerAddresses = peerEntries + .filter((peer) => nonEmptyOrNull(peer.ID) === null) + .map( + (peer) => + tailnetIpv4AddressesOf(peer.TailscaleIPs)[0] ?? + tailnetIpv6AddressesOf(peer.TailscaleIPs)[0] ?? + null, + ); + const peers = peerEntries.flatMap((peer): TailscalePeer[] => { const stableNodeId = nonEmptyOrNull(peer.ID); if (stableNodeId === null) return []; return [ @@ -382,6 +396,7 @@ export const parseTailscaleStatus = ( certDomains: parsed.CertDomains ?? [], userspaceNetworking: parsed.TUN === undefined ? null : !parsed.TUN, peers, + unidentifiedPeerAddresses, users: Object.entries(parsed.User ?? {}).map(([userId, user]) => ({ userId, loginName: nonEmptyOrNull(user.LoginName), From 16aaf9b606a432e575c8f018acbe4e9a2e619b6e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:22:03 -0700 Subject: [PATCH 006/114] fix(tailscale): turning sign-in off ends Tailscale sessions, and t3 up says who each route lets in - With sign-in off, the server ends Tailscale sessions when it starts, so turning sign-in off cuts devices off now instead of within the hour. - The Tailscale choice names who gets in; the LAN note always gives the command to turn it off; an older install's LAN access keeps its marker until someone chooses it. - A saved owner names the box in the printed grant when grants are required. - Docs: the question offers Tailscale too; a new service refuses LAN connections rather than "answers only on this machine". Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 18 ++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 21 +++++++++++++++- apps/server/src/cli/up.test.ts | 5 ++-- apps/server/src/cli/up.ts | 24 +++++++++++++------ .../settings/TailscaleSignInRow.tsx | 2 +- docs/user/background-service.md | 7 +++--- docs/user/remote-access.md | 5 ++-- 7 files changed, 66 insertions(+), 16 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index c26620bf58031..909f76979cd33 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -573,6 +573,24 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }, ); + 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 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); + }).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* () { diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 34ec065f28220..07ed0a93dcdda 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -204,7 +204,7 @@ const refuseNode = ( return null; }; -const make = Effect.gen(function* () { +export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const host = yield* TailscaleHost.TailscaleHost; const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; @@ -647,6 +647,25 @@ const make = Effect.gen(function* () { Effect.repeat(Schedule.spaced(RECHECK_INTERVAL)), Effect.forkScoped, ); + } else { + // Turning sign-in off ends Tailscale sessions now, not when they expire. + const signedIn = new Map( + (yield* sessions.listActive()) + .filter((session) => session.subject.startsWith(TAILNET_SUBJECT_PREFIX)) + .map((session) => [ + session.subject.slice(TAILNET_SUBJECT_PREFIX.length), + session.client.label ?? null, + ]), + ); + yield* Effect.forEach(signedIn, ([stableNodeId, label]) => + endAccess(stableNodeId, "sign_in_off", { device: label ?? stableNodeId, user: null }), + ).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not end Tailscale sessions after sign-in was turned off.", { + cause, + }), + ), + ); } return TailnetSignIn.of({ signIn, listDevices, blockDevice, unblockDevice, recheckSessions }); diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index c70da7637e3aa..a91e291d0d01d 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -300,13 +300,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => envAfter: "T3CODE_LAN=false\n", }, { - label: "the old default is kept", + // 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* () { diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index dcc2bb6b84b03..92972ef1ba5f9 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -175,7 +175,10 @@ export const planRoutes = ( }; const CHOICES = { - tailscale: { title: "Tailscale", description: "Tailnet devices. No pairing code." }, + tailscale: { + title: "Tailscale", + description: "Your untagged devices, or those your policy grants. 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; @@ -224,10 +227,10 @@ const formatSetup = (setup: Setup) => { ]; const notes = [ ...(setup.host === undefined && settings.lan - ? ["Anyone on your LAN or tailnet can reach the port. A pairing code is the only lock."] - : []), - ...(setup.lanFromOldDefault - ? ["Older installs keep LAN access on. Turn it off: `t3 up --no-lan`."] + ? [ + "Anyone on your LAN or tailnet can reach the port. A pairing code is the only lock.", + "Turn it off: `t3 up --no-lan`.", + ] : []), ...(settings.tailscale && setup.requireGrant ? ["No device can sign in through Tailscale until your tailnet policy grants it."] @@ -452,8 +455,12 @@ const tailscaleEntries = Effect.fn("cli.up.tailscale_entries")(function* ( `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 { - owner, + // A saved owner still names the box in the printed grant when grants are required. + owner: + owner ?? + (saved === undefined ? null : { userId: saved, login: loginOf(tailscale.status, saved) }), port, entries: { T3CODE_TAILSCALE_SIGN_IN: "true", @@ -553,7 +560,10 @@ const setUp = Effect.fn("cli.up")(function* ( ); 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( @@ -611,7 +621,7 @@ const setUp = Effect.fn("cli.up")(function* ( ? yield* tailscaleEntries(tailscale, flags, requireGrant) : null; const writes: Readonly> = { - T3CODE_LAN: String(routes.lan), + ...(lanChosen ? { T3CODE_LAN: String(routes.lan) } : {}), ...(routes.tailscale ? (signIn?.entries ?? {}) : current.tailscale ? TAILSCALE_OFF : {}), }; const planned = Object.fromEntries( diff --git a/apps/web/src/components/settings/TailscaleSignInRow.tsx b/apps/web/src/components/settings/TailscaleSignInRow.tsx index 7859994d6cf39..ac9f483a0d72e 100644 --- a/apps/web/src/components/settings/TailscaleSignInRow.tsx +++ b/apps/web/src/components/settings/TailscaleSignInRow.tsx @@ -35,7 +35,7 @@ export function TailscaleSignInRow({ ? "" : "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 within an hour. 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); diff --git a/docs/user/background-service.md b/docs/user/background-service.md index d0189451fcd26..2b56a74d14baa 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -44,9 +44,10 @@ under T3 home. Pass `--yes` from a script. ## Local network access -A new service answers only on this machine. Other devices reach it through -Tailscale or 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`). +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 diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 7dbb8a8f71ec0..dc636705aa554 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. From ef6ea573a1f9a8c880acc3a04ca760effb1bf1be Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:27:09 -0700 Subject: [PATCH 007/114] fix(tailscale): turning sign-in off also ends unused Tailscale sign-in codes Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 5 +++++ apps/server/src/auth/TailnetSignIn.ts | 19 ++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 909f76979cd33..a77e949b1a726 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -578,6 +578,9 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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, { @@ -588,6 +591,8 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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))); }); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 07ed0a93dcdda..e658c77d6e7f7 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -648,14 +648,19 @@ export const make = Effect.gen(function* () { Effect.forkScoped, ); } else { - // Turning sign-in off ends Tailscale sessions now, not when they expire. + // Turning sign-in off ends Tailscale sessions and unused sign-in codes now, not when + // they expire. + const now = yield* DateTime.now; const signedIn = new Map( - (yield* sessions.listActive()) - .filter((session) => session.subject.startsWith(TAILNET_SUBJECT_PREFIX)) - .map((session) => [ - session.subject.slice(TAILNET_SUBJECT_PREFIX.length), - session.client.label ?? null, - ]), + [ + ...(yield* sessions.listActive()).map((session) => ({ + subject: session.subject, + label: session.client.label ?? null, + })), + ...(yield* pairingLinks.listActive({ now })), + ] + .filter(({ subject }) => subject.startsWith(TAILNET_SUBJECT_PREFIX)) + .map(({ subject, label }) => [subject.slice(TAILNET_SUBJECT_PREFIX.length), label]), ); yield* Effect.forEach(signedIn, ([stableNodeId, label]) => endAccess(stableNodeId, "sign_in_off", { device: label ?? stableNodeId, user: null }), From 2b16c23640197e184663bd6ab3f21a7682444dae Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:31:03 -0700 Subject: [PATCH 008/114] fix(tailscale): explicit off switches are written and applied, and the desktop switch ignores inherited settings - --no-tailscale and --no-tailscale-serve write false even when the file says nothing (the service may inherit the setting) and restart to apply it, also while Tailscale is down here. - --no-tailscale-serve asks nothing and takes no new-machine defaults. - The desktop backend drops inherited T3CODE_TAILSCALE_SIGN_IN and _OWNER, so its own switch decides. - A failed sweep at start with sign-in off stops the start rather than leave a Tailscale session usable. - Copy: the Tailscale choice and the desktop row say grants decide first; a failed update says the service kept its old settings; a no-longer-tagged host does not print its old owner. Co-Authored-By: Claude Opus 5.5 --- .../DesktopBackendConfiguration.test.ts | 3 ++ .../backend/DesktopBackendConfiguration.ts | 2 ++ apps/server/src/auth/TailnetSignIn.ts | 7 +--- apps/server/src/cli/up.test.ts | 34 +++++++++++++++--- apps/server/src/cli/up.ts | 35 ++++++++++++++----- .../settings/TailscaleSignInRow.tsx | 4 +-- 6 files changed, 64 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 6cedcc84fd146..3ab5d706cb192 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -243,6 +243,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 583396bdda192..b6aa36ab90257 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. diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index e658c77d6e7f7..cb3c00921b453 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -662,14 +662,9 @@ export const make = Effect.gen(function* () { .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 }), - ).pipe( - Effect.catch((cause) => - Effect.logWarning("Could not end Tailscale sessions after sign-in was turned off.", { - cause, - }), - ), ); } diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index a91e291d0d01d..106f0a7a0a64e 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -53,6 +53,12 @@ it.each([ flags: noFlags, expected: { routes: { ...off, tailscale: true, lan: true }, ask: ["tailscale", "lan"] }, }, + { + 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 routes not named, even on a new machine", setup: { installed: false, current: off, available: both }, @@ -227,7 +233,7 @@ 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: "Nothing changed. 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 }, @@ -290,14 +296,32 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => envAfter: "T3CODE_LAN=false\nT3CODE_TAILSCALE_SIGN_IN=false\nT3CODE_TAILSCALE_SERVE=false\n", }, { - // Only turning Serve on implies Tailscale. - label: "--no-tailscale-serve leaves Tailscale off", + // 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: 0, - envAfter: "T3CODE_LAN=false\n", + 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", }, { // Not written as a choice, so status keeps calling it the older install's. diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 92972ef1ba5f9..cf2343026c5f2 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -148,9 +148,12 @@ export const planRoutes = ( /** False where this machine cannot offer the route. */ readonly available: Readonly>; }, - flags: RouteFlags, + flags: RouteFlags & { + /** Another setup flag was given, such as `--no-tailscale-serve`: nothing is asked. */ + readonly other?: boolean; + }, ) => { - const ask = [flags.tailscale, flags.connect, flags.lan].every(Option.isNone); + const ask = [flags.tailscale, flags.connect, flags.lan].every(Option.isNone) && !flags.other; const start: Routes = ask && !setup.installed ? { @@ -177,7 +180,7 @@ export const planRoutes = ( const CHOICES = { tailscale: { title: "Tailscale", - description: "Your untagged devices, or those your policy grants. No pairing code.", + 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." }, @@ -460,7 +463,9 @@ const tailscaleEntries = Effect.fn("cli.up.tailscale_entries")(function* ( // A saved owner still names the box in the printed grant when grants are required. owner: owner ?? - (saved === undefined ? null : { userId: saved, login: loginOf(tailscale.status, saved) }), + (saved === undefined || !tagged + ? null + : { userId: saved, login: loginOf(tailscale.status, saved) }), port, entries: { T3CODE_TAILSCALE_SIGN_IN: "true", @@ -545,7 +550,8 @@ const setUp = Effect.fn("cli.up")(function* ( hasCloudPublicConfig && (yield* CliState.readCliDesiredCloudLink) && (yield* CliState.readCliDesiredLinkMode) === "publish_only"; - const asks = [flags.tailscale, flags.connect, flags.lan].every(Option.isNone); + 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() @@ -556,7 +562,7 @@ const setUp = Effect.fn("cli.up")(function* ( current, available: { tailscale: tailscale.ok, connect: before.connect !== null }, }, - flags, + { ...flags, other }, ); const interactive = !flags.yes && interactiveTerminal(); let routes = plan.routes; @@ -620,9 +626,21 @@ const setUp = Effect.fn("cli.up")(function* ( routes.tailscale && tailscale.ok ? yield* tailscaleEntries(tailscale, flags, requireGrant) : null; + // An explicit off is written even when the file says nothing, since the service may + // inherit the setting, and a restart applies it. + const tailscaleOffChosen = Option.getOrUndefined(flags.tailscale) === false; + const serveOffChosen = Option.getOrUndefined(flags.tailscaleServe) === false; + const pinsOff = + (tailscaleOffChosen && before.entries.T3CODE_TAILSCALE_SIGN_IN !== "false") || + (serveOffChosen && before.entries.T3CODE_TAILSCALE_SERVE !== "false"); const writes: Readonly> = { ...(lanChosen ? { T3CODE_LAN: String(routes.lan) } : {}), - ...(routes.tailscale ? (signIn?.entries ?? {}) : current.tailscale ? TAILSCALE_OFF : {}), + ...(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]) => @@ -643,6 +661,7 @@ const setUp = Effect.fn("cli.up")(function* ( (before.service.current || newer) && (!sameSettings(before.settings, serviceSettings(planned, true)) || (routes.connect && !current.connect) || + pinsOff || stopped); // An older or broken service is reinstalled, which restarts it too. const update = before.service.installed && !before.service.current && !newer && !stopped; @@ -756,7 +775,7 @@ const setUp = Effect.fn("cli.up")(function* ( yield* restoreServiceEnv; // A failure, so a script sees that the running service kept its old access. return yield* upError( - `Service setup failed: ${result.failed} Nothing changed. Fix that, then run \`t3 up\` again.`, + `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}`); diff --git a/apps/web/src/components/settings/TailscaleSignInRow.tsx b/apps/web/src/components/settings/TailscaleSignInRow.tsx index ac9f483a0d72e..be9eaaf5d08a5 100644 --- a/apps/web/src/components/settings/TailscaleSignInRow.tsx +++ b/apps/web/src/components/settings/TailscaleSignInRow.tsx @@ -61,8 +61,8 @@ export function TailscaleSignInRow({ wslOnly ? "Not available while WSL only is on. Turn off WSL only to use it." : enabled - ? "Your own Tailscale devices connect with no pairing code." - : "Let your own Tailscale devices connect with no pairing code." + ? "Devices your tailnet policy grants, else your own untagged devices, connect with no pairing code." + : "Let devices your tailnet policy grants, else your own untagged devices, connect with no pairing code." } status={ movedPort ? ( From 79599019dd2bac258d4c403e58a0ccde57107859 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:41:54 -0700 Subject: [PATCH 009/114] fix(tailscale): Serve mappings from a stopped run are removed, and off switches always write and apply - With Serve off, the server removes a Serve mapping a previous run claimed but could not release (Tailscale was down), once Tailscale answers. - `--no-tailscale` restarts when only Serve was still unset. - With no service to run, explicit offs are still written. - `--no-tailscale-serve` and other runs that do not choose Tailscale no longer look up the owner again. - Copy no longer promises the owner fallback when grants are required. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.test.ts | 76 ++++++++++++++----- apps/server/src/cli/up.ts | 24 ++++-- apps/server/src/server.ts | 3 +- apps/server/src/tailscaleServe.test.ts | 69 +++++++++++++++-- apps/server/src/tailscaleServe.ts | 66 ++++++++++++++++ .../settings/TailscaleSignInRow.tsx | 6 +- 6 files changed, 208 insertions(+), 36 deletions(-) diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index 106f0a7a0a64e..a83b40be567de 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -12,6 +12,7 @@ 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. @@ -25,6 +26,26 @@ import * as BootService from "../cloud/bootService.ts"; import { upsertServiceEnv } from "./service.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, + certDomains: [], + userspaceNetworking: false, + peers: [], + unidentifiedPeerAddresses: [], + 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 }; @@ -173,6 +194,8 @@ 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; /** `t3 up` exits with an error. */ readonly fails?: boolean; /** Runs as if in a terminal, answering each question with this text. */ @@ -323,6 +346,35 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => 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", + }, + { + // 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", @@ -338,6 +390,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => const fs = yield* FileSystem.FileSystem; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-up-test-" }); yield* fs.writeFileString(`${baseDir}/service.env`, input.env); + if (input.tailscale !== undefined) { + vi.spyOn(Tailscale, "readTailscaleStatus").mockReturnValue(Effect.succeed(input.tailscale)); + } let installs = 0; let restarts = 0; const status = { @@ -445,27 +500,8 @@ it.each([ it.effect("a rerun on a tagged host keeps the owner saved before, with no prompt", () => Effect.gen(function* () { - const status = { - 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, - certDomains: [], - userspaceNetworking: false, - peers: [], - unidentifiedPeerAddresses: [], - users: [{ userId: "123", loginName: "me@example.com" }], - }; // Not interactive, as with --yes, so asking would fail with "Pass --owner". - expect(yield* resolveOwner(status, Option.none(), "123", false)).toEqual({ + expect(yield* resolveOwner(TAGGED_HOST, Option.none(), "123", false)).toEqual({ userId: "123", login: "me@example.com", }); diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index cf2343026c5f2..608ecb2e8cb34 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -580,6 +580,9 @@ const setUp = Effect.fn("cli.up")(function* ( 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], })), @@ -601,6 +604,10 @@ const setUp = Effect.fn("cli.up")(function* ( `No terminal. Choose access with ${offered.join(", ")}, or use --yes for the default. See \`t3 up --help\`.`, ); } + // An explicit off is written even when the file says nothing, since the service may + // inherit the setting, and a restart applies it. + const tailscaleOffChosen = Option.getOrUndefined(flags.tailscale) === false; + 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 && !routes.tailscale) { if (current.connect || before.connectRevoked) { @@ -608,7 +615,8 @@ const setUp = Effect.fn("cli.up")(function* ( } yield* CliService.writeServiceEnv(config.baseDir, { ...(current.lan ? { T3CODE_LAN: "false" } : {}), - ...(current.tailscale ? TAILSCALE_OFF : {}), + ...(current.tailscale || tailscaleOffChosen ? TAILSCALE_OFF : {}), + ...(serveOffChosen ? { T3CODE_TAILSCALE_SERVE: "false" } : {}), }); return yield* Console.log(formatNextSteps(routes, false)); } @@ -622,16 +630,18 @@ const setUp = Effect.fn("cli.up")(function* ( } 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 + routes.tailscale && tailscale.ok && tailscaleChosen ? yield* tailscaleEntries(tailscale, flags, requireGrant) : null; - // An explicit off is written even when the file says nothing, since the service may - // inherit the setting, and a restart applies it. - const tailscaleOffChosen = Option.getOrUndefined(flags.tailscale) === false; - const serveOffChosen = Option.getOrUndefined(flags.tailscaleServe) === false; const pinsOff = - (tailscaleOffChosen && before.entries.T3CODE_TAILSCALE_SIGN_IN !== "false") || + (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) } : {}), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6c9dc2e6a54fa..0af32cea9c15b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -855,12 +855,13 @@ const layerMakeServer = Layer.unwrap( ); const layerTailscaleServe = config.tailscaleServeEnabled ? TailscaleServe.layer({ + stateDir: config.stateDir, servePort: config.tailscaleServePort, parked: tailscaleParked, settled: tailscaleSettled, awaitActivation, }) - : Layer.empty; + : TailscaleServe.layerReleaseLeftover({ stateDir: config.stateDir }); const layerCloudDesiredLinkReconcile = Layer.effectDiscard( Effect.gen(function* () { const cloudLink = yield* CloudLink.CloudLink; diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index d9dd29bbb9625..33405fae8c875 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -5,7 +5,9 @@ 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"; @@ -29,12 +31,15 @@ interface FakeOptions { 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; } // 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, }; @@ -54,6 +59,10 @@ const makeFakeTailscale = (options: FakeOptions) => { ): { code: number; stdout?: string; stderr?: string; ackDelay?: Duration.Input } | undefined => { const command = args.join(" "); if (command === "serve status --json") { + state.statusCalls += 1; + if (state.statusCalls <= (options.statusFailures ?? 0)) { + return { code: 1, stderr: "failed to connect to local tailscaled" }; + } return { code: 0, stdout: serveStatus() }; } if (args[0] === "serve" && args[1] === "--bg") { @@ -102,22 +111,48 @@ const makeFakeTailscale = (options: FakeOptions) => { return { state, spawner, mappingApplied }; }; +const STATE_DIR = "/state"; +const CLAIM_PATH = "/state/tailscale-serve-claim.json"; + +// In memory, so writes land within the test clock's steps. +const memoryFileSystem = (files: Map) => { + 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) => Effect.sync(() => void files.set(path, data)), + makeDirectory: () => Effect.void, + remove: (path) => Effect.sync(() => void files.delete(path)), + }); +}; + +const fakeLayers = (fake: ReturnType, files: Map) => + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, fake.spawner), + Layer.succeed(HostProcess.Platform, "linux"), + Layer.succeed(FileSystem.FileSystem, memoryFileSystem(files)), + Path.layer, + ); + const startTailscaleServe = Effect.fn(function* (options: FakeOptions = {}) { const fake = makeFakeTailscale(options); + const files = new Map(); 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.mergeAll( - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, fake.spawner), - Layer.succeed(HostProcess.Platform, "linux"), + Layer.merge( + fakeLayers(fake, files), Layer.mock(HttpServer.HttpServer)({ address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 18638), }), @@ -126,7 +161,7 @@ const startTailscaleServe = Effect.fn(function* (options: FakeOptions = {}) { ), scope, ); - return { state: fake.state, mappingApplied: fake.mappingApplied, scope, settled }; + return { state: fake.state, mappingApplied: fake.mappingApplied, scope, settled, files }; }); describe("TailscaleServe", () => { @@ -171,13 +206,37 @@ describe("TailscaleServe", () => { it.effect("removes its Serve root on shutdown after a later attempt succeeded", () => Effect.gen(function* () { - const { state, scope } = yield* startTailscaleServe({ refusedServeAttempts: 1 }); + 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("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, `{"localPort":18638,"localHost":"127.0.0.1","servePort":${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); }), ); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index cdda6dbae0ff1..629a6e74a89fd 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -1,9 +1,13 @@ import { claimTailscaleServe, releaseTailscaleServe } from "@t3tools/tailscale"; 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 Option from "effect/Option"; +import * as Path from "effect/Path"; 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"; @@ -32,6 +36,17 @@ const remedyFor = (cause: ClaimError, manualCommand: string) => { } }; +/** + * The mapping this server set up, kept until it is removed. A run with Serve off removes + * one that is left, such as when Tailscale was down as the last run stopped. + */ +const CLAIM_FILE = "tailscale-serve-claim.json"; +const ServeClaim = Schema.fromJsonString( + Schema.Struct({ localPort: Schema.Number, localHost: Schema.String, servePort: Schema.Number }), +); +const encodeClaim = Schema.encodeEffect(ServeClaim); +const decodeClaim = Schema.decodeUnknownEffect(ServeClaim); + const failureKey = (cause: ClaimError) => cause._tag === "TailscaleCommandExitError" ? `exit:${cause.stderrDiagnostic}` : cause._tag; @@ -41,6 +56,7 @@ const failureKey = (cause: ClaimError) => * 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; @@ -63,6 +79,8 @@ export const layer = (options: { localHost: loopbackUrlHostForAddress(address), }; const logContext = { localPort: target.localPort, servePort: target.servePort }; + const fs = yield* FileSystem.FileSystem; + const claimPath = (yield* Path.Path).join(options.stateDir, CLAIM_FILE); const manualCommand = `tailscale serve --bg --https=${target.servePort} http://${target.localHost}:${target.localPort}`; const configured = yield* Ref.make(false); @@ -73,6 +91,7 @@ export const layer = (options: { Effect.flatMap((wasConfigured) => wasConfigured ? releaseTailscaleServe(target).pipe( + Effect.tap(() => fs.remove(claimPath, { force: true })), Effect.tap((released) => Effect.logInfo( released @@ -99,6 +118,18 @@ export const layer = (options: { // Shutdown waits for the commands' own timeouts instead. const attempt = claimTailscaleServe(target).pipe( Effect.tap(() => Ref.set(configured, true)), + Effect.tap(() => + encodeClaim(target).pipe( + Effect.flatMap((claim) => + fs + .makeDirectory(options.stateDir, { recursive: true }) + .pipe(Effect.andThen(fs.writeFileString(claimPath, claim))), + ), + Effect.catch((cause) => + Effect.logWarning("Could not record the Tailscale Serve mapping", { cause }), + ), + ), + ), Effect.uninterruptible, Effect.tap(() => Effect.logInfo("Tailscale Serve configured", logContext)), Effect.tapError((cause) => { @@ -117,3 +148,38 @@ export const layer = (options: { yield* attempt.pipe(Effect.retry(retrySchedule), Effect.forkScoped); }), ); + +/** + * With Serve off, removes the mapping an earlier run set up but could not remove, and + * retries until Tailscale answers. Does nothing when no such mapping was recorded. + */ +export const layerReleaseLeftover = (options: { readonly stateDir: string }) => + Layer.effectDiscard( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const claimPath = (yield* Path.Path).join(options.stateDir, CLAIM_FILE); + const raw = yield* fs.readFileString(claimPath).pipe(Effect.option); + if (Option.isNone(raw)) return; + const claim = yield* decodeClaim(raw.value).pipe(Effect.option); + yield* ( + Option.isNone(claim) + ? Effect.void + : releaseTailscaleServe(claim.value).pipe( + Effect.retry(retrySchedule), + Effect.tap((released) => + released + ? Effect.logInfo( + "Removed the Tailscale Serve mapping left from when Serve was on", + ) + : Effect.void, + ), + ) + ).pipe( + Effect.andThen(fs.remove(claimPath, { force: true })), + Effect.catch((cause) => + Effect.logWarning("Could not remove the leftover Tailscale Serve record", { cause }), + ), + Effect.forkScoped, + ); + }), + ); diff --git a/apps/web/src/components/settings/TailscaleSignInRow.tsx b/apps/web/src/components/settings/TailscaleSignInRow.tsx index be9eaaf5d08a5..fd1955eb25ff1 100644 --- a/apps/web/src/components/settings/TailscaleSignInRow.tsx +++ b/apps/web/src/components/settings/TailscaleSignInRow.tsx @@ -30,7 +30,7 @@ export function TailscaleSignInRow({ 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, only the devices it grants get in. ${ + ? `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. " @@ -61,8 +61,8 @@ export function TailscaleSignInRow({ wslOnly ? "Not available while WSL only is on. Turn off WSL only to use it." : enabled - ? "Devices your tailnet policy grants, else your own untagged devices, connect with no pairing code." - : "Let devices your tailnet policy grants, else your own untagged devices, connect with no pairing code." + ? "Devices your tailnet policy grants connect with no pairing code. With no grant, so do your own untagged devices, unless grants are required." + : "Let devices your tailnet policy grants connect with no pairing code. With no grant, your own untagged devices can too, unless grants are required." } status={ movedPort ? ( From d2306d987f115497ddc3d62139c42a30c2cce8fb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:43:06 -0700 Subject: [PATCH 010/114] test(cli): --no-tailscale-serve writes Serve off with no service to run Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index a83b40be567de..2c5455a183e13 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -364,6 +364,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => 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", + }, { // 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", From 6c79c1a20637f7ca76f54d3dc235e89b9b0bb9b9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:45:13 -0700 Subject: [PATCH 011/114] docs(tailscale): say what turning sign-in off does, and that a revoked Tailscale session signs in again Also: a down-Tailscale `--no-tailscale-serve` run says Serve turns off, the `--tailscale-serve` help says only the on form implies `--tailscale`, and the sign-in-off log names a device by its session label. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.ts | 3 ++- apps/server/src/cli/up.ts | 6 ++++-- docs/user/remote-access.md | 8 +++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index cb3c00921b453..6d3602a90b75d 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -651,13 +651,14 @@ export const make = Effect.gen(function* () { // 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, })), - ...(yield* pairingLinks.listActive({ now })), ] .filter(({ subject }) => subject.startsWith(TAILNET_SUBJECT_PREFIX)) .map(({ subject, label }) => [subject.slice(TAILNET_SUBJECT_PREFIX.length), label]), diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 608ecb2e8cb34..a2b1e2f0ea564 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -626,7 +626,9 @@ const setUp = Effect.fn("cli.up")(function* ( return yield* upError(`${tailscale.reason} Then run t3 up again.`); } if (tailscaleKept && !tailscale.ok && !(plan.ask.length > 0 && interactive)) { - yield* Console.log(`${tailscale.reason} Tailscale settings stay as they are.`); + yield* Console.log( + `${tailscale.reason} ${serveOffChosen ? "Serve turns off. Other Tailscale settings" : "Tailscale settings"} stay as they are.`, + ); } const requireGrant = flags.requireGrant || before.requireGrant; @@ -870,7 +872,7 @@ export const upCommand = Command.make("up", { ), tailscaleServe: Flag.Boolean("tailscale-serve").pipe( Flag.withDescription( - "Serve HTTPS through Tailscale Serve. Turn off with --no-tailscale-serve. Implies --tailscale.", + "Serve HTTPS through Tailscale Serve. Implies --tailscale. --no-tailscale-serve turns it off and leaves sign-in as it is.", ), Flag.optional, ), diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index dc636705aa554..c34c3654bf5c9 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -260,6 +260,10 @@ device connected to that machine, and **Block** it under **Tailscale devices on **. Its sessions there end now, and **Unblock** undoes it. 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` or turn it off in +**Settings → Sharing & access**. T3 Code restarts and ends every Tailscale +session and unused Tailscale sign-in code. + 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 @@ -497,7 +501,9 @@ 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, +change its grant, or turn [Tailscale sign-in](#tailscale-sign-in) off. Command-line management is available through `t3 auth --help`. A session with an open connection stays listed after its access credential From 980ce149a6d7edf1bf44278f9aafc35429b21217 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:49:08 -0700 Subject: [PATCH 012/114] fix(server): shutdown removes a Serve mapping whose claim landed but failed its check The claim is recorded before the first attempt and shutdown always releases. Release removes only a root that points at this server, so it is a no-op when no claim landed. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/tailscaleServe.test.ts | 19 ++++++++- apps/server/src/tailscaleServe.ts | 59 +++++++++----------------- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index 33405fae8c875..8cf91e7039d44 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -33,6 +33,8 @@ interface FakeOptions { 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; } // Stands in for the tailscale CLI and keeps the Serve root like the daemon does. @@ -42,6 +44,7 @@ const makeFakeTailscale = (options: FakeOptions) => { statusCalls: 0, releases: 0, root: options.heldBy ?? null, + statusFailurePending: false, }; // Completes when the daemon takes the mapping, before the CLI exits. const mappingApplied = Deferred.makeUnsafe(); @@ -60,7 +63,8 @@ const makeFakeTailscale = (options: FakeOptions) => { const command = args.join(" "); if (command === "serve status --json") { state.statusCalls += 1; - if (state.statusCalls <= (options.statusFailures ?? 0)) { + 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() }; @@ -74,6 +78,7 @@ const makeFakeTailscale = (options: FakeOptions) => { 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 }) }; } @@ -256,6 +261,18 @@ describe("TailscaleServe", () => { }), ); + 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({ diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 629a6e74a89fd..9d52ff315065e 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -5,7 +5,6 @@ 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 Schema from "effect/Schema"; import { HttpServer } from "effect/http"; @@ -82,32 +81,27 @@ export const layer = (options: { const fs = yield* FileSystem.FileSystem; const claimPath = (yield* Path.Path).join(options.stateDir, CLAIM_FILE); const manualCommand = `tailscale serve --bg --https=${target.servePort} http://${target.localHost}:${target.localPort}`; - const configured = yield* Ref.make(false); - - // Added before the retry fiber is forked, so it runs after that fiber - // is interrupted and sees the final state. + // Recorded before the first claim, since a claim can apply the mapping and still fail. + // Release removes only a root that points here, so it is safe when no claim landed. + yield* encodeClaim(target).pipe( + Effect.flatMap((claim) => + fs + .makeDirectory(options.stateDir, { recursive: true }) + .pipe(Effect.andThen(fs.writeFileString(claimPath, claim))), + ), + Effect.catch((cause) => + Effect.logWarning("Could not record the Tailscale Serve mapping", { cause }), + ), + ); + // Added before the retry fiber is forked, so it runs after that fiber is interrupted. yield* Effect.addFinalizer(() => - Ref.get(configured).pipe( - Effect.flatMap((wasConfigured) => - wasConfigured - ? releaseTailscaleServe(target).pipe( - Effect.tap(() => fs.remove(claimPath, { force: true })), - Effect.tap((released) => - Effect.logInfo( - released - ? "Tailscale Serve disabled" - : "Tailscale Serve no longer points at this server; leaving it in place", - logContext, - ), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to disable Tailscale Serve", { - cause, - ...logContext, - }), - ), - ) - : Effect.void, + releaseTailscaleServe(target).pipe( + Effect.tap(() => fs.remove(claimPath, { force: true })), + Effect.tap((released) => + released ? Effect.logInfo("Tailscale Serve disabled", logContext) : Effect.void, + ), + Effect.catch((cause) => + Effect.logWarning("Failed to disable Tailscale Serve", { cause, ...logContext }), ), ), ); @@ -117,19 +111,6 @@ export const layer = (options: { // between the two would leave it behind with nothing to remove it. // Shutdown waits for the commands' own timeouts instead. const attempt = claimTailscaleServe(target).pipe( - Effect.tap(() => Ref.set(configured, true)), - Effect.tap(() => - encodeClaim(target).pipe( - Effect.flatMap((claim) => - fs - .makeDirectory(options.stateDir, { recursive: true }) - .pipe(Effect.andThen(fs.writeFileString(claimPath, claim))), - ), - Effect.catch((cause) => - Effect.logWarning("Could not record the Tailscale Serve mapping", { cause }), - ), - ), - ), Effect.uninterruptible, Effect.tap(() => Effect.logInfo("Tailscale Serve configured", logContext)), Effect.tapError((cause) => { From 0726cb6498877c65bcbd7935a154d7dcc3952a90 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 18:56:31 -0700 Subject: [PATCH 013/114] fix(tailscale): a Serve port change cleans up the old mapping, and off means off for hand-started servers too - Serve: a record from a run on another Serve port is released before this run records its own, so the old HTTPS route no longer outlives the change. - `t3 up` with no service and a hand-started server running says that server keeps its access until it stops, instead of "Remote access is off". - Leaving Tailscale unchecked in the question pins it off, like `--no-tailscale`. - Docs: removing the last grant lets untagged devices back in; only the service or the desktop app restarts on sign-in off. Desktop copy uses the CLI's words. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.test.ts | 33 ++++++ apps/server/src/cli/up.ts | 19 +++- apps/server/src/tailscaleServe.test.ts | 46 ++++++-- apps/server/src/tailscaleServe.ts | 105 ++++++++++++------ .../settings/TailscaleSignInRow.tsx | 4 +- docs/user/remote-access.md | 11 +- 6 files changed, 167 insertions(+), 51 deletions(-) diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index 2c5455a183e13..28313c756df4e 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -196,6 +196,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => 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; /** `t3 up` exits with an error. */ readonly fails?: boolean; /** Runs as if in a terminal, answering each question with this text. */ @@ -373,6 +375,30 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => 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", + }, + { + // 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", @@ -399,6 +425,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => const fs = yield* FileSystem.FileSystem; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-up-test-" }); yield* fs.writeFileString(`${baseDir}/service.env`, input.env); + if (input.handStarted === true) { + yield* fs.makeDirectory(`${baseDir}/userdata`); + 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)); } diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index a2b1e2f0ea564..e3054883cbf2d 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -605,8 +605,10 @@ const setUp = Effect.fn("cli.up")(function* ( ); } // An explicit off is written even when the file says nothing, since the service may - // inherit the setting, and a restart applies it. - const tailscaleOffChosen = Option.getOrUndefined(flags.tailscale) === false; + // 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 && !routes.tailscale) { @@ -618,7 +620,18 @@ const setUp = Effect.fn("cli.up")(function* ( ...(current.tailscale || tailscaleOffChosen ? TAILSCALE_OFF : {}), ...(serveOffChosen ? { T3CODE_TAILSCALE_SERVE: "false" } : {}), }); - return yield* Console.log(formatNextSteps(routes, 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), + ); + return yield* Console.log( + handStarted + ? "T3 Code is running here outside the background service, with the access it started with. Stop it to end that access." + : 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); diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index 8cf91e7039d44..2478a479dbc1f 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -20,6 +20,7 @@ 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 { @@ -35,6 +36,10 @@ interface FakeOptions { 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; } // Stands in for the tailscale CLI and keeps the Serve root like the daemon does. @@ -44,19 +49,28 @@ const makeFakeTailscale = (options: FakeOptions) => { statusCalls: 0, releases: 0, root: options.heldBy ?? null, + otherRoot: options.heldOnOtherPort ?? null, statusFailurePending: false, }; // Completes when the daemon takes the mapping, before the CLI exits. const mappingApplied = Deferred.makeUnsafe(); - const serveStatus = () => - state.root === null + 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: { [SERVE_PORT]: { HTTPS: true } }, - Web: { - [`desktop.tail.ts.net:${SERVE_PORT}`]: { Handlers: { "/": { Proxy: state.root } } }, - }, + 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 => { @@ -82,6 +96,10 @@ const makeFakeTailscale = (options: FakeOptions) => { 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; @@ -143,7 +161,7 @@ const fakeLayers = (fake: ReturnType, files: Map(); + 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(); @@ -223,6 +241,20 @@ describe("TailscaleServe", () => { }), ); + 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: `{"localPort":18638,"localHost":"127.0.0.1","servePort":${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("with Serve off, removes a mapping a stopped run left, once Tailscale answers", () => Effect.gen(function* () { const fake = makeFakeTailscale({ heldBy: LOCAL_TARGET, statusFailures: 2 }); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 9d52ff315065e..1198e770a8fa3 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -5,13 +5,16 @@ 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 Schema from "effect/Schema"; import { HttpServer } from "effect/http"; import { loopbackUrlHostForAddress } from "./startupAccess.ts"; -type ClaimError = Effect.Error>; +type ClaimError = + | Effect.Error> + | Effect.Error>; // 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. @@ -43,8 +46,28 @@ const CLAIM_FILE = "tailscale-serve-claim.json"; const ServeClaim = Schema.fromJsonString( Schema.Struct({ localPort: Schema.Number, localHost: Schema.String, servePort: Schema.Number }), ); +type ServeClaim = typeof ServeClaim.Type; const encodeClaim = Schema.encodeEffect(ServeClaim); const decodeClaim = Schema.decodeUnknownEffect(ServeClaim); +const readClaim = (fs: FileSystem.FileSystem, claimPath: string) => + fs.readFileString(claimPath).pipe(Effect.flatMap(decodeClaim), Effect.option); +// A lost record only costs the cleanup after a crash, so it never stops Serve. +const writeClaim = ( + fs: FileSystem.FileSystem, + stateDir: string, + claimPath: string, + claim: ServeClaim, +) => + encodeClaim(claim).pipe( + Effect.flatMap((json) => + fs + .makeDirectory(stateDir, { recursive: true }) + .pipe(Effect.andThen(fs.writeFileString(claimPath, json))), + ), + Effect.catch((cause) => + Effect.logWarning("Could not record the Tailscale Serve mapping", { cause }), + ), + ); const failureKey = (cause: ClaimError) => cause._tag === "TailscaleCommandExitError" ? `exit:${cause.stderrDiagnostic}` : cause._tag; @@ -81,27 +104,29 @@ export const layer = (options: { const fs = yield* FileSystem.FileSystem; const claimPath = (yield* Path.Path).join(options.stateDir, CLAIM_FILE); const manualCommand = `tailscale serve --bg --https=${target.servePort} http://${target.localHost}:${target.localPort}`; - // Recorded before the first claim, since a claim can apply the mapping and still fail. - // Release removes only a root that points here, so it is safe when no claim landed. - yield* encodeClaim(target).pipe( - Effect.flatMap((claim) => - fs - .makeDirectory(options.stateDir, { recursive: true }) - .pipe(Effect.andThen(fs.writeFileString(claimPath, claim))), - ), - Effect.catch((cause) => - Effect.logWarning("Could not record the Tailscale Serve mapping", { cause }), + // A run on another Serve port left this record, so its mapping is removed first. On + // this port the claim itself replaces a stopped server's root and keeps a live one. + const previous = yield* Ref.make( + Option.filter( + yield* readClaim(fs, claimPath), + (claim) => claim.servePort !== target.servePort, ), ); + const recorded = yield* Ref.make(false); // Added before the retry fiber is forked, so it runs after that fiber is interrupted. yield* Effect.addFinalizer(() => - releaseTailscaleServe(target).pipe( - Effect.tap(() => fs.remove(claimPath, { force: true })), - Effect.tap((released) => - released ? Effect.logInfo("Tailscale Serve disabled", logContext) : Effect.void, - ), + Effect.gen(function* () { + if (!(yield* Ref.get(recorded))) 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* fs.remove(claimPath, { force: true }); + }).pipe( Effect.catch((cause) => - Effect.logWarning("Failed to disable Tailscale Serve", { cause, ...logContext }), + Effect.logDebug("Left the Tailscale Serve record for the next run", { cause }), ), ), ); @@ -110,7 +135,20 @@ export const layer = (options: { // 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 = claimTailscaleServe(target).pipe( + const attempt = Effect.gen(function* () { + const pending = yield* Ref.get(previous); + if (Option.isSome(pending)) { + yield* releaseTailscaleServe(pending.value); + yield* Ref.set(previous, Option.none()); + } + // 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. + if (!(yield* Ref.get(recorded))) { + yield* writeClaim(fs, options.stateDir, claimPath, target); + yield* Ref.set(recorded, true); + } + yield* claimTailscaleServe(target); + }).pipe( Effect.uninterruptible, Effect.tap(() => Effect.logInfo("Tailscale Serve configured", logContext)), Effect.tapError((cause) => { @@ -139,23 +177,20 @@ export const layerReleaseLeftover = (options: { readonly stateDir: string }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const claimPath = (yield* Path.Path).join(options.stateDir, CLAIM_FILE); - const raw = yield* fs.readFileString(claimPath).pipe(Effect.option); - if (Option.isNone(raw)) return; - const claim = yield* decodeClaim(raw.value).pipe(Effect.option); - yield* ( - Option.isNone(claim) - ? Effect.void - : releaseTailscaleServe(claim.value).pipe( - Effect.retry(retrySchedule), - Effect.tap((released) => - released - ? Effect.logInfo( - "Removed the Tailscale Serve mapping left from when Serve was on", - ) - : Effect.void, - ), - ) - ).pipe( + const claim = yield* readClaim(fs, claimPath); + if (Option.isNone(claim)) return; + yield* releaseTailscaleServe(claim.value).pipe( + Effect.tapError((cause) => + Effect.logDebug("Tailscale did not answer; retrying the leftover Serve removal", { + cause, + }), + ), + Effect.retry(retrySchedule), + Effect.tap((released) => + released + ? Effect.logInfo("Removed the Tailscale Serve mapping left from when Serve was on") + : Effect.void, + ), Effect.andThen(fs.remove(claimPath, { force: true })), Effect.catch((cause) => Effect.logWarning("Could not remove the leftover Tailscale Serve record", { cause }), diff --git a/apps/web/src/components/settings/TailscaleSignInRow.tsx b/apps/web/src/components/settings/TailscaleSignInRow.tsx index fd1955eb25ff1..98b65b4326a9a 100644 --- a/apps/web/src/components/settings/TailscaleSignInRow.tsx +++ b/apps/web/src/components/settings/TailscaleSignInRow.tsx @@ -61,8 +61,8 @@ export function TailscaleSignInRow({ wslOnly ? "Not available while WSL only is on. Turn off WSL only to use it." : enabled - ? "Devices your tailnet policy grants connect with no pairing code. With no grant, so do your own untagged devices, unless grants are required." - : "Let devices your tailnet policy grants connect with no pairing code. With no grant, your own untagged devices can too, unless grants are required." + ? "Devices your tailnet policy grants connect with no pairing code. While it grants no device here, so do your own untagged devices, unless grants are required." + : "Let devices your tailnet policy grants connect with no pairing code. While it grants no device here, your own untagged devices can too, unless grants are required." } status={ movedPort ? ( diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index c34c3654bf5c9..c5ec33f13e1c5 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -261,8 +261,9 @@ device connected to that machine, and **Block** it under **Tailscale devices on 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` or turn it off in -**Settings → Sharing & access**. T3 Code restarts and ends every Tailscale -session and unused Tailscale sign-in code. +**Settings → Sharing & access**. The background service or the desktop app +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 @@ -502,8 +503,10 @@ 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. A device that -signed in through Tailscale signs in again at once, so to keep it out, block it, -change its grant, or turn [Tailscale sign-in](#tailscale-sign-in) off. Command-line +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. Command-line management is available through `t3 auth --help`. A session with an open connection stays listed after its access credential From 51ce5b88403686ccc31d8881e379d5df47d44df9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:02:18 -0700 Subject: [PATCH 014/114] fix(server): Serve cleanup records survive a local port change, a failed write, and a stopped server - A record for any other target, not only another Serve port, is released first. - Serve waits until its cleanup record is saved. - `t3 up` with no service and nothing running removes a recorded leftover mapping before it says remote access is off. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.test.ts | 23 +++++++- apps/server/src/cli/up.ts | 23 ++++++-- apps/server/src/tailscaleServe.test.ts | 46 +++++++++++++-- apps/server/src/tailscaleServe.ts | 81 +++++++++++++++----------- 4 files changed, 128 insertions(+), 45 deletions(-) diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index 28313c756df4e..0213111112c56 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -198,6 +198,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => readonly tailscale?: Tailscale.TailscaleStatus; /** A server started by hand runs for this T3 home. */ readonly handStarted?: boolean; + /** A stopped server left a Serve mapping record. */ + readonly serveRecord?: boolean; /** `t3 up` exits with an error. */ readonly fails?: boolean; /** Runs as if in a terminal, answering each question with this text. */ @@ -387,6 +389,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => 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, + serveRecord: true, + 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", @@ -425,8 +438,16 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => 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 === true) { + yield* fs.makeDirectory(`${baseDir}/userdata`, { recursive: true }); + yield* fs.writeFileString( + `${baseDir}/userdata/tailscale-serve-claim.json`, + `{"localPort":3773,"localHost":"127.0.0.1","servePort":443}`, + ); + vi.spyOn(Tailscale, "releaseTailscaleServe").mockReturnValue(Effect.succeed(true)); + } if (input.handStarted === true) { - yield* fs.makeDirectory(`${baseDir}/userdata`); + 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"}`, diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index e3054883cbf2d..bdaa0d21a7008 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -27,6 +27,7 @@ import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; import * as ServerConfig from "../config.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { renderTerminalQrCode } from "../startupAccess.ts"; +import * as TailscaleServe from "../tailscaleServe.ts"; import { isSupportedTailscaleVersion, MINIMUM_TAILSCALE_VERSION, @@ -627,11 +628,25 @@ const setUp = Effect.fn("cli.up")(function* ( runtime, (state) => state.serviceManaged !== true && isProcessAlive(state.pid), ); - return yield* Console.log( - handStarted - ? "T3 Code is running here outside the background service, with the access it started with. Stop it to end that access." - : formatNextSteps(routes, false), + if (handStarted) { + return yield* Console.log( + "T3 Code is running here outside the background service, with the access it started with. Stop it to end that access.", + ); + } + // Nothing runs here to remove a Serve mapping a stopped server left, so do it now. + yield* TailscaleServe.releaseRecorded(config.stateDir).pipe( + Effect.flatMap((released) => + released + ? Console.log("Removed a Tailscale Serve mapping an earlier run left.") + : Effect.void, + ), + Effect.catch(() => + Console.log( + "Tailscale did not answer, so a Serve mapping an earlier run left may remain. Run this again once Tailscale runs.", + ), + ), ); + 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); diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index 2478a479dbc1f..f586f8b84b650 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -40,6 +40,8 @@ interface FakeOptions { 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; } // Stands in for the tailscale CLI and keeps the Serve root like the daemon does. @@ -138,24 +140,31 @@ const STATE_DIR = "/state"; const CLAIM_PATH = "/state/tailscale-serve-claim.json"; // In memory, so writes land within the test clock's steps. -const memoryFileSystem = (files: Map) => { +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) => Effect.sync(() => void files.set(path, data)), + writeFileString: (path, data) => + writeFails + ? missing.writeFileString(path, data) + : Effect.sync(() => void files.set(path, data)), makeDirectory: () => Effect.void, remove: (path) => Effect.sync(() => void files.delete(path)), }); }; -const fakeLayers = (fake: ReturnType, files: Map) => +const fakeLayers = ( + fake: ReturnType, + files: Map, + writeFails = false, +) => Layer.mergeAll( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, fake.spawner), Layer.succeed(HostProcess.Platform, "linux"), - Layer.succeed(FileSystem.FileSystem, memoryFileSystem(files)), + Layer.succeed(FileSystem.FileSystem, memoryFileSystem(files, writeFails)), Path.layer, ); @@ -175,7 +184,7 @@ const startTailscaleServe = Effect.fn(function* (options: FakeOptions = {}) { }).pipe( Layer.provide( Layer.merge( - fakeLayers(fake, files), + fakeLayers(fake, files, options.recordFails), Layer.mock(HttpServer.HttpServer)({ address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 18638), }), @@ -255,6 +264,33 @@ describe("TailscaleServe", () => { }), ); + it.effect("keeps the record of an older local port until its mapping is removed", () => + Effect.gen(function* () { + const older = `{"localPort":18637,"localHost":"127.0.0.1","servePort":${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 }); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 1198e770a8fa3..08a2110a6e111 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -5,6 +5,7 @@ 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 PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; @@ -14,7 +15,8 @@ import { loopbackUrlHostForAddress } from "./startupAccess.ts"; type ClaimError = | Effect.Error> - | Effect.Error>; + | Effect.Error> + | PlatformError.PlatformError; // 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. @@ -28,6 +30,9 @@ 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 === "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."; @@ -49,9 +54,11 @@ const ServeClaim = Schema.fromJsonString( type ServeClaim = typeof ServeClaim.Type; const encodeClaim = Schema.encodeEffect(ServeClaim); const decodeClaim = Schema.decodeUnknownEffect(ServeClaim); +const sameTarget = (a: ServeClaim, b: ServeClaim) => + a.localPort === b.localPort && a.localHost === b.localHost && a.servePort === b.servePort; const readClaim = (fs: FileSystem.FileSystem, claimPath: string) => fs.readFileString(claimPath).pipe(Effect.flatMap(decodeClaim), Effect.option); -// A lost record only costs the cleanup after a crash, so it never stops Serve. +// Serve waits for the record, so a crash never leaves a mapping that nothing removes. const writeClaim = ( fs: FileSystem.FileSystem, stateDir: string, @@ -59,14 +66,12 @@ const writeClaim = ( claim: ServeClaim, ) => encodeClaim(claim).pipe( + Effect.orDie, Effect.flatMap((json) => fs .makeDirectory(stateDir, { recursive: true }) .pipe(Effect.andThen(fs.writeFileString(claimPath, json))), ), - Effect.catch((cause) => - Effect.logWarning("Could not record the Tailscale Serve mapping", { cause }), - ), ); const failureKey = (cause: ClaimError) => @@ -104,13 +109,10 @@ export const layer = (options: { const fs = yield* FileSystem.FileSystem; const claimPath = (yield* Path.Path).join(options.stateDir, CLAIM_FILE); const manualCommand = `tailscale serve --bg --https=${target.servePort} http://${target.localHost}:${target.localPort}`; - // A run on another Serve port left this record, so its mapping is removed first. On - // this port the claim itself replaces a stopped server's root and keeps a live one. + // A record for another target is a mapping an earlier run left, so it is removed + // first, and only while Serve still points there. const previous = yield* Ref.make( - Option.filter( - yield* readClaim(fs, claimPath), - (claim) => claim.servePort !== target.servePort, - ), + Option.filter(yield* readClaim(fs, claimPath), (claim) => !sameTarget(claim, target)), ); const recorded = yield* Ref.make(false); // Added before the retry fiber is forked, so it runs after that fiber is interrupted. @@ -168,34 +170,43 @@ export const layer = (options: { }), ); +/** + * Removes the Serve mapping an earlier run recorded, while Serve still points there, then + * its record. Returns whether a mapping was removed. + */ +export const releaseRecorded = Effect.fn("TailscaleServe.releaseRecorded")(function* ( + stateDir: string, +) { + const fs = yield* FileSystem.FileSystem; + const claimPath = (yield* Path.Path).join(stateDir, CLAIM_FILE); + const claim = yield* readClaim(fs, claimPath); + if (Option.isNone(claim)) return false; + const released = yield* releaseTailscaleServe(claim.value); + yield* fs.remove(claimPath, { force: true }); + return released; +}); + /** * With Serve off, removes the mapping an earlier run set up but could not remove, and * retries until Tailscale answers. Does nothing when no such mapping was recorded. */ export const layerReleaseLeftover = (options: { readonly stateDir: string }) => Layer.effectDiscard( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const claimPath = (yield* Path.Path).join(options.stateDir, CLAIM_FILE); - const claim = yield* readClaim(fs, claimPath); - if (Option.isNone(claim)) return; - yield* releaseTailscaleServe(claim.value).pipe( - Effect.tapError((cause) => - Effect.logDebug("Tailscale did not answer; retrying the leftover Serve removal", { - cause, - }), - ), - Effect.retry(retrySchedule), - Effect.tap((released) => - released - ? Effect.logInfo("Removed the Tailscale Serve mapping left from when Serve was on") - : Effect.void, - ), - Effect.andThen(fs.remove(claimPath, { force: true })), - Effect.catch((cause) => - Effect.logWarning("Could not remove the leftover Tailscale Serve record", { cause }), - ), - Effect.forkScoped, - ); - }), + releaseRecorded(options.stateDir).pipe( + Effect.tapError((cause) => + Effect.logDebug("Tailscale did not answer; retrying the leftover Serve removal", { + cause, + }), + ), + Effect.retry(retrySchedule), + Effect.tap((released) => + released + ? Effect.logInfo("Removed the Tailscale Serve mapping left from when Serve was on") + : Effect.void, + ), + Effect.catch((cause) => + Effect.logWarning("Could not remove the leftover Tailscale Serve record", { cause }), + ), + Effect.forkScoped, + ), ); From 5a8c2e9c27f86934301abc6b9edb9c1a01ccc593 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:04:39 -0700 Subject: [PATCH 015/114] fix(cli): name the desktop app's own off switch, and say why a pin-only restart happens - With the desktop app's server running, `t3 up` points to its Settings switch. - A restart that only writes Tailscale off says so in its prompt. - Docs pair each off switch with what it restarts. - A leftover Serve removal backs off to every 10 minutes, and a failed old-port release no longer prints the new port's manual command. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.ts | 14 ++++++++------ apps/server/src/tailscaleServe.ts | 32 ++++++++++++++++++++----------- docs/user/remote-access.md | 8 ++++---- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index bdaa0d21a7008..e0bffbcb814cf 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -630,7 +630,7 @@ const setUp = Effect.fn("cli.up")(function* ( ); if (handStarted) { return yield* Console.log( - "T3 Code is running here outside the background service, with the access it started with. Stop it to end that access.", + "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.", ); } // Nothing runs here to remove a Serve mapping a stopped server left, so do it now. @@ -696,13 +696,13 @@ const setUp = Effect.fn("cli.up")(function* ( // 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) && - (!sameSettings(before.settings, serviceSettings(planned, true)) || - (routes.connect && !current.connect) || - pinsOff || - 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) { @@ -710,7 +710,9 @@ const setUp = Effect.fn("cli.up")(function* ( 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 off, so the service cannot inherit it, and restart? Running agent turns will stop.", initial: true, }), ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) })); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 08a2110a6e111..41f586c5574be 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -25,6 +25,11 @@ 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") { @@ -153,16 +158,21 @@ export const layer = (options: { }).pipe( Effect.uninterruptible, Effect.tap(() => Effect.logInfo("Tailscale Serve configured", logContext)), - Effect.tapError((cause) => { - const key = failureKey(cause); - const repeated = key === lastFailure; - lastFailure = key; - const log = repeated ? Effect.logDebug : Effect.logWarning; - return log( - `Failed to configure Tailscale Serve. ${remedyFor(cause, manualCommand)} Retrying in the background.`, - { cause, ...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 pending = yield* Ref.get(previous); + yield* log( + Option.isSome(pending) + ? `Could not remove the Tailscale Serve mapping an earlier run left on port ${pending.value.servePort}, so Serve waits. 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)), ); @@ -198,7 +208,7 @@ export const layerReleaseLeftover = (options: { readonly stateDir: string }) => cause, }), ), - Effect.retry(retrySchedule), + Effect.retry(leftoverSchedule), Effect.tap((released) => released ? Effect.logInfo("Removed the Tailscale Serve mapping left from when Serve was on") diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index c5ec33f13e1c5..de99acc47e3fe 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -260,10 +260,10 @@ device connected to that machine, and **Block** it under **Tailscale devices on **. Its sessions there end now, and **Unblock** undoes it. 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` or turn it off in -**Settings → Sharing & access**. The background service or the desktop app -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. +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 From 623ffcdbd3d2c787471569587cf4fffe3088c3f4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:17:07 -0700 Subject: [PATCH 016/114] fix(tailscale): Serve cleanup records follow the Tailscale account, and `t3 up` cleans up under the T3 home lock - Records are kept per Tailscale account, since each keeps its own Serve config. A record for another account stays until Tailscale switches back. - Records are written atomically, and an unreadable one is reported instead of being treated as none. - `t3 up` removes a leftover only while holding the T3 home lock, so a server that starts meanwhile keeps its record. - A Serve-only pin says "Write Tailscale Serve off". Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.test.ts | 45 +++++- apps/server/src/cli/up.ts | 49 ++++-- apps/server/src/tailscaleServe.test.ts | 64 +++++++- apps/server/src/tailscaleServe.ts | 209 +++++++++++++++++-------- 4 files changed, 271 insertions(+), 96 deletions(-) diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index 0213111112c56..b4a1e5be452f3 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -23,6 +23,8 @@ vi.mock("@t3tools/tailscale", async (importOriginal) => ({ 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, resolveOwner, upCommand, upPortCheck } from "./up.ts"; @@ -198,8 +200,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => readonly tailscale?: Tailscale.TailscaleStatus; /** A server started by hand runs for this T3 home. */ readonly handStarted?: boolean; - /** A stopped server left a Serve mapping record. */ - readonly serveRecord?: 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. */ @@ -393,7 +397,33 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => 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, - serveRecord: true, + tailscale: TAGGED_HOST, + serveRecord: `{"n1":{"localPort":3773,"localHost":"127.0.0.1","servePort":443}}`, + 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}}`, env: "", args: ["--no-tailscale"], installs: 0, @@ -438,14 +468,19 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => 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 === true) { + if (input.serveRecord !== undefined) { yield* fs.makeDirectory(`${baseDir}/userdata`, { recursive: true }); yield* fs.writeFileString( `${baseDir}/userdata/tailscale-serve-claim.json`, - `{"localPort":3773,"localHost":"127.0.0.1","servePort":443}`, + 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( diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index e0bffbcb814cf..1ede8bd26213f 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -27,6 +27,8 @@ import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; import * as ServerConfig from "../config.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 { isSupportedTailscaleVersion, @@ -628,24 +630,37 @@ const setUp = Effect.fn("cli.up")(function* ( runtime, (state) => state.serviceManaged !== true && isProcessAlive(state.pid), ); - if (handStarted) { - return yield* Console.log( - "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.", + 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 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) => + upError( + error._tag === "ServeRecordError" && error.reason === "unreadable" + ? error.message + : "Could not remove a Tailscale Serve mapping an earlier run left. Run this again once Tailscale runs.", + ), + ), ); + 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 > 0) { + return yield* upError( + "A Tailscale Serve mapping an earlier run left is on another Tailscale account. Switch Tailscale to it and run this again.", + ); + } } - // Nothing runs here to remove a Serve mapping a stopped server left, so do it now. - yield* TailscaleServe.releaseRecorded(config.stateDir).pipe( - Effect.flatMap((released) => - released - ? Console.log("Removed a Tailscale Serve mapping an earlier run left.") - : Effect.void, - ), - Effect.catch(() => - Console.log( - "Tailscale did not answer, so a Serve mapping an earlier run left may remain. Run this again once Tailscale runs.", - ), - ), - ); return yield* Console.log(formatNextSteps(routes, false)); } // Tailscale left on while it is down here keeps its settings; turning it on needs it. @@ -712,7 +727,7 @@ const setUp = Effect.fn("cli.up")(function* ( ? "Update and restart T3 Code? Running agent turns will stop." : changed ? "Apply changes and restart? Running agent turns will stop." - : "Write Tailscale off, so the service cannot inherit it, 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) })); diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index f586f8b84b650..9285bfde087f9 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -42,6 +42,8 @@ interface FakeOptions { readonly recorded?: string; /** Writing the claim record fails, as on a read-only state dir. */ readonly recordFails?: boolean; + /** The Tailscale account (node ID) that is live. */ + readonly account?: string; } // Stands in for the tailscale CLI and keeps the Serve root like the daemon does. @@ -53,6 +55,7 @@ const makeFakeTailscale = (options: FakeOptions) => { root: options.heldBy ?? null, otherRoot: options.heldOnOtherPort ?? null, statusFailurePending: false, + account: options.account ?? "n1", }; // Completes when the daemon takes the mapping, before the CLI exits. const mappingApplied = Deferred.makeUnsafe(); @@ -77,6 +80,12 @@ const makeFakeTailscale = (options: FakeOptions) => { args: ReadonlyArray, ): { code: number; stdout?: string; stderr?: string; ackDelay?: Duration.Input } | undefined => { const command = args.join(" "); + if (command === "status --json") { + return { + code: 0, + stdout: JSON.stringify({ BackendState: "Running", Self: { ID: state.account } }), + }; + } if (command === "serve status --json") { state.statusCalls += 1; if (state.statusCalls <= (options.statusFailures ?? 0) || state.statusFailurePending) { @@ -138,6 +147,8 @@ const makeFakeTailscale = (options: FakeOptions) => { const STATE_DIR = "/state"; const CLAIM_PATH = "/state/tailscale-serve-claim.json"; +const record = (localPort: number, servePort: number, account = "n1") => + JSON.stringify({ [account]: { localPort, localHost: "127.0.0.1", servePort } }); // In memory, so writes land within the test clock's steps. const memoryFileSystem = (files: Map, writeFails = false) => { @@ -152,6 +163,13 @@ const memoryFileSystem = (files: Map, writeFails = false) => { ? 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)), }); }; @@ -254,7 +272,7 @@ describe("TailscaleServe", () => { Effect.gen(function* () { const { state, settled, files } = yield* startTailscaleServe({ heldOnOtherPort: LOCAL_TARGET, - recorded: `{"localPort":18638,"localHost":"127.0.0.1","servePort":${OTHER_PORT}}`, + recorded: record(18638, OTHER_PORT), }); yield* Deferred.await(settled); @@ -266,7 +284,7 @@ describe("TailscaleServe", () => { it.effect("keeps the record of an older local port until its mapping is removed", () => Effect.gen(function* () { - const older = `{"localPort":18637,"localHost":"127.0.0.1","servePort":${SERVE_PORT}}`; + const older = record(18637, SERVE_PORT); const { scope, settled, files } = yield* startTailscaleServe({ heldBy: "http://127.0.0.1:18637", recorded: older, @@ -294,9 +312,7 @@ describe("TailscaleServe", () => { 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, `{"localPort":18638,"localHost":"127.0.0.1","servePort":${SERVE_PORT}}`], - ]); + const files = new Map([[CLAIM_PATH, record(18638, SERVE_PORT)]]); const scope = yield* Scope.make(); yield* Layer.buildWithScope( TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( @@ -313,6 +329,44 @@ describe("TailscaleServe", () => { }), ); + // 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("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)); + }), + ); + 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({ diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 41f586c5574be..1f1beba2ffc4b 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -1,9 +1,12 @@ -import { claimTailscaleServe, releaseTailscaleServe } from "@t3tools/tailscale"; +import { + claimTailscaleServe, + readTailscaleStatus, + releaseTailscaleServe, +} from "@t3tools/tailscale"; 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 Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; @@ -13,10 +16,17 @@ 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"]), + message: Schema.String, +}) {} + type ClaimError = | Effect.Error> - | Effect.Error> - | PlatformError.PlatformError; + | 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. @@ -35,7 +45,7 @@ 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 === "PlatformError") { + if (cause._tag === "PlatformError" || cause._tag === "ServeRecordError") { 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) { @@ -49,35 +59,70 @@ const remedyFor = (cause: ClaimError, manualCommand: string) => { }; /** - * The mapping this server set up, kept until it is removed. A run with Serve off removes - * one that is left, such as when Tailscale was down as the last run stopped. + * 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 CLAIM_FILE = "tailscale-serve-claim.json"; -const ServeClaim = Schema.fromJsonString( - Schema.Struct({ localPort: Schema.Number, localHost: Schema.String, servePort: Schema.Number }), -); -type ServeClaim = typeof ServeClaim.Type; -const encodeClaim = Schema.encodeEffect(ServeClaim); -const decodeClaim = Schema.decodeUnknownEffect(ServeClaim); -const sameTarget = (a: ServeClaim, b: ServeClaim) => +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; +const ServeRecords = Schema.fromJsonString(Schema.Record(Schema.String, ServeTarget)); +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; -const readClaim = (fs: FileSystem.FileSystem, claimPath: string) => - fs.readFileString(claimPath).pipe(Effect.flatMap(decodeClaim), Effect.option); -// Serve waits for the record, so a crash never leaves a mapping that nothing removes. -const writeClaim = ( - fs: FileSystem.FileSystem, - stateDir: string, - claimPath: string, - claim: ServeClaim, -) => - encodeClaim(claim).pipe( - Effect.orDie, - Effect.flatMap((json) => - fs - .makeDirectory(stateDir, { recursive: true }) - .pipe(Effect.andThen(fs.writeFileString(claimPath, json))), + +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); +}); + +/** The Tailscale account (this machine's node) whose Serve config is live now. */ +const currentAccount = Effect.fn("TailscaleServe.currentAccount")(function* () { + const { selfNodeId } = yield* readTailscaleStatus("active"); + if (selfNodeId === null) { + return yield* new ServeRecordError({ + reason: "no_account", + message: "Tailscale named no account here.", + }); + } + return selfNodeId; +}); const failureKey = (cause: ClaimError) => cause._tag === "TailscaleCommandExitError" ? `exit:${cause.stderrDiagnostic}` : cause._tag; @@ -111,26 +156,26 @@ export const layer = (options: { localHost: loopbackUrlHostForAddress(address), }; const logContext = { localPort: target.localPort, servePort: target.servePort }; - const fs = yield* FileSystem.FileSystem; - const claimPath = (yield* Path.Path).join(options.stateDir, CLAIM_FILE); const manualCommand = `tailscale serve --bg --https=${target.servePort} http://${target.localHost}:${target.localPort}`; - // A record for another target is a mapping an earlier run left, so it is removed - // first, and only while Serve still points there. - const previous = yield* Ref.make( - Option.filter(yield* readClaim(fs, claimPath), (claim) => !sameTarget(claim, target)), - ); - const recorded = yield* Ref.make(false); + // 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* () { - if (!(yield* Ref.get(recorded))) return; + 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 ((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* fs.remove(claimPath, { force: true }); + const { [recordedUnder]: _ours, ...rest } = yield* readRecords(options.stateDir); + yield* writeRecords(options.stateDir, rest); }).pipe( Effect.catch((cause) => Effect.logDebug("Left the Tailscale Serve record for the next run", { cause }), @@ -143,16 +188,24 @@ export const layer = (options: { // 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* () { - const pending = yield* Ref.get(previous); - if (Option.isSome(pending)) { - yield* releaseTailscaleServe(pending.value); - yield* Ref.set(previous, Option.none()); - } // 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. - if (!(yield* Ref.get(recorded))) { - yield* writeClaim(fs, options.stateDir, claimPath, target); - yield* Ref.set(recorded, true); + if ((yield* Ref.get(account)) === null) { + const now = yield* currentAccount(); + const records = yield* readRecords(options.stateDir).pipe( + Effect.catchTags({ + ServeRecordError: (error) => + Effect.logWarning(error.message).pipe(Effect.as(NO_RECORDS)), + }), + ); + const earlier = records[now]; + if (earlier !== undefined && !sameTarget(earlier, target)) { + yield* Ref.set(releasing, earlier); + yield* releaseTailscaleServe(earlier); + yield* Ref.set(releasing, null); + } + yield* writeRecords(options.stateDir, { ...records, [now]: target }); + yield* Ref.set(account, now); } yield* claimTailscaleServe(target); }).pipe( @@ -164,10 +217,10 @@ export const layer = (options: { const repeated = key === lastFailure; lastFailure = key; const log = repeated ? Effect.logDebug : Effect.logWarning; - const pending = yield* Ref.get(previous); + const earlier = yield* Ref.get(releasing); yield* log( - Option.isSome(pending) - ? `Could not remove the Tailscale Serve mapping an earlier run left on port ${pending.value.servePort}, so Serve waits. Retrying in the background.` + earlier !== null + ? `Could not remove the Tailscale Serve mapping an earlier run left on port ${earlier.servePort}, so Serve waits. Retrying in the background.` : `Failed to configure Tailscale Serve. ${remedyFor(cause, manualCommand)} Retrying in the background.`, { cause, ...logContext }, ); @@ -180,40 +233,58 @@ export const layer = (options: { }), ); +/** 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, while Serve still points there, then - * its record. Returns whether a mapping was removed. + * 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 fs = yield* FileSystem.FileSystem; - const claimPath = (yield* Path.Path).join(stateDir, CLAIM_FILE); - const claim = yield* readClaim(fs, claimPath); - if (Option.isNone(claim)) return false; - const released = yield* releaseTailscaleServe(claim.value); - yield* fs.remove(claimPath, { force: true }); - return released; + const records = yield* readRecords(stateDir); + if (Object.keys(records).length === 0) return { released: false, otherAccounts: 0 }; + const now = yield* currentAccount(); + const { [now]: ours, ...rest } = records; + const released = ours === undefined ? false : yield* releaseTailscaleServe(ours); + yield* writeRecords(stateDir, rest); + return { released, otherAccounts: Object.keys(rest).length }; }); /** - * With Serve off, removes the mapping an earlier run set up but could not remove, and - * retries until Tailscale answers. Does nothing when no such mapping was recorded. + * 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.tapError((cause) => - Effect.logDebug("Tailscale did not answer; retrying the leftover Serve removal", { - cause, - }), - ), - Effect.retry(leftoverSchedule), - Effect.tap((released) => + Effect.tap(({ released }) => released ? Effect.logInfo("Removed the Tailscale Serve mapping left from when Serve was on") : Effect.void, ), + Effect.flatMap(({ otherAccounts }) => + otherAccounts > 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 }), ), From cee80e22aff081b36c20073a9a262bbc17aa9469 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:19:04 -0700 Subject: [PATCH 017/114] fix(server): Serve cleanup errors keep their remedy and name file errors as such Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.ts | 4 +++- apps/server/src/tailscaleServe.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 1ede8bd26213f..893c43dc6ffaa 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -647,7 +647,9 @@ const setUp = Effect.fn("cli.up")(function* ( upError( error._tag === "ServeRecordError" && error.reason === "unreadable" ? error.message - : "Could not remove a Tailscale Serve mapping an earlier run left. Run this again once Tailscale runs.", + : error._tag === "PlatformError" + ? `Could not update the Tailscale Serve record: ${error.message}` + : "Tailscale did not answer, so a Serve mapping an earlier run left may remain. Run this again once Tailscale runs.", ), ), ); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 1f1beba2ffc4b..886f3e763b039 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -220,7 +220,7 @@ export const layer = (options: { 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. Retrying in the background.` + ? `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 }, ); From 17b272f6dacc961ee78e570f93d4597326f25cea Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:25:43 -0700 Subject: [PATCH 018/114] fix(tailscale): a policy scan from another Tailscale account never decides sign-in here - The policy scan, and the last full scan it may reuse, name the Tailscale account they read. After a switch, the scan is redone at once, so the old tailnet's "no grants" cannot let an ungranted device on the new one in. - Serve checks the account on every try, so a retry that lands after a switch is recorded where it landed. - With Serve on, an unreadable record stops Serve instead of being replaced. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 16 +++++++++ apps/server/src/auth/TailnetSignIn.ts | 39 ++++++++++++++++------ apps/server/src/tailscaleServe.test.ts | 29 ++++++++++++++++ apps/server/src/tailscaleServe.ts | 16 ++++----- 4 files changed, 80 insertions(+), 20 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index a77e949b1a726..3fb52d62e23c0 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -685,6 +685,22 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).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()); + 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 grant sign-in during a scan that finds no grants keeps the owner rule off", () => { const tailnet = withLaptop(); return Effect.gen(function* () { diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 6d3602a90b75d..3ebcb48221e2f 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -223,13 +223,18 @@ export const make = Effect.gen(function* () { const grantSignIns = yield* Ref.make(0); // 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. - const lastCleanScan = yield* Ref.make<{ readonly granted: boolean; readonly at: number } | null>( - null, - ); - const scanPolicy = yield* Effect.cachedWithTTL( + // 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); + const [scanPolicy, rescanPolicy] = yield* Effect.cachedInvalidateWithTTL( Effect.gen(function* () { const signInsBefore = yield* Ref.get(grantSignIns); const status = yield* host.status; + const account = status.selfNodeId; const { granted, unreadable } = yield* policyGrantedPeers(status, (address) => host.whois(address, { fromServer: true }), ); @@ -238,16 +243,20 @@ export const make = Effect.gen(function* () { 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, { granted: found, at: now }); + yield* Ref.set(lastCleanScan, { account, granted: found, at: now }); if (!found) yield* Ref.update(grantSignIns, (count) => (count === signInsBefore ? 0 : count)); - return { granted: found, ttl: ttlOf(found) }; + return { account, granted: found, ttl: ttlOf(found) }; } const last = yield* Ref.get(lastCleanScan); - const left = last === null ? 0 : Duration.toMillis(POLICY_SCAN_TTL) - (now - last.at); + 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)), }; @@ -258,12 +267,20 @@ export const make = Effect.gen(function* () { * 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.gen(function* () { + const ownerRuleOff = Effect.fn("TailnetSignIn.ownerRuleOff")(function* (account: string | null) { if (yield* fs.exists(requireGrantPath).pipe(Effect.orElseSucceed(() => true))) { return "no_grant" as const; } - // Scanned first, so a scan that finds no grants can clear the sign-in count. - const scan = yield* scanPolicy.pipe(Effect.option); + // 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* Ref.get(grantSignIns)) > 0) return "no_grant" as const; return Option.isNone(scan) ? ("policy_unknown" as const) : null; @@ -282,7 +299,7 @@ export const make = Effect.gen(function* () { ? refused("grant_unusable") : ({ _tag: "Allowed", scopes: granted, via: "grant" } satisfies AccessDecision); } - const off = yield* ownerRuleOff; + const off = yield* ownerRuleOff(status.selfNodeId); if (off !== null) return refused(off); const ownerUserId = TailscaleHost.tailnetOwnerUserId(status, config.tailscaleOwnerUserId); if (ownerUserId === null) return refused("owner_unknown"); diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index 9285bfde087f9..c0c71fcc67819 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -354,6 +354,35 @@ describe("TailscaleServe", () => { }), ); + 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)); + }), + ); + + 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("keeps its record on shutdown after Tailscale switched to another account", () => Effect.gen(function* () { const { state, scope, settled, files } = yield* startTailscaleServe(); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 886f3e763b039..33d0588294aff 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -45,7 +45,8 @@ 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 === "PlatformError" || cause._tag === "ServeRecordError") { + if (cause._tag === "ServeRecordError") return `${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) { @@ -190,14 +191,11 @@ export const layer = (options: { 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. - if ((yield* Ref.get(account)) === null) { - const now = yield* currentAccount(); - const records = yield* readRecords(options.stateDir).pipe( - Effect.catchTags({ - ServeRecordError: (error) => - Effect.logWarning(error.message).pipe(Effect.as(NO_RECORDS)), - }), - ); + // Checked on every try, since Tailscale may have switched accounts since the last. + const now = yield* currentAccount(); + if ((yield* Ref.get(account)) !== now) { + // An unreadable record may name a mapping to remove, so Serve waits for it. + const records = yield* readRecords(options.stateDir); const earlier = records[now]; if (earlier !== undefined && !sameTarget(earlier, target)) { yield* Ref.set(releasing, earlier); From 25c372f51e387d802b76f7991bdfaf9a7debcddc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:29:54 -0700 Subject: [PATCH 019/114] fix(tailscale): Serve records follow the login profile, and cleanup errors name the account and the way out - Records are keyed by tailnet and user, as Tailscale keeps Serve config per login profile, so a device re-added with a new node ID still matches. - `t3 up` names the other account ("you@work on work.example") and says to delete the record if that account or Tailscale is gone. - Logged out says "Run `tailscale up` to sign in."; lock errors keep their own text. - The Tailscale status parser reads the tailnet name. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.test.ts | 18 ++++++- apps/server/src/cli/up.ts | 34 +++++++++----- .../src/environment/TailscaleHealth.test.ts | 1 + apps/server/src/tailscaleServe.test.ts | 35 ++++++++++++-- apps/server/src/tailscaleServe.ts | 47 ++++++++++++------- packages/tailscale/src/tailscale.test.ts | 2 + packages/tailscale/src/tailscale.ts | 7 ++- 7 files changed, 111 insertions(+), 33 deletions(-) diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index b4a1e5be452f3..6588c6e9245b9 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -41,6 +41,7 @@ const TAGGED_HOST: Tailscale.TailscaleStatus = { keyExpired: false, health: [], magicDnsEnabled: true, + tailnetName: null, certDomains: [], userspaceNetworking: false, peers: [], @@ -398,7 +399,20 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => 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}}`, + 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: "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, @@ -423,7 +437,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => 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}}`, + serveRecord: `{"n1":{"localPort":3773,"localHost":"127.0.0.1","servePort":443,"label":"n1"}}`, env: "", args: ["--no-tailscale"], installs: 0, diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 893c43dc6ffaa..2638fb6c6a892 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -636,6 +636,8 @@ const setUp = Effect.fn("cli.up")(function* ( // 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( @@ -643,23 +645,33 @@ const setUp = Effect.fn("cli.up")(function* ( Effect.andThen(TailscaleServe.releaseRecorded(config.stateDir)), Effect.scoped, Effect.catchTags({ ServerAlreadyRunningError: () => Effect.succeed(null) }), - Effect.catch((error) => - upError( - error._tag === "ServeRecordError" && error.reason === "unreadable" - ? error.message - : error._tag === "PlatformError" - ? `Could not update the Tailscale Serve record: ${error.message}` - : "Tailscale did not answer, so a Serve mapping an earlier run left may remain. Run this again once Tailscale runs.", - ), - ), + Effect.catch((error) => { + switch (error._tag) { + case "ServeRecordError": + return upError( + error.reason === "unreadable" + ? error.message + : `${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 > 0) { + if (leftover.otherAccounts.length > 0) { return yield* upError( - "A Tailscale Serve mapping an earlier run left is on another Tailscale account. Switch Tailscale to it and run this again.", + `A Tailscale Serve mapping an earlier run left is on another Tailscale account: ${leftover.otherAccounts.join(", ")}. Switch Tailscale to it and run this again, or delete ${record} if that account is gone.`, ); } } diff --git a/apps/server/src/environment/TailscaleHealth.test.ts b/apps/server/src/environment/TailscaleHealth.test.ts index c2c9737b265d3..5c3b688350266 100644 --- a/apps/server/src/environment/TailscaleHealth.test.ts +++ b/apps/server/src/environment/TailscaleHealth.test.ts @@ -33,6 +33,7 @@ const running: TailscaleStatus = { keyExpired: false, health: [], magicDnsEnabled: true, + tailnetName: null, certDomains: ["studio.tail1234.ts.net"], userspaceNetworking: false, peers: [], diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index c0c71fcc67819..2f12ff6384050 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -42,8 +42,10 @@ interface FakeOptions { readonly recorded?: string; /** Writing the claim record fails, as on a read-only state dir. */ readonly recordFails?: boolean; - /** The Tailscale account (node ID) that is live. */ + /** This machine's node ID on the live Tailscale account. */ readonly account?: string; + /** The live account's tailnet and user, which name its login profile. */ + readonly profile?: { readonly tailnet: string; readonly userId: string }; } // Stands in for the tailscale CLI and keeps the Serve root like the daemon does. @@ -83,7 +85,11 @@ const makeFakeTailscale = (options: FakeOptions) => { if (command === "status --json") { return { code: 0, - stdout: JSON.stringify({ BackendState: "Running", Self: { ID: state.account } }), + stdout: JSON.stringify({ + BackendState: "Running", + Self: { ID: state.account, UserID: options.profile?.userId }, + CurrentTailnet: { Name: options.profile?.tailnet }, + }), }; } if (command === "serve status --json") { @@ -148,7 +154,7 @@ const makeFakeTailscale = (options: FakeOptions) => { const STATE_DIR = "/state"; const CLAIM_PATH = "/state/tailscale-serve-claim.json"; const record = (localPort: number, servePort: number, account = "n1") => - JSON.stringify({ [account]: { localPort, localHost: "127.0.0.1", servePort } }); + JSON.stringify({ [account]: { localPort, localHost: "127.0.0.1", servePort, label: account } }); // In memory, so writes land within the test clock's steps. const memoryFileSystem = (files: Map, writeFails = false) => { @@ -383,6 +389,29 @@ describe("TailscaleServe", () => { }), ); + 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("keeps its record on shutdown after Tailscale switched to another account", () => Effect.gen(function* () { const { state, scope, settled, files } = yield* startTailscaleServe(); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 33d0588294aff..494407e302dff 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -71,7 +71,9 @@ const ServeTarget = Schema.Struct({ servePort: Schema.Number, }); type ServeTarget = typeof ServeTarget.Type; -const ServeRecords = Schema.fromJsonString(Schema.Record(Schema.String, ServeTarget)); +// Keyed by account; `label` names it for a person, such as `you@example.com on example.com`. +const ServeRecord = Schema.Struct({ ...ServeTarget.fields, label: Schema.String }); +const ServeRecords = Schema.fromJsonString(Schema.Record(Schema.String, ServeRecord)); type ServeRecords = typeof ServeRecords.Type; const NO_RECORDS: ServeRecords = {}; const encodeRecords = Schema.encodeEffect(ServeRecords); @@ -79,7 +81,8 @@ const decodeRecords = Schema.decodeUnknownEffect(ServeRecords); const sameTarget = (a: ServeTarget, b: ServeTarget) => a.localPort === b.localPort && a.localHost === b.localHost && a.servePort === b.servePort; -const recordPath = Effect.fn("TailscaleServe.recordPath")(function* (stateDir: string) { +/** 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); }); @@ -113,16 +116,25 @@ const writeRecords = Effect.fn("TailscaleServe.writeRecords")(function* ( yield* fs.rename(`${path}.tmp`, path); }); -/** The Tailscale account (this machine's node) whose Serve config is live now. */ +/** + * The Tailscale account whose Serve config is live now. Tailscale keeps Serve config per + * login profile, one user on one tailnet, which outlives a node ID when a device is re-added. + */ const currentAccount = Effect.fn("TailscaleServe.currentAccount")(function* () { - const { selfNodeId } = yield* readTailscaleStatus("active"); - if (selfNodeId === null) { + const status = yield* readTailscaleStatus("active"); + const login = status.users.find((user) => user.userId === status.selfUserId)?.loginName; + const key = + status.tailnetName !== null && status.selfUserId !== null + ? `${status.tailnetName}/${status.selfUserId}` + : status.selfNodeId; + if (key === null) { return yield* new ServeRecordError({ reason: "no_account", - message: "Tailscale named no account here.", + message: "Tailscale is not signed in here. Run `tailscale up` to sign in.", }); } - return selfNodeId; + const label = [login, status.tailnetName].filter((part) => part != null).join(" on ") || key; + return { key, label }; }); const failureKey = (cause: ClaimError) => @@ -168,7 +180,7 @@ export const layer = (options: { 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 ((yield* currentAccount()) !== recordedUnder) return; + if ((yield* currentAccount()).key !== recordedUnder) return; const released = yield* releaseTailscaleServe(target).pipe( Effect.tapError((cause) => Effect.logWarning("Failed to disable Tailscale Serve", { cause, ...logContext }), @@ -193,17 +205,20 @@ export const layer = (options: { // 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. const now = yield* currentAccount(); - if ((yield* Ref.get(account)) !== now) { + if ((yield* Ref.get(account)) !== now.key) { // An unreadable record may name a mapping to remove, so Serve waits for it. const records = yield* readRecords(options.stateDir); - const earlier = records[now]; + const earlier = records[now.key]; if (earlier !== undefined && !sameTarget(earlier, target)) { yield* Ref.set(releasing, earlier); yield* releaseTailscaleServe(earlier); yield* Ref.set(releasing, null); } - yield* writeRecords(options.stateDir, { ...records, [now]: target }); - yield* Ref.set(account, now); + yield* writeRecords(options.stateDir, { + ...records, + [now.key]: { ...target, label: now.label }, + }); + yield* Ref.set(account, now.key); } yield* claimTailscaleServe(target); }).pipe( @@ -245,12 +260,12 @@ export const releaseRecorded = Effect.fn("TailscaleServe.releaseRecorded")(funct stateDir: string, ) { const records = yield* readRecords(stateDir); - if (Object.keys(records).length === 0) return { released: false, otherAccounts: 0 }; + if (Object.keys(records).length === 0) return { released: false, otherAccounts: [] }; const now = yield* currentAccount(); - const { [now]: ours, ...rest } = records; + const { [now.key]: ours, ...rest } = records; const released = ours === undefined ? false : yield* releaseTailscaleServe(ours); yield* writeRecords(stateDir, rest); - return { released, otherAccounts: Object.keys(rest).length }; + return { released, otherAccounts: Object.values(rest).map((record) => record.label) }; }); /** @@ -266,7 +281,7 @@ export const layerReleaseLeftover = (options: { readonly stateDir: string }) => : Effect.void, ), Effect.flatMap(({ otherAccounts }) => - otherAccounts > 0 + otherAccounts.length > 0 ? Effect.fail( new ServeRecordError({ reason: "other_account", diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 673bf823bece9..78301703ec819 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -228,6 +228,7 @@ describe("tailscale", () => { keyExpired: false, health: [], magicDnsEnabled: null, + tailnetName: null, certDomains: [], userspaceNetworking: null, peers: [], @@ -249,6 +250,7 @@ describe("tailscale", () => { 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, diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 92973ef628f21..21fcd6c28fafa 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -215,7 +215,9 @@ const TailscaleStatusJson = Schema.Struct({ 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({ MagicDNSEnabled: lenient(Schema.Boolean) })), + CurrentTailnet: lenient( + Schema.Struct({ Name: lenient(Schema.String), MagicDNSEnabled: lenient(Schema.Boolean) }), + ), Self: lenient( Schema.Struct({ ...TailscaleStatusNodeFields, @@ -284,6 +286,8 @@ export interface TailscaleStatus { 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 @@ -393,6 +397,7 @@ export const parseTailscaleStatus = ( 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, From 3b3c76f5af5384a8f7eb8fd6b63d7e426b29fa5b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:39:36 -0700 Subject: [PATCH 020/114] fix(tailscale): whois answers and Serve records never cross Tailscale accounts - The whois cache is keyed by this machine's node and the address, since another account is another tailnet where the address names another device. - With no node ID for this host, the owner rule does not apply. - Serve records are keyed by Tailscale's login profile ID (`tailscale switch --list --json`), which tagging does not change; older CLIs fall back to the tailnet and user. - A switch during a removal keeps the record and retries. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 9 +++ apps/server/src/auth/TailnetSignIn.ts | 2 + apps/server/src/cli/up.test.ts | 1 + .../src/tailscale/TailscaleHost.test.ts | 32 +++++++-- apps/server/src/tailscale/TailscaleHost.ts | 11 +++- apps/server/src/tailscaleServe.test.ts | 66 +++++++++++++++++++ apps/server/src/tailscaleServe.ts | 24 ++++++- packages/tailscale/src/tailscale.ts | 38 ++++++++++- 8 files changed, 175 insertions(+), 8 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 3fb52d62e23c0..033c16faae9de 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -701,6 +701,15 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).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* () { diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 3ebcb48221e2f..606ce0cb3cdca 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -271,6 +271,8 @@ export const make = Effect.gen(function* () { 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; // 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( diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index 6588c6e9245b9..fd3106793c7e2 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -19,6 +19,7 @@ import { afterEach, vi } from "vite-plus/test"; vi.mock("@t3tools/tailscale", async (importOriginal) => ({ ...(await importOriginal()), readTailscaleStatus: () => Effect.fail("Tailscale is not running in tests"), + readTailscaleProfile: () => Effect.fail("Tailscale is not running in tests"), })); import packageJson from "../../package.json" with { type: "json" }; diff --git a/apps/server/src/tailscale/TailscaleHost.test.ts b/apps/server/src/tailscale/TailscaleHost.test.ts index 1809dcf8906c8..e738b6207a37d 100644 --- a/apps/server/src/tailscale/TailscaleHost.test.ts +++ b/apps/server/src/tailscale/TailscaleHost.test.ts @@ -15,14 +15,22 @@ const WHOIS_JSON = JSON.stringify({ Node: { StableID: "nStudio", User: 7, Addresses: ["100.88.1.2/32"], AllowedIPs: [] }, }); -const layerTest = (whoisSpawns: { count: number }) => +const layerTest = (whoisSpawns: { count: number; account?: string }) => TailscaleHost.layer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => { - whoisSpawns.count += 1; + ChildProcessSpawner.make((command) => { + const { args } = command as unknown as { readonly args: ReadonlyArray }; + const isWhois = args[0] === "whois"; + if (isWhois) whoisSpawns.count += 1; + const stdout = isWhois + ? WHOIS_JSON + : JSON.stringify({ + BackendState: "Running", + Self: { ID: whoisSpawns.account ?? "nBox" }, + }); return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -31,7 +39,7 @@ const layerTest = (whoisSpawns: { count: number }) => kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(new TextEncoder().encode(WHOIS_JSON)), + stdout: Stream.make(new TextEncoder().encode(stdout)), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -80,4 +88,20 @@ it.layer(NodeServices.layer)("TailscaleHost", (it) => { expect(whoisSpawns.count).toBe(12); }).pipe(Effect.provide(layerTest(whoisSpawns))); }); + + // Another account is another tailnet, where the same address names another device. + it.effect("never reuses a whois answer from another Tailscale account", () => { + const whoisSpawns: { count: number; account?: string } = { count: 0, account: "nBox" }; + return Effect.gen(function* () { + const host = yield* TailscaleHost.TailscaleHost; + yield* host.status; + yield* TestClock.adjust("2500 millis"); + yield* host.whois("100.88.1.2"); + whoisSpawns.account = "nBoxAtWork"; + // The status read expires and names the new account; the whois answer has not expired. + yield* TestClock.adjust("2600 millis"); + yield* host.whois("100.88.1.2"); + expect(whoisSpawns.count).toBe(2); + }).pipe(Effect.provide(layerTest(whoisSpawns))); + }); }); diff --git a/apps/server/src/tailscale/TailscaleHost.ts b/apps/server/src/tailscale/TailscaleHost.ts index 252ac6f071fd6..d3814e3d6e408 100644 --- a/apps/server/src/tailscale/TailscaleHost.ts +++ b/apps/server/src/tailscale/TailscaleHost.ts @@ -132,8 +132,11 @@ const make = Effect.gen(function* () { }); }); + // Keyed by this machine's node and the address, since a switch to another Tailscale + // account switches tailnets, and an address there names another device. const whoisCache = yield* Cache.makeWith( - Effect.fn("TailscaleHost.whoisLookup")(function* (address: string) { + Effect.fn("TailscaleHost.whoisLookup")(function* (key: string) { + const address = key.slice(key.indexOf(" ") + 1); if (!(yield* takeLookup(address))) { return yield* new TailscaleWhoisRateLimitedError({}); } @@ -149,7 +152,11 @@ const make = Effect.gen(function* () { ? readTailscaleWhois(address).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ) - : Cache.get(whoisCache, address); + : status.pipe( + Effect.map(({ selfNodeId }) => selfNodeId ?? ""), + Effect.orElseSucceed(() => ""), + Effect.flatMap((account) => Cache.get(whoisCache, `${account} ${address}`)), + ); return TailscaleHost.of({ inUse, status, whois }); }); diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index 2f12ff6384050..a1e82356d614d 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -46,6 +46,10 @@ interface FakeOptions { readonly account?: string; /** 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. @@ -82,6 +86,21 @@ const makeFakeTailscale = (options: FakeOptions) => { args: ReadonlyArray, ): { code: number; stdout?: string; stderr?: string; ackDelay?: Duration.Input } | undefined => { const command = args.join(" "); + 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, @@ -93,6 +112,10 @@ const makeFakeTailscale = (options: FakeOptions) => { }; } 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; @@ -412,6 +435,49 @@ describe("TailscaleServe", () => { }), ); + 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, record(18638, SERVE_PORT, "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("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(); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 494407e302dff..59ce22de91eb3 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -1,5 +1,6 @@ import { claimTailscaleServe, + readTailscaleProfile, readTailscaleStatus, releaseTailscaleServe, } from "@t3tools/tailscale"; @@ -7,6 +8,7 @@ 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 Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; @@ -18,7 +20,7 @@ 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"]), + reason: Schema.Literals(["unreadable", "no_account", "other_account", "switched"]), message: Schema.String, }) {} @@ -121,6 +123,17 @@ const writeRecords = Effect.fn("TailscaleServe.writeRecords")(function* ( * login profile, one user on one tailnet, which outlives a node ID when a device is re-added. */ const currentAccount = Effect.fn("TailscaleServe.currentAccount")(function* () { + // The profile ID is the key Tailscale stores Serve config under. Tagging a host changes + // its user, not its profile. + const profile = yield* readTailscaleProfile().pipe( + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ); + if (profile !== null) { + const label = [profile.account, profile.tailnet].filter((part) => part !== null).join(" on "); + return { key: profile.id, label: label || profile.id }; + } + // Older Tailscale lists no profiles, so the profile's tailnet and user stand in. const status = yield* readTailscaleStatus("active"); const login = status.users.find((user) => user.userId === status.selfUserId)?.loginName; const key = @@ -187,6 +200,8 @@ export const layer = (options: { ), ); if (released) yield* Effect.logInfo("Tailscale Serve disabled", logContext); + // A switch while removing it means the removal may have run on the other account. + if ((yield* currentAccount()).key !== recordedUnder) return; const { [recordedUnder]: _ours, ...rest } = yield* readRecords(options.stateDir); yield* writeRecords(options.stateDir, rest); }).pipe( @@ -264,6 +279,13 @@ export const releaseRecorded = Effect.fn("TailscaleServe.releaseRecorded")(funct const now = yield* currentAccount(); const { [now.key]: ours, ...rest } = records; const released = ours === undefined ? false : yield* releaseTailscaleServe(ours); + // A switch while removing it means the removal may have run on the other account. + if ((yield* currentAccount()).key !== now.key) { + return yield* new ServeRecordError({ + reason: "switched", + message: "Tailscale switched accounts during cleanup. Run this again.", + }); + } yield* writeRecords(stateDir, rest); return { released, otherAccounts: Object.values(rest).map((record) => record.label) }; }); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 21fcd6c28fafa..40f14f8a2e680 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -48,7 +48,7 @@ const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | " const TailscaleCommandContext = { executable: Schema.Literals(["tailscale", "tailscale.exe"]), - subcommand: Schema.Literals(["status", "serve", "debug", "whois"]), + subcommand: Schema.Literals(["status", "serve", "debug", "whois", "switch"]), argumentCount: Schema.Number, }; @@ -685,6 +685,42 @@ export const readTailscaleWhois = Effect.fn("tailscale.readTailscaleWhois")(func 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); + +/** + * The login profile Tailscale is on now: one account on one tailnet. Tailscale keeps Serve + * config per profile. None when no profile is selected. + */ +export const readTailscaleProfile = Effect.fn("tailscale.readTailscaleProfile")(function* () { + const stdout = yield* runTailscale( + "switch", + ["switch", "--list", "--json"], + TAILSCALE_STATUS_TIMEOUT, + "active", + ); + const profiles = yield* decodeTailscaleProfiles(stdout).pipe( + Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), + ); + const selected = profiles.find((profile) => profile.selected === true); + return selected === undefined + ? Option.none() + : Option.some({ + id: selected.id, + tailnet: selected.tailnet ?? null, + account: selected.account ?? null, + }); +}); + export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; readonly servePort?: number; From 64b6b43110cf1bbf19011ad045f4014e8e9cbff5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:41:28 -0700 Subject: [PATCH 021/114] fix(cli): a leftover on another account says the off worked and the mapping may remain Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.test.ts | 2 +- apps/server/src/cli/up.ts | 4 +++- apps/server/src/tailscaleServe.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index fd3106793c7e2..7c3acc06727b3 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -409,7 +409,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => }, { label: "a Serve record on another Tailscale account names it and the way out", - prints: "another Tailscale account: me@work.example on work.example. Switch", + prints: "may remain on another Tailscale account: me@work.example on work.example. Switch", fails: true, installed: false, tailscale: TAGGED_HOST, diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 2638fb6c6a892..4ef5b52797410 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -670,8 +670,10 @@ const setUp = Effect.fn("cli.up")(function* ( yield* Console.log("Removed a Tailscale Serve mapping an earlier run left."); } if (leftover.otherAccounts.length > 0) { + yield* Console.log(formatNextSteps(routes, false)); + const one = leftover.otherAccounts.length === 1; return yield* upError( - `A Tailscale Serve mapping an earlier run left is on another Tailscale account: ${leftover.otherAccounts.join(", ")}. Switch Tailscale to it and run this again, or delete ${record} if that account is gone.`, + `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.`, ); } } diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 59ce22de91eb3..b3510d01062fc 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -55,7 +55,7 @@ const remedyFor = (cause: ClaimError, manualCommand: string) => { 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 log in."; + return "Run `tailscale up` to sign in."; default: return `Run \`${manualCommand}\` by hand to see why.`; } @@ -219,6 +219,7 @@ export const layer = (options: { // 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 now = yield* currentAccount(); if ((yield* Ref.get(account)) !== now.key) { // An unreadable record may name a mapping to remove, so Serve waits for it. From 6ee0a9d5a162f364a0cbce087ea03818e9ea7a75 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:52:28 -0700 Subject: [PATCH 022/114] fix(tailscale): whois is never cached across accounts, and Serve records match every key an account had - Sign-in whois is no longer cached (still rate limited), so an answer from another account's tailnet can never be reused right after a switch. - A Serve record matches the account under its profile ID, tailnet and user, or node ID, so records from an older CLI or a re-added device still match. - With Serve on, an older mapping's record is replaced only if the account is unchanged after its removal. - `t3 tailscale require-grant` says a grant is required when Tailscale names no node for this host, as the server does. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/tailscale.ts | 37 +++++--- .../src/tailscale/TailscaleHost.test.ts | 46 ++-------- apps/server/src/tailscale/TailscaleHost.ts | 31 ++----- apps/server/src/tailscaleServe.test.ts | 39 +++++++++ apps/server/src/tailscaleServe.ts | 85 ++++++++++++------- 5 files changed, 129 insertions(+), 109 deletions(-) diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 31aad0aef2e81..76629a63f1bc1 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -55,7 +55,11 @@ export const turnOnRequireGrant = Effect.fn("cli.tailscale.turnOnRequireGrant")( const readPolicyGrantedPeers = readTailscaleStatus("active").pipe( Effect.flatMap((status) => policyGrantedPeers(status, readTailscaleWhois).pipe( - Effect.map((scan) => ({ ...scan, tagged: status.selfTags.length > 0 })), + Effect.map((scan) => ({ + ...scan, + tagged: status.selfTags.length > 0, + noNode: status.selfNodeId === null, + })), ), ), Effect.option, @@ -114,23 +118,28 @@ const requireGrantCommand = Command.make("require-grant", { ] : scan === null ? ["Grant required: yes, because Tailscale is not answering."] - : scan.unreadable.length > 0 + : scan.noNode ? [ - `Grant required: probably. Tailscale could not read ${scan.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.", + "Grant required: yes, because Tailscale names no node for this host.", + "Run `tailscale up` to sign in.", ] - : scan.tagged && !(yield* hasOwner(config.baseDir, config.tailscaleOwnerUserId)) + : scan.unreadable.length > 0 ? [ - "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: probably. Tailscale could not read ${scan.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.", ] - : [ - "Grant required: no. Your tailnet policy grants T3 Code here to no device,", - "so 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", - ]; + : scan.tagged && !(yield* hasOwner(config.baseDir, config.tailscaleOwnerUserId)) + ? [ + "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,", + "so 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, diff --git a/apps/server/src/tailscale/TailscaleHost.test.ts b/apps/server/src/tailscale/TailscaleHost.test.ts index e738b6207a37d..9c4c3db0e727b 100644 --- a/apps/server/src/tailscale/TailscaleHost.test.ts +++ b/apps/server/src/tailscale/TailscaleHost.test.ts @@ -15,22 +15,14 @@ const WHOIS_JSON = JSON.stringify({ Node: { StableID: "nStudio", User: 7, Addresses: ["100.88.1.2/32"], AllowedIPs: [] }, }); -const layerTest = (whoisSpawns: { count: number; account?: string }) => +const layerTest = (whoisSpawns: { count: number }) => TailscaleHost.layer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const { args } = command as unknown as { readonly args: ReadonlyArray }; - const isWhois = args[0] === "whois"; - if (isWhois) whoisSpawns.count += 1; - const stdout = isWhois - ? WHOIS_JSON - : JSON.stringify({ - BackendState: "Running", - Self: { ID: whoisSpawns.account ?? "nBox" }, - }); + ChildProcessSpawner.make(() => { + whoisSpawns.count += 1; return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -39,7 +31,7 @@ const layerTest = (whoisSpawns: { count: number; account?: string }) => kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(new TextEncoder().encode(stdout)), + stdout: Stream.make(new TextEncoder().encode(WHOIS_JSON)), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -55,27 +47,17 @@ const layerTest = (whoisSpawns: { count: number; account?: string }) => ); it.layer(NodeServices.layer)("TailscaleHost", (it) => { - it.effect("keeps whois answers for 3 s and allows 10 lookups per address a minute", () => { + 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"; - yield* host.whois(address); - yield* TestClock.adjust("2999 millis"); - yield* host.whois(address); - expect(whoisSpawns.count).toBe(1); - yield* TestClock.adjust("1 millis"); - yield* host.whois(address); - expect(whoisSpawns.count).toBe(2); - - for (let lookup = 3; lookup <= 10; lookup += 1) { - yield* TestClock.adjust("3 seconds"); + for (let lookup = 1; lookup <= 10; lookup += 1) { yield* host.whois(address); } expect(whoisSpawns.count).toBe(10); - yield* TestClock.adjust("3 seconds"); const refused = yield* host.whois(address).pipe(Effect.flip); expect(refused._tag).toBe("TailscaleWhoisRateLimitedError"); expect(whoisSpawns.count).toBe(10); @@ -88,20 +70,4 @@ it.layer(NodeServices.layer)("TailscaleHost", (it) => { expect(whoisSpawns.count).toBe(12); }).pipe(Effect.provide(layerTest(whoisSpawns))); }); - - // Another account is another tailnet, where the same address names another device. - it.effect("never reuses a whois answer from another Tailscale account", () => { - const whoisSpawns: { count: number; account?: string } = { count: 0, account: "nBox" }; - return Effect.gen(function* () { - const host = yield* TailscaleHost.TailscaleHost; - yield* host.status; - yield* TestClock.adjust("2500 millis"); - yield* host.whois("100.88.1.2"); - whoisSpawns.account = "nBoxAtWork"; - // The status read expires and names the new account; the whois answer has not expired. - yield* TestClock.adjust("2600 millis"); - yield* host.whois("100.88.1.2"); - expect(whoisSpawns.count).toBe(2); - }).pipe(Effect.provide(layerTest(whoisSpawns))); - }); }); diff --git a/apps/server/src/tailscale/TailscaleHost.ts b/apps/server/src/tailscale/TailscaleHost.ts index d3814e3d6e408..fecb39cce9379 100644 --- a/apps/server/src/tailscale/TailscaleHost.ts +++ b/apps/server/src/tailscale/TailscaleHost.ts @@ -10,7 +10,6 @@ import { type TailscaleWhois, type TailscaleWhoisParseError, } from "@t3tools/tailscale"; -import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -23,10 +22,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts"; const STATUS_TTL = Duration.seconds(5); -// Short, so a renewal reads a node that was tagged or removed seconds ago. -const WHOIS_TTL = Duration.seconds(3); -const WHOIS_CACHE_CAPACITY = 256; -// Lookups that miss the cache, per address. Each one spawns the CLI. +// Lookups per address from sign-in requests. Each one spawns the CLI. const WHOIS_LOOKUPS_PER_WINDOW = 10; const WHOIS_WINDOW_MS = 60_000; @@ -132,32 +128,19 @@ const make = Effect.gen(function* () { }); }); - // Keyed by this machine's node and the address, since a switch to another Tailscale - // account switches tailnets, and an address there names another device. - const whoisCache = yield* Cache.makeWith( - Effect.fn("TailscaleHost.whoisLookup")(function* (key: string) { - const address = key.slice(key.indexOf(" ") + 1); - if (!(yield* takeLookup(address))) { + // 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), ); - }), - { capacity: WHOIS_CACHE_CAPACITY, timeToLive: () => WHOIS_TTL }, + }, ); - const whois: TailscaleHost["Service"]["whois"] = (address, options) => - options?.fromServer === true - ? readTailscaleWhois(address).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ) - : status.pipe( - Effect.map(({ selfNodeId }) => selfNodeId ?? ""), - Effect.orElseSucceed(() => ""), - Effect.flatMap((account) => Cache.get(whoisCache, `${account} ${address}`)), - ); - return TailscaleHost.of({ inUse, status, whois }); }); diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index a1e82356d614d..cbb2f83f0223c 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -459,6 +459,45 @@ describe("TailscaleServe", () => { }), ); + 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")]]); + 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( + "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" }); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index b3510d01062fc..a7caa2752792c 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -119,37 +119,62 @@ const writeRecords = Effect.fn("TailscaleServe.writeRecords")(function* ( }); /** - * The Tailscale account whose Serve config is live now. Tailscale keeps Serve config per - * login profile, one user on one tailnet, which outlives a node ID when a device is re-added. + * 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* () { - // The profile ID is the key Tailscale stores Serve config under. Tagging a host changes - // its user, not its profile. const profile = yield* readTailscaleProfile().pipe( Effect.map(Option.getOrNull), Effect.orElseSucceed(() => null), ); - if (profile !== null) { - const label = [profile.account, profile.tailnet].filter((part) => part !== null).join(" on "); - return { key: profile.id, label: label || profile.id }; - } - // Older Tailscale lists no profiles, so the profile's tailnet and user stand in. const status = yield* readTailscaleStatus("active"); - const login = status.users.find((user) => user.userId === status.selfUserId)?.loginName; - const key = + const legacyKey = status.tailnetName !== null && status.selfUserId !== null ? `${status.tailnetName}/${status.selfUserId}` - : status.selfNodeId; - if (key === null) { + : 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 label = [login, status.tailnetName].filter((part) => part != null).join(" on ") || key; - return { key, label }; + 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 }; }); +/** The records that belong to `account`, and the rest. */ +const splitRecords = (records: ServeRecords, account: { readonly keys: ReadonlyArray }) => { + const entries = Object.entries(records); + return { + ours: entries.filter(([key]) => account.keys.includes(key)).map(([, record]) => record), + rest: Object.fromEntries(entries.filter(([key]) => !account.keys.includes(key))), + }; +}; + +// A switch while removing a mapping means the removal may have run on the other account. +const switchedDuring = (account: { readonly key: string }) => + currentAccount().pipe( + Effect.flatMap((now) => + now.keys.includes(account.key) + ? Effect.void + : Effect.fail( + new ServeRecordError({ + reason: "switched", + message: "Tailscale switched accounts during cleanup. Run this again.", + }), + ), + ), + ); + const failureKey = (cause: ClaimError) => cause._tag === "TailscaleCommandExitError" ? `exit:${cause.stderrDiagnostic}` : cause._tag; @@ -193,15 +218,14 @@ export const layer = (options: { 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 ((yield* currentAccount()).key !== recordedUnder) return; + if (!(yield* currentAccount()).keys.includes(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); - // A switch while removing it means the removal may have run on the other account. - if ((yield* currentAccount()).key !== recordedUnder) return; + yield* switchedDuring({ key: recordedUnder }); const { [recordedUnder]: _ours, ...rest } = yield* readRecords(options.stateDir); yield* writeRecords(options.stateDir, rest); }).pipe( @@ -221,17 +245,19 @@ export const layer = (options: { // Checked on every try, since Tailscale may have switched accounts since the last. yield* Ref.set(releasing, null); const now = yield* currentAccount(); - if ((yield* Ref.get(account)) !== now.key) { + const recordedUnder = yield* Ref.get(account); + if (recordedUnder === null || !now.keys.includes(recordedUnder)) { // An unreadable record may name a mapping to remove, so Serve waits for it. - const records = yield* readRecords(options.stateDir); - const earlier = records[now.key]; - if (earlier !== undefined && !sameTarget(earlier, target)) { + const { ours, rest } = splitRecords(yield* readRecords(options.stateDir), now); + 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, { - ...records, + ...rest, [now.key]: { ...target, label: now.label }, }); yield* Ref.set(account, now.key); @@ -278,15 +304,12 @@ export const releaseRecorded = Effect.fn("TailscaleServe.releaseRecorded")(funct const records = yield* readRecords(stateDir); if (Object.keys(records).length === 0) return { released: false, otherAccounts: [] }; const now = yield* currentAccount(); - const { [now.key]: ours, ...rest } = records; - const released = ours === undefined ? false : yield* releaseTailscaleServe(ours); - // A switch while removing it means the removal may have run on the other account. - if ((yield* currentAccount()).key !== now.key) { - return yield* new ServeRecordError({ - reason: "switched", - message: "Tailscale switched accounts during cleanup. Run this again.", - }); + const { ours, rest } = splitRecords(records, now); + let released = false; + for (const record of ours) { + if (yield* releaseTailscaleServe(record)) released = true; } + yield* switchedDuring(now); yield* writeRecords(stateDir, rest); return { released, otherAccounts: Object.values(rest).map((record) => record.label) }; }); From 9d21849a4a13a71dcadb14aa71a657cb67f92623 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 19:58:54 -0700 Subject: [PATCH 023/114] fix(tailscale): a Serve record for a logged-out profile is dropped, and the switched error reads once - Records note the login profile they were made on. `tailscale logout` deletes the profile and its Serve config, so a record for a profile Tailscale no longer lists is dropped instead of reported as another account. - "Tailscale switched accounts during cleanup. Run this again." prints as is. - The profile list parser has its own test. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.test.ts | 2 +- apps/server/src/cli/up.ts | 2 +- apps/server/src/tailscaleServe.test.ts | 31 ++++++++++++++++ apps/server/src/tailscaleServe.ts | 45 +++++++++++++++++------- packages/tailscale/src/tailscale.test.ts | 29 +++++++++++++++ packages/tailscale/src/tailscale.ts | 38 ++++++++++++-------- 6 files changed, 119 insertions(+), 28 deletions(-) diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index 7c3acc06727b3..a49fe0a442c8f 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -19,7 +19,7 @@ import { afterEach, vi } from "vite-plus/test"; vi.mock("@t3tools/tailscale", async (importOriginal) => ({ ...(await importOriginal()), readTailscaleStatus: () => Effect.fail("Tailscale is not running in tests"), - readTailscaleProfile: () => 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" }; diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 4ef5b52797410..88d4e52e7faf6 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -649,7 +649,7 @@ const setUp = Effect.fn("cli.up")(function* ( switch (error._tag) { case "ServeRecordError": return upError( - error.reason === "unreadable" + error.reason === "unreadable" || error.reason === "switched" ? error.message : `${error.message} Then run this again, ${orDelete}`, ); diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index cbb2f83f0223c..b1fa488e9aef5 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -482,6 +482,37 @@ describe("TailscaleServe", () => { }), ); + 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, + JSON.stringify({ + prof1: { + localPort: 18638, + localHost: "127.0.0.1", + servePort: SERVE_PORT, + label: "me@example.com on example.com", + profileId: "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", () => diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index a7caa2752792c..e9263fc57cebc 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -1,6 +1,6 @@ import { claimTailscaleServe, - readTailscaleProfile, + readTailscaleProfiles, readTailscaleStatus, releaseTailscaleServe, } from "@t3tools/tailscale"; @@ -8,7 +8,6 @@ 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 Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; @@ -74,7 +73,12 @@ const ServeTarget = Schema.Struct({ }); type ServeTarget = typeof ServeTarget.Type; // Keyed by account; `label` names it for a person, such as `you@example.com on example.com`. -const ServeRecord = Schema.Struct({ ...ServeTarget.fields, label: Schema.String }); +// `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), +}); const ServeRecords = Schema.fromJsonString(Schema.Record(Schema.String, ServeRecord)); type ServeRecords = typeof ServeRecords.Type; const NO_RECORDS: ServeRecords = {}; @@ -125,10 +129,8 @@ const writeRecords = Effect.fn("TailscaleServe.writeRecords")(function* ( * 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* () { - const profile = yield* readTailscaleProfile().pipe( - Effect.map(Option.getOrNull), - Effect.orElseSucceed(() => null), - ); + const profiles = yield* readTailscaleProfiles().pipe(Effect.orElseSucceed(() => null)); + const profile = profiles?.selected ?? null; const status = yield* readTailscaleStatus("active"); const legacyKey = status.tailnetName !== null && status.selfUserId !== null @@ -148,15 +150,30 @@ const currentAccount = Effect.fn("TailscaleServe.currentAccount")(function* () { null; const tailnet = profile?.tailnet ?? status.tailnetName; const label = [login, tailnet].filter((part) => part !== null).join(" on ") || key; - return { key, keys, label }; + return { key, keys, label, profileId: profile?.id ?? null, profiles: profiles?.ids ?? null }; }); -/** The records that belong to `account`, and the rest. */ -const splitRecords = (records: ServeRecords, account: { readonly keys: ReadonlyArray }) => { +/** + * The records that belong to `account`, and the rest. A record made on a login profile + * Tailscale no longer lists is dropped: `tailscale logout` deleted its Serve config with it. + */ +const splitRecords = ( + records: ServeRecords, + account: { + readonly keys: ReadonlyArray; + readonly profiles: ReadonlyArray | null; + }, +) => { const entries = Object.entries(records); + const gone = ([, record]: (typeof entries)[number]) => + record.profileId !== undefined && + account.profiles !== null && + !account.profiles.includes(record.profileId); return { ours: entries.filter(([key]) => account.keys.includes(key)).map(([, record]) => record), - rest: Object.fromEntries(entries.filter(([key]) => !account.keys.includes(key))), + rest: Object.fromEntries( + entries.filter((entry) => !account.keys.includes(entry[0]) && !gone(entry)), + ), }; }; @@ -258,7 +275,11 @@ export const layer = (options: { if (ours.length > 0) yield* switchedDuring(now); yield* writeRecords(options.stateDir, { ...rest, - [now.key]: { ...target, label: now.label }, + [now.key]: { + ...target, + label: now.label, + ...(now.profileId === null ? {} : { profileId: now.profileId }), + }, }); yield* Ref.set(account, now.key); } diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 78301703ec819..53d305b536a24 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -17,6 +17,7 @@ import { ensureTailscaleServe, parseTailscaleExitNode, parseTailscaleSshEnabled, + parseTailscaleProfiles, parseTailscaleStatus, parseTailscaleWhois, probeTailscaleHttpsEndpoint, @@ -238,6 +239,34 @@ describe("tailscale", () => { }), ); + // `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"], + }); + }), + ); + it.effect("parses host facts and peers, keeping user IDs past 2^53 exact", () => Effect.gen(function* () { const status = yield* parseTailscaleStatus(fullStatusJson); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 40f14f8a2e680..f96d3a2de2d31 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -697,28 +697,38 @@ const TailscaleProfilesJson = Schema.fromJsonString( ); 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 profile Tailscale is on now: one account on one tailnet. Tailscale keeps Serve - * config per profile. None when no profile is selected. + * 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 readTailscaleProfile = Effect.fn("tailscale.readTailscaleProfile")(function* () { +export const readTailscaleProfiles = Effect.fn("tailscale.readTailscaleProfiles")(function* () { const stdout = yield* runTailscale( "switch", ["switch", "--list", "--json"], TAILSCALE_STATUS_TIMEOUT, "active", ); - const profiles = yield* decodeTailscaleProfiles(stdout).pipe( - Effect.mapError((cause) => new TailscaleStatusParseError({ cause })), - ); - const selected = profiles.find((profile) => profile.selected === true); - return selected === undefined - ? Option.none() - : Option.some({ - id: selected.id, - tailnet: selected.tailnet ?? null, - account: selected.account ?? null, - }); + return yield* parseTailscaleProfiles(stdout); }); export function buildTailscaleHttpsBaseUrl(input: { From cc2a617b863624549f6136bcc5d98ff69ae46f7d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 20:08:26 -0700 Subject: [PATCH 024/114] fix(tailscale): sign-in decides on a fresh status read, and one bad peer cannot hide a grant - Sign-in, its policy scan and the session recheck read `tailscale status` fresh, so a status kept from before a switch to another Tailscale account cannot apply that account's policy and owner. - The status parser reads each peer separately; one it cannot read counts as unreadable, so the policy scan fails closed instead of losing every peer. - Serve cleanup: on Windows, Tailscale lists only the signed-in Windows user's profiles, so an unlisted profile's record is kept there. Signed out of the last profile, a record whose profile is gone is dropped. When Tailscale cannot list its profiles, cleanup says so instead of naming another account. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 9 +- apps/server/src/auth/TailnetSignIn.ts | 6 +- apps/server/src/cli/up.test.ts | 14 +++ apps/server/src/cli/up.ts | 2 +- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/TailnetPeers.test.ts | 1 + .../src/environment/TailscaleHealth.test.ts | 1 + apps/server/src/tailscale/TailscaleHost.ts | 15 +-- apps/server/src/tailscaleServe.test.ts | 81 +++++++++++++---- apps/server/src/tailscaleServe.ts | 91 +++++++++++++------ packages/tailscale/src/tailscale.test.ts | 20 ++++ packages/tailscale/src/tailscale.ts | 60 +++++++----- 12 files changed, 215 insertions(+), 86 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 033c16faae9de..b742c74af1363 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -61,6 +61,8 @@ const phoneNode = (overrides: JsonObject = {}): JsonObject => ({ /** 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; node: JsonObject | null; /** The whois `CapMap`: the app capabilities grants give the device. */ capMap?: JsonObject; @@ -86,7 +88,10 @@ const layerTailscaleHost = (tailnet: Tailnet) => TailscaleHost.TailscaleHost, TailscaleHost.TailscaleHost.of({ inUse: Effect.succeed(true), - status: Effect.suspend(() => parseTailscaleStatus(tailscaleJson(tailnet.status))), + status: Effect.suspend(() => + parseTailscaleStatus(tailscaleJson(tailnet.staleStatus ?? tailnet.status)), + ), + freshStatus: Effect.suspend(() => parseTailscaleStatus(tailscaleJson(tailnet.status))), whois: (address) => Effect.gen(function* () { const device = tailnet.devices?.[address] ?? { @@ -690,6 +695,8 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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" }, diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 606ce0cb3cdca..ae8191f9623d7 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -233,7 +233,7 @@ export const make = Effect.gen(function* () { const [scanPolicy, rescanPolicy] = yield* Effect.cachedInvalidateWithTTL( Effect.gen(function* () { const signInsBefore = yield* Ref.get(grantSignIns); - const status = yield* host.status; + const status = yield* host.freshStatus; const account = status.selfNodeId; const { granted, unreadable } = yield* policyGrantedPeers(status, (address) => host.whois(address, { fromServer: true }), @@ -338,7 +338,7 @@ export const make = Effect.gen(function* () { // A hard 403: production CORS allows any origin and the `dpop` header. if (!isTrustedOrigin(request.headers.origin)) return yield* refuse("browser_request"); - const status = yield* host.status.pipe( + const status = yield* host.freshStatus.pipe( Effect.catch((cause) => refuse("tailscale_unavailable", cause)), ); if (status.backendState !== "Running" || status.userspaceNetworking === true) { @@ -541,7 +541,7 @@ export const make = Effect.gen(function* () { } // A daemon that is starting or logged out lists no peers, which proves nothing for a // while. Past the grace period nothing vouches for these devices, so their access ends. - const status = yield* host.status.pipe(Effect.option); + const status = yield* host.freshStatus.pipe(Effect.option); if (status._tag === "None" || status.value.backendState !== "Running") { const nowMs = DateTime.toEpochMillis(now); statusFailingSince ??= nowMs; diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index a49fe0a442c8f..fbb3789c002a4 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -420,6 +420,20 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 up", (it) => 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", diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 88d4e52e7faf6..4ec760672df8d 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -649,7 +649,7 @@ const setUp = Effect.fn("cli.up")(function* ( switch (error._tag) { case "ServeRecordError": return upError( - error.reason === "unreadable" || error.reason === "switched" + error.reason !== "no_account" ? error.message : `${error.message} Then run this again, ${orDelete}`, ); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index bb92718213297..3bf8d1d07206e 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -28,6 +28,7 @@ const layerTailscaleHost = (statusJson: string) => TailscaleHost.TailscaleHost.of({ inUse: Effect.succeed(true), status: parseTailscaleStatus(statusJson), + freshStatus: parseTailscaleStatus(statusJson), whois: () => Effect.die("whois is not read by the descriptor"), }), ); diff --git a/apps/server/src/environment/TailnetPeers.test.ts b/apps/server/src/environment/TailnetPeers.test.ts index 671dfd5b90fe9..9e746e6ffc40f 100644 --- a/apps/server/src/environment/TailnetPeers.test.ts +++ b/apps/server/src/environment/TailnetPeers.test.ts @@ -42,6 +42,7 @@ const layerTest = ( TailscaleHost.TailscaleHost.of({ inUse: Effect.succeed(true), status, + freshStatus: status, whois: () => Effect.die("whois is not used for discovery"), }), ), diff --git a/apps/server/src/environment/TailscaleHealth.test.ts b/apps/server/src/environment/TailscaleHealth.test.ts index 5c3b688350266..017327ae5daa3 100644 --- a/apps/server/src/environment/TailscaleHealth.test.ts +++ b/apps/server/src/environment/TailscaleHealth.test.ts @@ -162,6 +162,7 @@ describe("TailscaleHealth", () => { 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/tailscale/TailscaleHost.ts b/apps/server/src/tailscale/TailscaleHost.ts index fecb39cce9379..af27a2789e468 100644 --- a/apps/server/src/tailscale/TailscaleHost.ts +++ b/apps/server/src/tailscale/TailscaleHost.ts @@ -52,6 +52,11 @@ export class TailscaleHost extends Context.Service< 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. Single-flight per address, kept for a few * seconds at most, and rate limited per address. Policy scans and rechecks @@ -105,12 +110,10 @@ const make = Effect.gen(function* () { entries?.some((entry) => !entry.internal && isTailscaleIpv4Address(entry.address)), ), ); - const status = yield* Effect.cachedWithTTL( - readTailscaleStatus(featureOn ? "active" : "passive").pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - STATUS_TTL, + 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) { @@ -141,7 +144,7 @@ const make = Effect.gen(function* () { }, ); - return TailscaleHost.of({ inUse, status, whois }); + 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 index b1fa488e9aef5..86fb964103c93 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -42,8 +42,11 @@ interface FakeOptions { 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. */ - readonly account?: string; + /** 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. */ @@ -61,7 +64,7 @@ const makeFakeTailscale = (options: FakeOptions) => { root: options.heldBy ?? null, otherRoot: options.heldOnOtherPort ?? null, statusFailurePending: false, - account: options.account ?? "n1", + account: options.account === undefined ? "n1" : options.account, }; // Completes when the daemon takes the mapping, before the CLI exits. const mappingApplied = Deferred.makeUnsafe(); @@ -86,6 +89,9 @@ const makeFakeTailscale = (options: FakeOptions) => { 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" } @@ -106,7 +112,7 @@ const makeFakeTailscale = (options: FakeOptions) => { code: 0, stdout: JSON.stringify({ BackendState: "Running", - Self: { ID: state.account, UserID: options.profile?.userId }, + Self: { ID: state.account ?? undefined, UserID: options.profile?.userId }, CurrentTailnet: { Name: options.profile?.tailnet }, }), }; @@ -171,11 +177,22 @@ const makeFakeTailscale = (options: FakeOptions) => { }), ); }); - return { state, spawner, mappingApplied }; + 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, + }, + }); const record = (localPort: number, servePort: number, account = "n1") => JSON.stringify({ [account]: { localPort, localHost: "127.0.0.1", servePort, label: account } }); @@ -210,7 +227,7 @@ const fakeLayers = ( ) => Layer.mergeAll( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, fake.spawner), - Layer.succeed(HostProcess.Platform, "linux"), + Layer.succeed(HostProcess.Platform, fake.platform), Layer.succeed(FileSystem.FileSystem, memoryFileSystem(files, writeFails)), Path.layer, ); @@ -485,20 +502,44 @@ describe("TailscaleServe", () => { 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, - JSON.stringify({ - prof1: { - localPort: 18638, - localHost: "127.0.0.1", - servePort: SERVE_PORT, - label: "me@example.com on example.com", - profileId: "prof1", - }, - }), - ], - ]); + 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("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("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( diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index e9263fc57cebc..668f216e405ca 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -4,6 +4,7 @@ import { 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"; @@ -19,7 +20,7 @@ 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"]), + reason: Schema.Literals(["unreadable", "no_account", "other_account", "switched", "unlisted"]), message: Schema.String, }) {} @@ -79,6 +80,7 @@ const ServeRecord = Schema.Struct({ label: Schema.String, profileId: 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 = {}; @@ -128,8 +130,11 @@ const writeRecords = Effect.fn("TailscaleServe.writeRecords")(function* ( * 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* () { - const profiles = yield* readTailscaleProfiles().pipe(Effect.orElseSucceed(() => null)); +type Profiles = Effect.Success> | null; +const readProfiles = readTailscaleProfiles().pipe(Effect.orElseSucceed((): Profiles => null)); + +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 = @@ -154,28 +159,31 @@ const currentAccount = Effect.fn("TailscaleServe.currentAccount")(function* () { }); /** - * The records that belong to `account`, and the rest. A record made on a login profile - * Tailscale no longer lists is dropped: `tailscale logout` deleted its Serve config with it. + * Sorts records by whose they are. `ours` belong to `account`. `gone` were made on a login + * profile Tailscale no longer lists: `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 its profiles to tell. */ -const splitRecords = ( +const sortRecords = Effect.fn("TailscaleServe.sortRecords")(function* ( records: ServeRecords, - account: { - readonly keys: ReadonlyArray; - readonly profiles: ReadonlyArray | null; - }, -) => { - const entries = Object.entries(records); - const gone = ([, record]: (typeof entries)[number]) => - record.profileId !== undefined && - account.profiles !== null && - !account.profiles.includes(record.profileId); - return { - ours: entries.filter(([key]) => account.keys.includes(key)).map(([, record]) => record), - rest: Object.fromEntries( - entries.filter((entry) => !account.keys.includes(entry[0]) && !gone(entry)), - ), + keys: ReadonlyArray, + 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 (keys.includes(key)) 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: { readonly key: string }) => @@ -261,11 +269,13 @@ export const layer = (options: { // 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 now = yield* currentAccount(); + const profiles = yield* readProfiles; + const now = yield* currentAccount(profiles); const recordedUnder = yield* Ref.get(account); if (recordedUnder === null || !now.keys.includes(recordedUnder)) { // An unreadable record may name a mapping to remove, so Serve waits for it. - const { ours, rest } = splitRecords(yield* readRecords(options.stateDir), now); + const records = yield* readRecords(options.stateDir); + const { ours, others, unlisted } = yield* sortRecords(records, now.keys, profiles); for (const earlier of ours.filter((record) => !sameTarget(record, target))) { yield* Ref.set(releasing, earlier); yield* releaseTailscaleServe(earlier); @@ -274,7 +284,8 @@ export const layer = (options: { // Its record is replaced only once its mapping is gone from this account. if (ours.length > 0) yield* switchedDuring(now); yield* writeRecords(options.stateDir, { - ...rest, + ...others, + ...unlisted, [now.key]: { ...target, label: now.label, @@ -324,15 +335,35 @@ export const releaseRecorded = Effect.fn("TailscaleServe.releaseRecorded")(funct ) { const records = yield* readRecords(stateDir); if (Object.keys(records).length === 0) return { released: false, otherAccounts: [] }; - const now = yield* currentAccount(); - const { ours, rest } = splitRecords(records, now); + 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?.keys ?? [], profiles); let released = false; for (const record of ours) { if (yield* releaseTailscaleServe(record)) released = true; } - yield* switchedDuring(now); - yield* writeRecords(stateDir, rest); - return { released, otherAccounts: Object.values(rest).map((record) => record.label) }; + 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. Run this again.", + }); + } + 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) }; }); /** diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 53d305b536a24..f0f1497d62b1e 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -267,6 +267,26 @@ describe("tailscale", () => { }), ); + // 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.unidentifiedPeerAddresses, [null]); + }), + ); + it.effect("parses host facts and peers, keeping user IDs past 2^53 exact", () => Effect.gen(function* () { const status = yield* parseTailscaleStatus(fullStatusJson); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index f96d3a2de2d31..2aa449b246473 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -207,6 +207,19 @@ const TailscaleStatusNodeFields = { 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({ Version: lenient(Schema.String), BackendState: lenient(Schema.String), @@ -224,22 +237,8 @@ const TailscaleStatusJson = Schema.Struct({ CapMap: lenient(Schema.Record(Schema.String, Schema.Unknown)), }), ), - Peer: lenient( - Schema.Record( - Schema.String, - 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), - }), - ), - ), + // Read per peer below, so one malformed peer cannot hide the rest. + Peer: Schema.optional(Schema.Unknown), }); export interface TailscalePeer { @@ -349,15 +348,26 @@ export const parseTailscaleStatus = ( decodeTailscaleStatusJson(rawStatusJson).pipe( Effect.map((parsed): TailscaleStatus => { const self = parsed.Self; - const peerEntries = Object.values(parsed.Peer ?? {}); - const unidentifiedPeerAddresses = peerEntries - .filter((peer) => nonEmptyOrNull(peer.ID) === null) - .map( - (peer) => - tailnetIpv4AddressesOf(peer.TailscaleIPs)[0] ?? - tailnetIpv6AddressesOf(peer.TailscaleIPs)[0] ?? - null, - ); + const rawPeers = + parsed.Peer === undefined || parsed.Peer === null + ? [] + : typeof parsed.Peer === "object" && !Array.isArray(parsed.Peer) + ? Object.values(parsed.Peer) + : [parsed.Peer]; + const decodedPeers = rawPeers.map((peer) => decodeTailscaleStatusPeer(peer)); + const peerEntries = decodedPeers.flatMap(Option.toArray); + const unidentifiedPeerAddresses = [ + ...peerEntries + .filter((peer) => nonEmptyOrNull(peer.ID) === null) + .map( + (peer) => + tailnetIpv4AddressesOf(peer.TailscaleIPs)[0] ?? + tailnetIpv6AddressesOf(peer.TailscaleIPs)[0] ?? + null, + ), + // A peer that cannot be read has no ID or address to look up, so a scan counts it unreadable. + ...decodedPeers.filter(Option.isNone).map(() => null), + ]; const peers = peerEntries.flatMap((peer): TailscalePeer[] => { const stableNodeId = nonEmptyOrNull(peer.ID); if (stableNodeId === null) return []; From b509471aa7959c0476ce0849e04aca2c71610eae Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 20:11:24 -0700 Subject: [PATCH 025/114] fix(tailscale): Serve records match by any key their account had, and server logs drop "run this again" - Records store every key the account had (profile ID, tailnet and user, node ID), and match when any is shared, so a later failed profile read still finds them. A switch counts only when no key is shared. - The server retries a switched or unlisted cleanup itself, so its log no longer tells the operator to run anything; `t3 up` still does. - The whois doc comment matches the uncached lookup. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.ts | 6 ++- apps/server/src/tailscale/TailscaleHost.ts | 5 +- apps/server/src/tailscaleServe.test.ts | 59 ++++++++++++++++++++-- apps/server/src/tailscaleServe.ts | 50 ++++++++++++------ 4 files changed, 95 insertions(+), 25 deletions(-) diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 4ec760672df8d..849c6394c0831 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -649,9 +649,11 @@ const setUp = Effect.fn("cli.up")(function* ( switch (error._tag) { case "ServeRecordError": return upError( - error.reason !== "no_account" + error.reason === "unreadable" ? error.message - : `${error.message} Then run this again, ${orDelete}`, + : error.reason === "switched" || error.reason === "unlisted" + ? `${error.message} Run this again.` + : `${error.message} Then run this again, ${orDelete}`, ); case "PlatformError": return upError(`Could not update the Tailscale Serve record: ${error.message}`); diff --git a/apps/server/src/tailscale/TailscaleHost.ts b/apps/server/src/tailscale/TailscaleHost.ts index af27a2789e468..14ebeb4ff215b 100644 --- a/apps/server/src/tailscale/TailscaleHost.ts +++ b/apps/server/src/tailscale/TailscaleHost.ts @@ -58,9 +58,8 @@ export class TailscaleHost extends Context.Service< */ readonly freshStatus: Effect.Effect; /** - * Who owns a tailnet address. Single-flight per address, kept for a few - * seconds at most, and rate limited per address. Policy scans and rechecks - * (`fromServer`) skip the cache and the limit; their callers bound how often they run. + * 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, diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index 86fb964103c93..00a857634db1f 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -193,8 +193,22 @@ const profileRecord = (id: string) => profileId: id, }, }); -const record = (localPort: number, servePort: number, account = "n1") => - JSON.stringify({ [account]: { localPort, localHost: "127.0.0.1", servePort, label: account } }); +// `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, +) => + JSON.stringify({ + [account]: { + localPort, + localHost: "127.0.0.1", + servePort, + label: account, + ...(keys === undefined ? {} : { keys }), + }, + }); // In memory, so writes land within the test clock's steps. const memoryFileSystem = (files: Map, writeFails = false) => { @@ -414,7 +428,7 @@ describe("TailscaleServe", () => { 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)); + assert.equal(files.get(CLAIM_PATH), record(18638, SERVE_PORT, "n1", ["n1"])); }), ); @@ -517,6 +531,43 @@ describe("TailscaleServe", () => { }), ); + 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"], + }, + }), + ], + ]); + 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("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. @@ -598,7 +649,7 @@ describe("TailscaleServe", () => { yield* Scope.close(scope, Exit.void); assert.equal(state.releases, 0); - assert.equal(files.get(CLAIM_PATH), record(18638, SERVE_PORT)); + assert.equal(files.get(CLAIM_PATH), record(18638, SERVE_PORT, "n1", ["n1"])); }), ); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 668f216e405ca..cf123fd05dce9 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -47,7 +47,11 @@ 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.message} Serve waits until then.`; + 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."; } @@ -79,6 +83,8 @@ 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)), }); type ServeRecord = typeof ServeRecord.Type; const ServeRecords = Schema.fromJsonString(Schema.Record(Schema.String, ServeRecord)); @@ -164,6 +170,10 @@ const currentAccount = Effect.fn("TailscaleServe.currentAccount")(function* (lis * 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 its profiles to tell. */ +const sameAccount = (a: ReadonlyArray, b: ReadonlyArray) => + a.some((key) => b.includes(key)); +const recordKeys = (key: string, record: ServeRecord) => [key, ...(record.keys ?? [])]; + const sortRecords = Effect.fn("TailscaleServe.sortRecords")(function* ( records: ServeRecords, keys: ReadonlyArray, @@ -176,7 +186,7 @@ const sortRecords = Effect.fn("TailscaleServe.sortRecords")(function* ( unlisted: {} as Record, }; for (const [key, record] of Object.entries(records)) { - if (keys.includes(key)) sorted.ours.push(record); + if (sameAccount(recordKeys(key, record), keys)) 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; @@ -186,15 +196,15 @@ const sortRecords = Effect.fn("TailscaleServe.sortRecords")(function* ( }); // A switch while removing a mapping means the removal may have run on the other account. -const switchedDuring = (account: { readonly key: string }) => +const switchedDuring = (keys: ReadonlyArray) => currentAccount().pipe( Effect.flatMap((now) => - now.keys.includes(account.key) + sameAccount(now.keys, keys) ? Effect.void : Effect.fail( new ServeRecordError({ reason: "switched", - message: "Tailscale switched accounts during cleanup. Run this again.", + message: "Tailscale switched accounts during cleanup.", }), ), ), @@ -233,8 +243,8 @@ export const layer = (options: { }; const logContext = { localPort: target.localPort, servePort: target.servePort }; const manualCommand = `tailscale serve --bg --https=${target.servePort} http://${target.localHost}:${target.localPort}`; - // The account this run's mapping is recorded under, once it is. - const account = yield* Ref.make(null); + // The keys of the account this run's mapping is recorded under, once it is. + const account = yield* Ref.make | null>(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. @@ -243,16 +253,23 @@ export const layer = (options: { 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 (!(yield* currentAccount()).keys.includes(recordedUnder)) return; + if (!sameAccount((yield* currentAccount()).keys, 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({ key: recordedUnder }); - const { [recordedUnder]: _ours, ...rest } = yield* readRecords(options.stateDir); - yield* writeRecords(options.stateDir, rest); + yield* switchedDuring(recordedUnder); + const records = yield* readRecords(options.stateDir); + yield* writeRecords( + options.stateDir, + Object.fromEntries( + Object.entries(records).filter( + ([key, record]) => !sameAccount(recordKeys(key, record), recordedUnder), + ), + ), + ); }).pipe( Effect.catch((cause) => Effect.logDebug("Left the Tailscale Serve record for the next run", { cause }), @@ -272,7 +289,7 @@ export const layer = (options: { const profiles = yield* readProfiles; const now = yield* currentAccount(profiles); const recordedUnder = yield* Ref.get(account); - if (recordedUnder === null || !now.keys.includes(recordedUnder)) { + if (recordedUnder === null || !sameAccount(now.keys, 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.keys, profiles); @@ -282,17 +299,18 @@ export const layer = (options: { 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); + if (ours.length > 0) yield* switchedDuring(now.keys); yield* writeRecords(options.stateDir, { ...others, ...unlisted, [now.key]: { ...target, label: now.label, + keys: now.keys, ...(now.profileId === null ? {} : { profileId: now.profileId }), }, }); - yield* Ref.set(account, now.key); + yield* Ref.set(account, now.keys); } yield* claimTailscaleServe(target); }).pipe( @@ -348,13 +366,13 @@ export const releaseRecorded = Effect.fn("TailscaleServe.releaseRecorded")(funct for (const record of ours) { if (yield* releaseTailscaleServe(record)) released = true; } - if (now !== null && ours.length > 0) yield* switchedDuring(now); + if (now !== null && ours.length > 0) yield* switchedDuring(now.keys); 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. Run this again.", + "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) { From 4024675b0c60ec283dbb0d2cfaab244f5e7c4d44 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 20:18:37 -0700 Subject: [PATCH 026/114] fix(tailscale): a policy scan across an account switch decides nothing, and sign-in reads Tailscale only after the proof - A policy scan checks the account again after its lookups and fails closed if it changed; a peer whose address now names another node counts as unread. - Sign-in gates on the kept status, the DPoP proof and the per-address whois limit before it reads `tailscale status` again to decide. - Serve records: when both sides know their login profile, the profile IDs decide alone, so two profiles on one tailnet and user never match. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 35 ++++++++++++++- apps/server/src/auth/TailnetSignIn.ts | 50 ++++++++++++++-------- apps/server/src/auth/tailnetGrants.ts | 15 +++++-- apps/server/src/tailscaleServe.test.ts | 35 +++++++++++++++ apps/server/src/tailscaleServe.ts | 47 ++++++++++++-------- 5 files changed, 142 insertions(+), 40 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index b742c74af1363..d45bcf87bbf56 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -63,6 +63,8 @@ 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; node: JsonObject | null; /** The whois `CapMap`: the app capabilities grants give the device. */ capMap?: JsonObject; @@ -91,7 +93,10 @@ const layerTailscaleHost = (tailnet: Tailnet) => status: Effect.suspend(() => parseTailscaleStatus(tailscaleJson(tailnet.staleStatus ?? tailnet.status)), ), - freshStatus: Effect.suspend(() => parseTailscaleStatus(tailscaleJson(tailnet.status))), + freshStatus: Effect.suspend(() => { + tailnet.freshReads = (tailnet.freshReads ?? 0) + 1; + return parseTailscaleStatus(tailscaleJson(tailnet.status)); + }), whois: (address) => Effect.gen(function* () { const device = tailnet.devices?.[address] ?? { @@ -461,6 +466,16 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { ), ); + 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); + }).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(); @@ -708,6 +723,24 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).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("with no node ID for this host, the owner rule does not apply", () => { const { ID: _, ...self } = hostStatus().Self; const tailnet = withLaptop(); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index ae8191f9623d7..c2ea69ef727ae 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -238,6 +238,9 @@ export const make = Effect.gen(function* () { const { granted, unreadable } = yield* policyGrantedPeers(status, (address) => host.whois(address, { fromServer: true }), ); + // Lookups that ran after a switch read another tailnet, so the scan says nothing. + const after = yield* host.freshStatus; + if (after.selfNodeId !== account) return yield* Effect.fail(["this host"]); 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); @@ -338,33 +341,35 @@ export const make = Effect.gen(function* () { // A hard 403: production CORS allows any origin and the `dpop` header. if (!isTrustedOrigin(request.headers.origin)) return yield* refuse("browser_request"); - const status = yield* host.freshStatus.pipe( - Effect.catch((cause) => refuse("tailscale_unavailable", cause)), - ); - if (status.backendState !== "Running" || status.userspaceNetworking === true) { - return yield* refuse("tailscale_unavailable"); - } - if (!TailscaleHost.isSupportedTailscaleVersion(status.version)) { - return yield* refuse("tailscale_outdated"); - } - const socket = requestSocket(request); - if ( - socket.localPort === undefined || - !isOwnHost(request.headers.host, status, socket.localPort) - ) { - return yield* refuse("browser_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) || - !status.tailnetIpv4Addresses.includes(localAddress) + !isTailscaleIpv4Address(remoteAddress) ) { 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 kept status gates the request, so only one with a valid proof that passes the + // per-address whois limit makes T3 Code read `tailscale status` again. + const kept = yield* host.status.pipe( + Effect.catch((cause) => refuse("tailscale_unavailable", cause)), + ); + const keptRefusal = hostRefusal(kept); + if (keptRefusal !== null) return yield* refuse(keptRefusal); const proofKeyThumbprint = yield* verifyRequestDpopProof({ request }).pipe( Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), @@ -384,6 +389,13 @@ export const make = Effect.gen(function* () { ), ), ); + // Decided on a status read now, as one kept from before an account switch names the + // old tailnet's policy and owner. + const status = yield* host.freshStatus.pipe( + Effect.catch((cause) => refuse("tailscale_unavailable", cause)), + ); + const refusal = hostRefusal(status); + if (refusal !== null) return yield* refuse(refusal); const decision = yield* decide(node, status); if (decision._tag === "Refused") { return yield* refuse( @@ -396,7 +408,7 @@ export const make = Effect.gen(function* () { (node.sharerUserId === null ? node.loginName : null) ?? remoteAddress, status.selfTags[0] ?? localAddress, - socket.localPort, + localPort, ) : undefined, ); diff --git a/apps/server/src/auth/tailnetGrants.ts b/apps/server/src/auth/tailnetGrants.ts index c2d4f82077be0..267e592137532 100644 --- a/apps/server/src/auth/tailnetGrants.ts +++ b/apps/server/src/auth/tailnetGrants.ts @@ -43,21 +43,30 @@ export const policyGrantedPeers = Effect.fn("tailnetGrants.policyGrantedPeers")( const peers = [ ...status.peers.map((peer) => { const address = peer.tailnetIpv4Addresses[0] ?? peer.tailnetIpv6Addresses[0]; - return { address, name: peer.hostName ?? address ?? peer.stableNodeId }; + return { + address, + name: peer.hostName ?? address ?? peer.stableNodeId, + stableNodeId: peer.stableNodeId as string | null, + }; }), ...status.unidentifiedPeerAddresses.map((address) => ({ address: address ?? undefined, name: address ?? "a peer with no ID", + stableNodeId: null, })), ]; const results = yield* Effect.forEach( peers, - ({ address, name }) => { + ({ address, name, stableNodeId }) => { if (address === undefined) return Effect.succeed({ name, granted: null }); return whois(address).pipe( Effect.map((node) => ({ name, - granted: Object.hasOwn(node.capabilities, TAILNET_APP_CAPABILITY) as boolean | null, + // 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 })), ); diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index 00a857634db1f..4c30ac65e4382 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -587,6 +587,41 @@ describe("TailscaleServe", () => { }), ); + 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: [] }); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index cf123fd05dce9..928d61ac8e70f 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -170,13 +170,25 @@ const currentAccount = Effect.fn("TailscaleServe.currentAccount")(function* (lis * 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 its profiles to tell. */ -const sameAccount = (a: ReadonlyArray, b: ReadonlyArray) => - a.some((key) => b.includes(key)); -const recordKeys = (key: string, record: ServeRecord) => [key, ...(record.keys ?? [])]; +/** 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; +} +// Two profiles can share a tailnet and user, as two Windows users signed in to one account, +// so known profile IDs decide alone; other keys match only when one side has no profile. +const sameAccount = (a: AccountKeys, b: AccountKeys) => + a.profileId !== null && b.profileId !== null + ? a.profileId === b.profileId + : a.keys.some((key) => b.keys.includes(key)); +const recordAccount = (key: string, record: ServeRecord): AccountKeys => ({ + keys: [key, ...(record.keys ?? [])], + profileId: record.profileId ?? null, +}); const sortRecords = Effect.fn("TailscaleServe.sortRecords")(function* ( records: ServeRecords, - keys: ReadonlyArray, + account: AccountKeys | null, profiles: Profiles, ) { const proofOfAbsence = profiles !== null && (yield* HostProcess.Platform) !== "win32"; @@ -186,8 +198,9 @@ const sortRecords = Effect.fn("TailscaleServe.sortRecords")(function* ( unlisted: {} as Record, }; for (const [key, record] of Object.entries(records)) { - if (sameAccount(recordKeys(key, record), keys)) sorted.ours.push(record); - else if (record.profileId === undefined) sorted.others[key] = record; + 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; @@ -196,10 +209,10 @@ const sortRecords = Effect.fn("TailscaleServe.sortRecords")(function* ( }); // A switch while removing a mapping means the removal may have run on the other account. -const switchedDuring = (keys: ReadonlyArray) => +const switchedDuring = (account: AccountKeys) => currentAccount().pipe( Effect.flatMap((now) => - sameAccount(now.keys, keys) + sameAccount(now, account) ? Effect.void : Effect.fail( new ServeRecordError({ @@ -244,7 +257,7 @@ export const layer = (options: { 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>(null); + 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. @@ -253,7 +266,7 @@ export const layer = (options: { 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()).keys, recordedUnder)) return; + if (!sameAccount(yield* currentAccount(), recordedUnder)) return; const released = yield* releaseTailscaleServe(target).pipe( Effect.tapError((cause) => Effect.logWarning("Failed to disable Tailscale Serve", { cause, ...logContext }), @@ -266,7 +279,7 @@ export const layer = (options: { options.stateDir, Object.fromEntries( Object.entries(records).filter( - ([key, record]) => !sameAccount(recordKeys(key, record), recordedUnder), + ([key, record]) => !sameAccount(recordAccount(key, record), recordedUnder), ), ), ); @@ -289,17 +302,17 @@ export const layer = (options: { const profiles = yield* readProfiles; const now = yield* currentAccount(profiles); const recordedUnder = yield* Ref.get(account); - if (recordedUnder === null || !sameAccount(now.keys, recordedUnder)) { + 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.keys, profiles); + 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.keys); + if (ours.length > 0) yield* switchedDuring(now); yield* writeRecords(options.stateDir, { ...others, ...unlisted, @@ -310,7 +323,7 @@ export const layer = (options: { ...(now.profileId === null ? {} : { profileId: now.profileId }), }, }); - yield* Ref.set(account, now.keys); + yield* Ref.set(account, { keys: now.keys, profileId: now.profileId }); } yield* claimTailscaleServe(target); }).pipe( @@ -361,12 +374,12 @@ export const releaseRecorded = Effect.fn("TailscaleServe.releaseRecorded")(funct error.reason === "no_account" ? Effect.succeed(null) : Effect.fail(error), }), ); - const { ours, others, unlisted } = yield* sortRecords(records, now?.keys ?? [], profiles); + 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.keys); + if (now !== null && ours.length > 0) yield* switchedDuring(now); yield* writeRecords(stateDir, { ...others, ...unlisted }); if (Object.keys(unlisted).length > 0) { return yield* new ServeRecordError({ From ab2c3097258763c3012a43d0749eae166c6d4fae Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 20:20:35 -0700 Subject: [PATCH 027/114] fix(cli): an unlisted Serve record gives the way out, and its doc comments sit on their code Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.ts | 6 ++++-- apps/server/src/tailscaleServe.ts | 18 +++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 849c6394c0831..89a1f4d597edb 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -651,9 +651,11 @@ const setUp = Effect.fn("cli.up")(function* ( return upError( error.reason === "unreadable" ? error.message - : error.reason === "switched" || error.reason === "unlisted" + : error.reason === "switched" ? `${error.message} Run this again.` - : `${error.message} Then run this again, ${orDelete}`, + : 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}`); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 928d61ac8e70f..036197e8ecd21 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -130,15 +130,15 @@ const writeRecords = Effect.fn("TailscaleServe.writeRecords")(function* ( 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. */ -type Profiles = Effect.Success> | null; -const readProfiles = readTailscaleProfiles().pipe(Effect.orElseSucceed((): Profiles => null)); - const currentAccount = Effect.fn("TailscaleServe.currentAccount")(function* (listed?: Profiles) { const profiles = listed === undefined ? yield* readProfiles : listed; const profile = profiles?.selected ?? null; @@ -164,12 +164,6 @@ const currentAccount = Effect.fn("TailscaleServe.currentAccount")(function* (lis return { key, keys, label, profileId: profile?.id ?? null, profiles: profiles?.ids ?? null }; }); -/** - * Sorts records by whose they are. `ours` belong to `account`. `gone` were made on a login - * profile Tailscale no longer lists: `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 its profiles to tell. - */ /** An account as a record or a read names it: every key it had, and its profile if listed. */ interface AccountKeys { readonly keys: ReadonlyArray; @@ -186,6 +180,12 @@ const recordAccount = (key: string, record: ServeRecord): AccountKeys => ({ profileId: record.profileId ?? 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, From 307cd3f97a257f8f60947161ad659800eb9a5657 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 20:28:38 -0700 Subject: [PATCH 028/114] fix(tailscale): a cold sign-in reads status twice, not four times, and the address limit comes first - A fresh status read also replaces the kept one, and sign-in drops its kept gate, so a cold owner sign-in reads `tailscale status` twice (the decision and the policy scan's account check), not four times. - Sign-in order: cheap socket checks, the per-address whois limit, the DPoP proof, then the status read; an over-limit peer costs no replay-marker write. - Serve records: with one side's login profile unknown, node IDs decide, so another profile on the same tailnet and user is kept, not removed. - `t3 tailscale require-grant` checks the account again after its scan. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 12 +++++- apps/server/src/auth/TailnetSignIn.ts | 28 ++++++------- apps/server/src/cli/tailscale.ts | 6 +++ .../src/tailscale/TailscaleHost.test.ts | 12 ++++++ apps/server/src/tailscale/TailscaleHost.ts | 10 +++-- apps/server/src/tailscaleServe.test.ts | 42 ++++++++++++++++++- apps/server/src/tailscaleServe.ts | 28 ++++++++++--- 7 files changed, 111 insertions(+), 27 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index d45bcf87bbf56..8450bf72847d2 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -65,6 +65,8 @@ interface Tailnet { 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; node: JsonObject | null; /** The whois `CapMap`: the app capabilities grants give the device. */ capMap?: JsonObject; @@ -93,12 +95,17 @@ const layerTailscaleHost = (tailnet: Tailnet) => status: Effect.suspend(() => parseTailscaleStatus(tailscaleJson(tailnet.staleStatus ?? tailnet.status)), ), + // As on the host, a read now also replaces the kept one. freshStatus: Effect.suspend(() => { tailnet.freshReads = (tailnet.freshReads ?? 0) + 1; + delete tailnet.staleStatus; return parseTailscaleStatus(tailscaleJson(tailnet.status)); }), - whois: (address) => + whois: (address, options) => Effect.gen(function* () { + if (tailnet.rateLimited === true && options?.fromServer !== true) { + return yield* new TailscaleHost.TailscaleWhoisRateLimitedError({}); + } const device = tailnet.devices?.[address] ?? { node: tailnet.node, capMap: tailnet.capMap, @@ -473,6 +480,9 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { "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))); }); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index c2ea69ef727ae..b921a75d15759 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -233,7 +233,8 @@ export const make = Effect.gen(function* () { const [scanPolicy, rescanPolicy] = yield* Effect.cachedInvalidateWithTTL( Effect.gen(function* () { const signInsBefore = yield* Ref.get(grantSignIns); - const status = yield* host.freshStatus; + // The status a sign-in just read, or a recheck's; a switch after it is caught below. + const status = yield* host.status; const account = status.selfNodeId; const { granted, unreadable } = yield* policyGrantedPeers(status, (address) => host.whois(address, { fromServer: true }), @@ -349,7 +350,8 @@ export const make = Effect.gen(function* () { if ( remoteAddress === undefined || localAddress === undefined || - !isTailscaleIpv4Address(remoteAddress) + !isTailscaleIpv4Address(remoteAddress) || + !isTailscaleIpv4Address(localAddress) ) { return yield* refuse("not_direct_tailnet"); } @@ -363,19 +365,8 @@ export const make = Effect.gen(function* () { : !status.tailnetIpv4Addresses.includes(localAddress) ? "not_direct_tailnet" : null; - // The kept status gates the request, so only one with a valid proof that passes the - // per-address whois limit makes T3 Code read `tailscale status` again. - const kept = yield* host.status.pipe( - Effect.catch((cause) => refuse("tailscale_unavailable", cause)), - ); - const keptRefusal = hostRefusal(kept); - if (keptRefusal !== null) return yield* refuse(keptRefusal); - - const proofKeyThumbprint = yield* verifyRequestDpopProof({ request }).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), - Effect.provideService(Crypto.Crypto, crypto), - ); - + // 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) @@ -389,8 +380,13 @@ export const make = Effect.gen(function* () { ), ), ); + const proofKeyThumbprint = yield* verifyRequestDpopProof({ request }).pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), + Effect.provideService(Crypto.Crypto, crypto), + ); // Decided on a status read now, as one kept from before an account switch names the - // old tailnet's policy and owner. + // 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)), ); diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 76629a63f1bc1..d9eee7c58d2f8 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -55,6 +55,12 @@ export const turnOnRequireGrant = Effect.fn("cli.tailscale.turnOnRequireGrant")( const readPolicyGrantedPeers = readTailscaleStatus("active").pipe( Effect.flatMap((status) => policyGrantedPeers(status, readTailscaleWhois).pipe( + // Lookups that ran after an account switch read another tailnet, so they say nothing. + Effect.tap(() => + readTailscaleStatus("active").pipe( + Effect.filterOrFail((after) => after.selfNodeId === status.selfNodeId), + ), + ), Effect.map((scan) => ({ ...scan, tagged: status.selfTags.length > 0, diff --git a/apps/server/src/tailscale/TailscaleHost.test.ts b/apps/server/src/tailscale/TailscaleHost.test.ts index 9c4c3db0e727b..50f33d1368965 100644 --- a/apps/server/src/tailscale/TailscaleHost.test.ts +++ b/apps/server/src/tailscale/TailscaleHost.test.ts @@ -47,6 +47,18 @@ const layerTest = (whoisSpawns: { count: number }) => ); it.layer(NodeServices.layer)("TailscaleHost", (it) => { + it.effect("keeps a fresh status read, so the next kept read shares it", () => { + const spawns = { count: 0 }; + return Effect.gen(function* () { + const host = yield* TailscaleHost.TailscaleHost; + yield* host.freshStatus; + yield* host.status; + expect(spawns.count).toBe(1); + yield* host.freshStatus; + expect(spawns.count).toBe(2); + }).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* () { diff --git a/apps/server/src/tailscale/TailscaleHost.ts b/apps/server/src/tailscale/TailscaleHost.ts index 14ebeb4ff215b..4919cbf499f21 100644 --- a/apps/server/src/tailscale/TailscaleHost.ts +++ b/apps/server/src/tailscale/TailscaleHost.ts @@ -109,10 +109,14 @@ const make = Effect.gen(function* () { entries?.some((entry) => !entry.internal && isTailscaleIpv4Address(entry.address)), ), ); - const freshStatus = readTailscaleStatus(featureOn ? "active" : "passive").pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + const [status, forgetStatus] = yield* Effect.cachedInvalidateWithTTL( + readTailscaleStatus(featureOn ? "active" : "passive").pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + STATUS_TTL, ); - const status = yield* Effect.cachedWithTTL(freshStatus, STATUS_TTL); + // Kept too, so a reader right after it shares the answer rather than reading again. + const freshStatus = forgetStatus.pipe(Effect.andThen(status)); const lookupsByAddress = yield* Ref.make(new Map>()); const takeLookup = Effect.fnUntraced(function* (address: string) { diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index 4c30ac65e4382..bc1fb7e2f91a7 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -199,6 +199,7 @@ const record = ( servePort: number, account = "n1", keys?: ReadonlyArray, + nodeId?: string, ) => JSON.stringify({ [account]: { @@ -207,6 +208,7 @@ const record = ( servePort, label: account, ...(keys === undefined ? {} : { keys }), + ...(nodeId === undefined ? {} : { nodeId }), }, }); @@ -428,7 +430,7 @@ describe("TailscaleServe", () => { 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"])); + assert.equal(files.get(CLAIM_PATH), record(18638, SERVE_PORT, "n1", ["n1"], "n1")); }), ); @@ -549,6 +551,7 @@ describe("TailscaleServe", () => { label: "me@example.com on example.com", profileId: "prof1", keys: ["prof1", "example.com/7", "n1"], + nodeId: "n1", }, }), ], @@ -568,6 +571,41 @@ describe("TailscaleServe", () => { }), ); + 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", + }, + }); + 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. @@ -684,7 +722,7 @@ describe("TailscaleServe", () => { yield* Scope.close(scope, Exit.void); assert.equal(state.releases, 0); - assert.equal(files.get(CLAIM_PATH), record(18638, SERVE_PORT, "n1", ["n1"])); + assert.equal(files.get(CLAIM_PATH), record(18638, SERVE_PORT, "n1", ["n1"], "n1")); }), ); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 036197e8ecd21..36a1e6451c14d 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -85,6 +85,7 @@ const ServeRecord = Schema.Struct({ 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)); @@ -161,23 +162,35 @@ const currentAccount = Effect.fn("TailscaleServe.currentAccount")(function* (lis 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, profiles: profiles?.ids ?? null }; + 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; other keys match only when one side has no profile. +// 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; other +// keys match only when neither side has a profile, as from a CLI too old to list them. const sameAccount = (a: AccountKeys, b: AccountKeys) => a.profileId !== null && b.profileId !== null ? a.profileId === b.profileId - : a.keys.some((key) => b.keys.includes(key)); + : (a.profileId !== null || b.profileId !== null) && a.nodeId !== null && b.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, }); /** @@ -320,10 +333,15 @@ export const layer = (options: { ...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 }); + yield* Ref.set(account, { + keys: now.keys, + profileId: now.profileId, + nodeId: now.nodeId, + }); } yield* claimTailscaleServe(target); }).pipe( From 8a594c047e0c86d75e0ef980949656b66ceef870 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 20:32:01 -0700 Subject: [PATCH 029/114] fix(tailscale): sign-in refuses a caller the fresh status does not name at its address - After the fresh status read, the caller must be the peer that status names at its address, as the session recheck asks, so a whois answered before an account switch is not judged by the new account. - A Serve leftover from an earlier sign-in to the same account (a new profile on Windows) says so, instead of "another account". - The policy scan's peer list is typed, not cast. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 52 +++++++++++++++++++++- apps/server/src/auth/TailnetSignIn.ts | 11 +++++ apps/server/src/auth/tailnetGrants.ts | 8 +++- apps/server/src/cli/up.ts | 6 +++ apps/server/src/tailscaleServe.ts | 10 ++++- 5 files changed, 81 insertions(+), 6 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 8450bf72847d2..712b3e7cd6c9f 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -87,19 +87,42 @@ 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; + if (node === null || 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, + }, + }, + }; +}; + const layerTailscaleHost = (tailnet: Tailnet) => Layer.succeed( TailscaleHost.TailscaleHost, TailscaleHost.TailscaleHost.of({ inUse: Effect.succeed(true), status: Effect.suspend(() => - parseTailscaleStatus(tailscaleJson(tailnet.staleStatus ?? tailnet.status)), + parseTailscaleStatus( + tailscaleJson(withCaller(tailnet, tailnet.staleStatus ?? tailnet.status)), + ), ), // As on the host, a read now also replaces the kept one. freshStatus: Effect.suspend(() => { tailnet.freshReads = (tailnet.freshReads ?? 0) + 1; delete tailnet.staleStatus; - return parseTailscaleStatus(tailscaleJson(tailnet.status)); + return parseTailscaleStatus(tailscaleJson(withCaller(tailnet, tailnet.status))); }), whois: (address, options) => Effect.gen(function* () { @@ -486,6 +509,31 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).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(); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index b921a75d15759..62dc9ead303b8 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -392,6 +392,17 @@ export const make = Effect.gen(function* () { ); const refusal = hostRefusal(status); if (refusal !== null) return yield* refuse(refusal); + // The caller must be the peer this status names at its address, as the recheck asks, + // so a whois answered before an account switch is not judged by the new account. + if ( + !status.peers.some( + (peer) => + peer.stableNodeId === node.stableNodeId && + peer.tailnetIpv4Addresses.includes(remoteAddress), + ) + ) { + return yield* refuse("tailscale_unavailable"); + } const decision = yield* decide(node, status); if (decision._tag === "Refused") { return yield* refuse( diff --git a/apps/server/src/auth/tailnetGrants.ts b/apps/server/src/auth/tailnetGrants.ts index 267e592137532..504f3ecbbe9b6 100644 --- a/apps/server/src/auth/tailnetGrants.ts +++ b/apps/server/src/auth/tailnetGrants.ts @@ -40,13 +40,17 @@ export const policyGrantedPeers = Effect.fn("tailnetGrants.policyGrantedPeers")( status: TailscaleStatus, whois: (address: string) => Effect.Effect, ) { - const peers = [ + 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 as string | null, + stableNodeId: peer.stableNodeId, }; }), ...status.unidentifiedPeerAddresses.map((address) => ({ diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 89a1f4d597edb..f99deebaa2044 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -675,6 +675,12 @@ const setUp = Effect.fn("cli.up")(function* ( } 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)) { + return yield* upError( + `A Tailscale Serve mapping may remain from an earlier sign-in to ${leftover.currentLabel}, or from another Windows user signed in to it; Tailscale keeps each one'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.`, diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index 36a1e6451c14d..dc61b3a7fa26f 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -383,7 +383,9 @@ export const releaseRecorded = Effect.fn("TailscaleServe.releaseRecorded")(funct stateDir: string, ) { const records = yield* readRecords(stateDir); - if (Object.keys(records).length === 0) return { released: false, otherAccounts: [] }; + 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( @@ -412,7 +414,11 @@ export const releaseRecorded = Effect.fn("TailscaleServe.releaseRecorded")(funct message: "Tailscale is not signed in here. Run `tailscale up` to sign in.", }); } - return { released, otherAccounts: Object.values(others).map((record) => record.label) }; + return { + released, + otherAccounts: Object.values(others).map((record) => record.label), + currentLabel: now?.label ?? null, + }; }); /** From 95206a1eaf32a44d3a578908c404aed5dd20d0c7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 20:39:38 -0700 Subject: [PATCH 030/114] fix(tailscale): a fresh status read is always its own, and a connection from the host itself is told so - `freshStatus` starts its own read; joining a kept read already running could answer from before an account switch. The policy scan starts from the status the decision just read, so a cold sign-in still reads status twice. - The caller check lets the host's own node through to the `own_node` refusal; the test fake no longer lists the host among its own peers. - Serve records: with one side's profile unknown and no node ID to compare, ownership is uncertain, so the record is kept. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 10 ++-- apps/server/src/auth/TailnetSignIn.ts | 13 +++-- .../src/tailscale/TailscaleHost.test.ts | 54 +++++++++++++++---- apps/server/src/tailscale/TailscaleHost.ts | 11 ++-- apps/server/src/tailscaleServe.test.ts | 15 +++++- apps/server/src/tailscaleServe.ts | 8 +-- 6 files changed, 80 insertions(+), 31 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 712b3e7cd6c9f..373a3950c8b4c 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -91,7 +91,13 @@ const granted = (...values: ReadonlyArray): JsonObject => ({ const withCaller = (tailnet: Tailnet, status: JsonObject): JsonObject => { const node = tailnet.node; const peers = (status.Peer ?? {}) as Record; - if (node === null || Object.values(peers).some((peer) => peer.ID === node.StableID)) { + 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) ?? []; @@ -118,10 +124,8 @@ const layerTailscaleHost = (tailnet: Tailnet) => tailscaleJson(withCaller(tailnet, tailnet.staleStatus ?? tailnet.status)), ), ), - // As on the host, a read now also replaces the kept one. freshStatus: Effect.suspend(() => { tailnet.freshReads = (tailnet.freshReads ?? 0) + 1; - delete tailnet.staleStatus; return parseTailscaleStatus(tailscaleJson(withCaller(tailnet, tailnet.status))); }), whois: (address, options) => diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 62dc9ead303b8..c80ce05bc0f3c 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -230,11 +230,13 @@ export const make = Effect.gen(function* () { 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* () { const signInsBefore = yield* Ref.get(grantSignIns); - // The status a sign-in just read, or a recheck's; a switch after it is caught below. - const status = yield* host.status; + // A switch after this status is caught below. + const status = (yield* Ref.get(scanFrom)) ?? (yield* host.status); const account = status.selfNodeId; const { granted, unreadable } = yield* policyGrantedPeers(status, (address) => host.whois(address, { fromServer: true }), @@ -271,12 +273,14 @@ export const make = Effect.gen(function* () { * 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* (account: string | null) { + 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( @@ -305,7 +309,7 @@ export const make = Effect.gen(function* () { ? refused("grant_unusable") : ({ _tag: "Allowed", scopes: granted, via: "grant" } satisfies AccessDecision); } - const off = yield* ownerRuleOff(status.selfNodeId); + 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"); @@ -395,6 +399,7 @@ export const make = Effect.gen(function* () { // The caller must be the peer this status names at its address, as the recheck asks, // so a whois answered before an account switch is not judged by the new account. if ( + node.stableNodeId !== status.selfNodeId && !status.peers.some( (peer) => peer.stableNodeId === node.stableNodeId && diff --git a/apps/server/src/tailscale/TailscaleHost.test.ts b/apps/server/src/tailscale/TailscaleHost.test.ts index 50f33d1368965..c50ea2ae11812 100644 --- a/apps/server/src/tailscale/TailscaleHost.test.ts +++ b/apps/server/src/tailscale/TailscaleHost.test.ts @@ -1,7 +1,9 @@ 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"; @@ -15,23 +17,49 @@ const WHOIS_JSON = JSON.stringify({ Node: { StableID: "nStudio", User: 7, Addresses: ["100.88.1.2/32"], AllowedIPs: [] }, }); -const layerTest = (whoisSpawns: { count: number }) => +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(() => { + 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: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + 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(WHOIS_JSON)), + stdout: Stream.make(new TextEncoder().encode(stdout)), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, @@ -47,15 +75,19 @@ const layerTest = (whoisSpawns: { count: number }) => ); it.layer(NodeServices.layer)("TailscaleHost", (it) => { - it.effect("keeps a fresh status read, so the next kept read shares it", () => { - const spawns = { count: 0 }; + 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; - yield* host.freshStatus; - yield* host.status; - expect(spawns.count).toBe(1); - yield* host.freshStatus; - expect(spawns.count).toBe(2); + 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))); }); diff --git a/apps/server/src/tailscale/TailscaleHost.ts b/apps/server/src/tailscale/TailscaleHost.ts index 4919cbf499f21..3e909ee59b99e 100644 --- a/apps/server/src/tailscale/TailscaleHost.ts +++ b/apps/server/src/tailscale/TailscaleHost.ts @@ -109,14 +109,11 @@ const make = Effect.gen(function* () { entries?.some((entry) => !entry.internal && isTailscaleIpv4Address(entry.address)), ), ); - const [status, forgetStatus] = yield* Effect.cachedInvalidateWithTTL( - readTailscaleStatus(featureOn ? "active" : "passive").pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - STATUS_TTL, + // 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), ); - // Kept too, so a reader right after it shares the answer rather than reading again. - const freshStatus = forgetStatus.pipe(Effect.andThen(status)); + const status = yield* Effect.cachedWithTTL(freshStatus, STATUS_TTL); const lookupsByAddress = yield* Ref.make(new Map>()); const takeLookup = Effect.fnUntraced(function* (address: string) { diff --git a/apps/server/src/tailscaleServe.test.ts b/apps/server/src/tailscaleServe.test.ts index bc1fb7e2f91a7..267099605fcdb 100644 --- a/apps/server/src/tailscaleServe.test.ts +++ b/apps/server/src/tailscaleServe.test.ts @@ -476,7 +476,7 @@ describe("TailscaleServe", () => { profileId: "prof1", profile: { tailnet: "example.com", userId: "tagged-devices" }, }); - const files = new Map([[CLAIM_PATH, record(18638, SERVE_PORT, "prof1")]]); + const files = new Map([[CLAIM_PATH, profileRecord("prof1")]]); const scope = yield* Scope.make(); yield* Layer.buildWithScope( TailscaleServe.layerReleaseLeftover({ stateDir: STATE_DIR }).pipe( @@ -499,7 +499,9 @@ describe("TailscaleServe", () => { profileId: "prof1", profile: { tailnet: "example.com", userId: "7" }, }); - const files = new Map([[CLAIM_PATH, record(18638, SERVE_PORT, "example.com/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( @@ -589,6 +591,15 @@ describe("TailscaleServe", () => { 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(); diff --git a/apps/server/src/tailscaleServe.ts b/apps/server/src/tailscaleServe.ts index dc61b3a7fa26f..d6bfb0e6d6ace 100644 --- a/apps/server/src/tailscaleServe.ts +++ b/apps/server/src/tailscaleServe.ts @@ -179,13 +179,13 @@ interface AccountKeys { 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; other -// keys match only when neither side has a profile, as from a CLI too old to list them. +// 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 && b.nodeId !== null - ? a.nodeId === b.nodeId + : 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 ?? [])], From 076aa2a2978e8ab7a81ae578e50ece5a677d8f9f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 20:41:24 -0700 Subject: [PATCH 031/114] fix(cli): the same-account Serve leftover names Windows users only on Windows Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index f99deebaa2044..b3e16ee6fa3e5 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -677,8 +677,9 @@ const setUp = Effect.fn("cli.up")(function* ( 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.Platform) === "win32"; return yield* upError( - `A Tailscale Serve mapping may remain from an earlier sign-in to ${leftover.currentLabel}, or from another Windows user signed in to it; Tailscale keeps each one's Serve settings apart. Delete ${record} if that sign-in is gone.`, + `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 one's Serve settings apart. Delete ${record} if that sign-in is gone.`, ); } const one = leftover.otherAccounts.length === 1; From 4289395b181293964e6c6ce10b3e33ada5e41623 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 20:46:28 -0700 Subject: [PATCH 032/114] fix(tailscale): a device that joins during a policy scan keeps the scan from deciding The scan starts from the status the decision read, so a device that joined since was never looked up. Its account check after the lookups now also checks that no device joined, and fails closed if one did, so a new granted device cannot be missed and its sign-in count cleared. `require-grant off` setup text says it lets untagged devices in only while the policy grants no device here. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 29 ++++++++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 11 +++++++- apps/server/src/cli/up.ts | 2 +- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 373a3950c8b4c..0f9dfea268a19 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -803,6 +803,35 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).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("with no node ID for this host, the owner rule does not apply", () => { const { ID: _, ...self } = hostStatus().Self; const tailnet = withLaptop(); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index c80ce05bc0f3c..c28f5f1770bd8 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -241,9 +241,18 @@ export const make = Effect.gen(function* () { const { granted, unreadable } = yield* policyGrantedPeers(status, (address) => host.whois(address, { fromServer: true }), ); - // Lookups that ran after a switch read another tailnet, so the scan says nothing. + // Lookups that ran after a switch read another tailnet, and a device that joined since + // the scanned list was not looked up, so either way the scan says nothing. const after = yield* host.freshStatus; if (after.selfNodeId !== account) return yield* Effect.fail(["this host"]); + const scanned = new Set(status.peers.map((peer) => peer.stableNodeId)); + const joined = after.peers.filter((peer) => !scanned.has(peer.stableNodeId)); + if ( + joined.length > 0 || + after.unidentifiedPeerAddresses.length > status.unidentifiedPeerAddresses.length + ) { + return yield* Effect.fail(joined.map((peer) => peer.hostName ?? peer.stableNodeId)); + } 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); diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index b3e16ee6fa3e5..85096c4d1dd96 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -511,7 +511,7 @@ const formatTailscale = ( ? [ "Add this grant to your tailnet policy:", indent(tailnetGrantSnippet(src, dst, port)), - "Let your untagged devices in without a grant: `t3 tailscale require-grant off`.", + "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.", From 999366311b35775c49d8748bff4f27f76d720d22 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 20:48:11 -0700 Subject: [PATCH 033/114] fix(cli): the same-account Serve leftover reads cleanly on every platform Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 85096c4d1dd96..46ec6eb438db4 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -677,9 +677,9 @@ const setUp = Effect.fn("cli.up")(function* ( 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.Platform) === "win32"; + 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 one's Serve settings apart. Delete ${record} if that sign-in is gone.`, + `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; From 56d5fa082e8560bde0c2861a67d209ccebd0bae7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 20:57:06 -0700 Subject: [PATCH 034/114] fix(tailscale): a grant a policy scan found stands when a device joins, and swapped peers with no ID count as joined - A device that joined during a scan makes only its "no grants" unproven; a grant it found stands, so an older "no grants" is never reused in its place. - Peers with no ID are compared by address, so one replacing another during a scan counts as a join. - `t3 tailscale require-grant` uses the same check as the server's scan. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 70 ++++++++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 16 ++--- apps/server/src/auth/tailnetGrants.ts | 18 ++++++ apps/server/src/cli/tailscale.ts | 13 ++-- 4 files changed, 103 insertions(+), 14 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 0f9dfea268a19..50c37e9eee9fe 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -832,6 +832,76 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).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(); + return Effect.gen(function* () { + // A scan finds no grants, then the laptop gets one and the next scan reads it. + yield* signIn(makeDpopKey()); + 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, 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 stands, not the older "no grants". + tailnet.devices = { [LAPTOP_ADDRESS]: { node: null }, [TABLET_ADDRESS]: tabletDevice }; + yield* TestClock.adjust("11 seconds"); + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("no_grant"); + }).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(); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index c28f5f1770bd8..0b14bd8cbe142 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -41,7 +41,7 @@ import { verifyRequestDpopProof } from "./dpop.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; -import { policyGrantedPeers, tailnetGrantScopes } from "./tailnetGrants.ts"; +import { peersJoined, policyGrantedPeers, tailnetGrantScopes } from "./tailnetGrants.ts"; const GRANT_TTL = Duration.minutes(2); const RECHECK_INTERVAL = Duration.seconds(30); @@ -241,17 +241,13 @@ export const make = Effect.gen(function* () { const { granted, unreadable } = yield* policyGrantedPeers(status, (address) => host.whois(address, { fromServer: true }), ); - // Lookups that ran after a switch read another tailnet, and a device that joined since - // the scanned list was not looked up, so either way the scan says nothing. + // 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; if (after.selfNodeId !== account) return yield* Effect.fail(["this host"]); - const scanned = new Set(status.peers.map((peer) => peer.stableNodeId)); - const joined = after.peers.filter((peer) => !scanned.has(peer.stableNodeId)); - if ( - joined.length > 0 || - after.unidentifiedPeerAddresses.length > status.unidentifiedPeerAddresses.length - ) { - return yield* Effect.fail(joined.map((peer) => peer.hostName ?? peer.stableNodeId)); + if (granted.length === 0 && peersJoined(status, after)) { + return yield* Effect.fail(["a device that joined during the check"]); } const now = yield* Clock.currentTimeMillis; // "No grants" is rechecked sooner, so a new grant is noticed within a minute. diff --git a/apps/server/src/auth/tailnetGrants.ts b/apps/server/src/auth/tailnetGrants.ts index 504f3ecbbe9b6..5ada4cc722819 100644 --- a/apps/server/src/auth/tailnetGrants.ts +++ b/apps/server/src/auth/tailnetGrants.ts @@ -31,6 +31,24 @@ export const tailnetGrantScopes = ( return [...scopes]; }; +/** + * Whether `after` lists a device `before` did not, so a scan of `before` never looked it up. + * Peers with no ID are compared by address, so one replacing another still counts. + */ +export const peersJoined = (before: TailscaleStatus, after: TailscaleStatus): boolean => { + const known = new Set(before.peers.map((peer) => peer.stableNodeId)); + if (after.peers.some((peer) => !known.has(peer.stableNodeId))) return true; + const unidentified = new Map(); + for (const address of before.unidentifiedPeerAddresses) { + unidentified.set(address, (unidentified.get(address) ?? 0) + 1); + } + return after.unidentifiedPeerAddresses.some((address) => { + const left = unidentified.get(address) ?? 0; + unidentified.set(address, 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 diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index d9eee7c58d2f8..0f0894ccbdd3b 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -14,7 +14,7 @@ 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 { policyGrantedPeers } from "../auth/tailnetGrants.ts"; +import { peersJoined, policyGrantedPeers } from "../auth/tailnetGrants.ts"; import { formatTailscaleHealth, readTailscaleHealth } from "../environment/TailscaleHealth.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -55,10 +55,15 @@ export const turnOnRequireGrant = Effect.fn("cli.tailscale.turnOnRequireGrant")( const readPolicyGrantedPeers = readTailscaleStatus("active").pipe( Effect.flatMap((status) => policyGrantedPeers(status, readTailscaleWhois).pipe( - // Lookups that ran after an account switch read another tailnet, so they say nothing. - Effect.tap(() => + // Lookups that ran after an account switch read another tailnet, and "no grants" is + // unproven if a device joined meanwhile, as the server's own scan treats them. + Effect.tap((scan) => readTailscaleStatus("active").pipe( - Effect.filterOrFail((after) => after.selfNodeId === status.selfNodeId), + Effect.filterOrFail( + (after) => + after.selfNodeId === status.selfNodeId && + (scan.granted.length > 0 || !peersJoined(status, after)), + ), ), ), Effect.map((scan) => ({ From 3bd2a00abef0513d3701087d1d37d8cf7a49d789 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 21:00:19 -0700 Subject: [PATCH 035/114] test(server): table test for the policy scan's join check Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/tailnetGrants.test.ts | 37 ++++++++++++++++++++++ apps/server/src/auth/tailnetGrants.ts | 6 +++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/auth/tailnetGrants.test.ts diff --git a/apps/server/src/auth/tailnetGrants.test.ts b/apps/server/src/auth/tailnetGrants.test.ts new file mode 100644 index 0000000000000..30a72c9394267 --- /dev/null +++ b/apps/server/src/auth/tailnetGrants.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "@effect/vitest"; + +import { peersJoined } from "./tailnetGrants.ts"; + +const list = (ids: ReadonlyArray, unidentified: ReadonlyArray = []) => ({ + peers: ids.map((stableNodeId) => ({ stableNodeId })), + unidentifiedPeerAddresses: unidentified, +}); + +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"], ["100.88.1.21"]), + after: list(["a"], ["100.88.1.22"]), + joined: true, + }, + { + name: "a second peer with no ID at the same address", + before: list([], ["100.88.1.21"]), + after: list([], ["100.88.1.21", "100.88.1.21"]), + joined: true, + }, + { + name: "the same peers with no ID, one with no address", + before: list([], [null, "100.88.1.21"]), + after: list([], ["100.88.1.21", null]), + 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 index 5ada4cc722819..d2c5a2ac4dd34 100644 --- a/apps/server/src/auth/tailnetGrants.ts +++ b/apps/server/src/auth/tailnetGrants.ts @@ -35,7 +35,11 @@ export const tailnetGrantScopes = ( * Whether `after` lists a device `before` did not, so a scan of `before` never looked it up. * Peers with no ID are compared by address, so one replacing another still counts. */ -export const peersJoined = (before: TailscaleStatus, after: TailscaleStatus): boolean => { +type PeerList = Pick & { + readonly peers: ReadonlyArray<{ readonly stableNodeId: string }>; +}; + +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; const unidentified = new Map(); From 22c2d3e6c608cac6e5b946ee5c4e51174cc27ddb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 21:16:51 -0700 Subject: [PATCH 036/114] fix(tailscale): a grant a scan sees is kept at once, and peers with no ID are compared by node key - A policy scan keeps a grant it sees before its account check, so if that check cannot finish, a later scan falls back to the grant, never to an older "no grants". - The status parser keeps each peer with no ID's node key, and the join check compares by key and address, so another node taking the same address counts. - `t3 tailscale require-grant` says the account or its devices changed during the check, instead of "Tailscale is not answering". - A test whose first scan left a sign-in code for the background recheck to race with now starts from a refused device, so it no longer flakes. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 71 +++++++++++++++-- apps/server/src/auth/TailnetSignIn.ts | 6 ++ apps/server/src/auth/tailnetGrants.test.ts | 22 +++--- apps/server/src/auth/tailnetGrants.ts | 26 ++++--- apps/server/src/cli/tailscale.ts | 76 ++++++++++--------- apps/server/src/cli/up.test.ts | 2 +- .../src/environment/TailscaleHealth.test.ts | 2 +- packages/tailscale/src/tailscale.test.ts | 4 +- packages/tailscale/src/tailscale.ts | 49 +++++++----- 9 files changed, 175 insertions(+), 83 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 50c37e9eee9fe..c05b8b59a7b30 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -67,6 +67,8 @@ interface Tailnet { 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; node: JsonObject | null; /** The whois `CapMap`: the app capabilities grants give the device. */ capMap?: JsonObject; @@ -126,7 +128,9 @@ const layerTailscaleHost = (tailnet: Tailnet) => ), freshStatus: Effect.suspend(() => { tailnet.freshReads = (tailnet.freshReads ?? 0) + 1; - return parseTailscaleStatus(tailscaleJson(withCaller(tailnet, tailnet.status))); + return tailnet.freshReads === tailnet.failFreshReadAt + ? parseTailscaleStatus("not json") + : parseTailscaleStatus(tailscaleJson(withCaller(tailnet, tailnet.status))); }), whois: (address, options) => Effect.gen(function* () { @@ -838,11 +842,30 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { const tabletDevice = { node: phoneNode({ StableID: "nTablet", Addresses: [`${TABLET_ADDRESS}/32`] }), }; + 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", + }; const tailnet = withLaptop(); + tailnet.status = hostStatus({ Peer: { ...peers, stranger: strangerPeer } }); + tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; return Effect.gen(function* () { - // A scan finds no grants, then the laptop gets one and the next scan reads it. - yield* signIn(makeDpopKey()); + // Another user's device is refused after a scan that finds no grants. Being refused, + // it leaves no sign-in code for the background recheck to scan on. + 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"); @@ -852,12 +875,50 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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, tablet } }); + 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 stands, not the older "no grants". - tailnet.devices = { [LAPTOP_ADDRESS]: { node: null }, [TABLET_ADDRESS]: tabletDevice }; + 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 STRANGER_ADDRESS = "100.88.1.30"; + const stranger = phoneNode({ + StableID: "nStranger", + User: "int64:999", + Addresses: [`${STRANGER_ADDRESS}/32`], + AllowedIPs: [`${STRANGER_ADDRESS}/32`], + }); + const tailnet = withLaptop(); + tailnet.status = hostStatus({ + Peer: { + ...peers, + stranger: { ID: "nStranger", TailscaleIPs: [STRANGER_ADDRESS], UserID: "int64:999" }, + }, + }); + tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; + return Effect.gen(function* () { + // A refused device leaves no sign-in code, so no background recheck scans meanwhile. + 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 the laptop's grant, then its account check cannot read Tailscale. + tailnet.failFreshReadAt = (tailnet.freshReads ?? 0) + 2; + 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))); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 0b14bd8cbe142..f6ea29330d59b 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -241,6 +241,12 @@ export const make = Effect.gen(function* () { const { granted, unreadable } = yield* policyGrantedPeers(status, (address) => host.whois(address, { fromServer: true }), ); + // A grant seen is kept at once, so if the checks below cannot finish, a later scan that + // cannot read every peer falls back to it, never to an older "no grants". + if (granted.length > 0) { + const seenAt = yield* Clock.currentTimeMillis; + yield* Ref.set(lastCleanScan, { account, granted: true, at: seenAt }); + } // 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. diff --git a/apps/server/src/auth/tailnetGrants.test.ts b/apps/server/src/auth/tailnetGrants.test.ts index 30a72c9394267..56b091e6fbfdf 100644 --- a/apps/server/src/auth/tailnetGrants.test.ts +++ b/apps/server/src/auth/tailnetGrants.test.ts @@ -2,9 +2,13 @@ import { expect, it } from "@effect/vitest"; import { peersJoined } from "./tailnetGrants.ts"; -const list = (ids: ReadonlyArray, unidentified: ReadonlyArray = []) => ({ +// 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 })), - unidentifiedPeerAddresses: unidentified, + unidentifiedPeers: unidentified.map((peer) => { + const [key = "", address] = peer.split("@"); + return { key, address: address ?? null }; + }), }); it.each([ @@ -13,20 +17,20 @@ it.each([ { name: "a device joined", before: list(["a"]), after: list(["a", "b"]), joined: true }, { name: "a peer with no ID replaced another", - before: list(["a"], ["100.88.1.21"]), - after: list(["a"], ["100.88.1.22"]), + before: list(["a"], ["k1@100.88.1.21"]), + after: list(["a"], ["k2@100.88.1.22"]), joined: true, }, { - name: "a second peer with no ID at the same address", - before: list([], ["100.88.1.21"]), - after: list([], ["100.88.1.21", "100.88.1.21"]), + 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([], [null, "100.88.1.21"]), - after: list([], ["100.88.1.21", null]), + before: list([], ["k1", "k2@100.88.1.21"]), + after: list([], ["k2@100.88.1.21", "k1"]), joined: false, }, ])( diff --git a/apps/server/src/auth/tailnetGrants.ts b/apps/server/src/auth/tailnetGrants.ts index d2c5a2ac4dd34..d4b7a7205fbf1 100644 --- a/apps/server/src/auth/tailnetGrants.ts +++ b/apps/server/src/auth/tailnetGrants.ts @@ -31,24 +31,28 @@ export const tailnetGrantScopes = ( 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 address, so one replacing another still counts. + * Peers with no ID are compared by node key and address, so one replacing another counts. */ -type PeerList = Pick & { - readonly peers: ReadonlyArray<{ readonly stableNodeId: string }>; -}; 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; - const unidentified = new Map(); - for (const address of before.unidentifiedPeerAddresses) { - unidentified.set(address, (unidentified.get(address) ?? 0) + 1); + // 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.unidentifiedPeerAddresses.some((address) => { - const left = unidentified.get(address) ?? 0; - unidentified.set(address, left - 1); + return after.unidentifiedPeers.some((peer) => { + const left = unidentified.get(keyOf(peer)) ?? 0; + unidentified.set(keyOf(peer), left - 1); return left === 0; }); }; @@ -75,7 +79,7 @@ export const policyGrantedPeers = Effect.fn("tailnetGrants.policyGrantedPeers")( stableNodeId: peer.stableNodeId, }; }), - ...status.unidentifiedPeerAddresses.map((address) => ({ + ...status.unidentifiedPeers.map(({ address }) => ({ address: address ?? undefined, name: address ?? "a peer with no ID", stableNodeId: null, diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 0f0894ccbdd3b..512550b07634c 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -51,26 +51,28 @@ export const turnOnRequireGrant = Effect.fn("cli.tailscale.turnOnRequireGrant")( ); }); -/** Peers the tailnet policy grants T3 Code here, and whether this host is tagged. Null when Tailscale cannot say. */ +/** + * Peers the tailnet policy grants T3 Code here, and whether this host is tagged. Null when + * Tailscale cannot say, and "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 = readTailscaleStatus("active").pipe( Effect.flatMap((status) => policyGrantedPeers(status, readTailscaleWhois).pipe( - // Lookups that ran after an account switch read another tailnet, and "no grants" is - // unproven if a device joined meanwhile, as the server's own scan treats them. - Effect.tap((scan) => + Effect.flatMap((scan) => readTailscaleStatus("active").pipe( - Effect.filterOrFail( - (after) => - after.selfNodeId === status.selfNodeId && - (scan.granted.length > 0 || !peersJoined(status, after)), + Effect.map((after) => + after.selfNodeId !== status.selfNodeId || + (scan.granted.length === 0 && peersJoined(status, after)) + ? ("changed" as const) + : { + ...scan, + tagged: status.selfTags.length > 0, + noNode: status.selfNodeId === null, + }, ), ), ), - Effect.map((scan) => ({ - ...scan, - tagged: status.selfTags.length > 0, - noNode: status.selfNodeId === null, - })), ), ), Effect.option, @@ -114,7 +116,8 @@ const requireGrantCommand = Command.make("require-grant", { yield* fs.remove(file, { force: true }); } const since = (yield* fs.exists(file)) ? (yield* fs.readFileString(file)).trim() : null; - const scan = yield* readPolicyGrantedPeers; + const read = yield* readPolicyGrantedPeers; + const scan = read === "changed" ? null : read; const granted = scan?.granted ?? []; const lines = since !== null @@ -127,30 +130,35 @@ const requireGrantCommand = Command.make("require-grant", { `Grant required: yes, because your tailnet policy grants ${TAILNET_APP_CAPABILITY} here to: ${granted.join(", ")}.`, "Remove those grants to let your own untagged devices in without one.", ] - : scan === null - ? ["Grant required: yes, because Tailscale is not answering."] - : scan.noNode - ? [ - "Grant required: yes, because Tailscale names no node for this host.", - "Run `tailscale up` to sign in.", - ] - : scan.unreadable.length > 0 + : read === "changed" + ? [ + "Grant required: unknown. This host's Tailscale account or its devices changed during the check.", + "Run this again.", + ] + : scan === null + ? ["Grant required: yes, because Tailscale is not answering."] + : scan.noNode ? [ - `Grant required: probably. Tailscale could not read ${scan.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.", + "Grant required: yes, because Tailscale names no node for this host.", + "Run `tailscale up` to sign in.", ] - : scan.tagged && !(yield* hasOwner(config.baseDir, config.tailscaleOwnerUserId)) + : scan.unreadable.length > 0 ? [ - "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: probably. Tailscale could not read ${scan.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.", ] - : [ - "Grant required: no. Your tailnet policy grants T3 Code here to no device,", - "so 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", - ]; + : scan.tagged && !(yield* hasOwner(config.baseDir, config.tailscaleOwnerUserId)) + ? [ + "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,", + "so 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, diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index fbb3789c002a4..203419d1df4e3 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -46,7 +46,7 @@ const TAGGED_HOST: Tailscale.TailscaleStatus = { certDomains: [], userspaceNetworking: false, peers: [], - unidentifiedPeerAddresses: [], + unidentifiedPeers: [], users: [{ userId: "123", loginName: "me@example.com" }], }; diff --git a/apps/server/src/environment/TailscaleHealth.test.ts b/apps/server/src/environment/TailscaleHealth.test.ts index 017327ae5daa3..40f8d8eb14021 100644 --- a/apps/server/src/environment/TailscaleHealth.test.ts +++ b/apps/server/src/environment/TailscaleHealth.test.ts @@ -37,7 +37,7 @@ const running: TailscaleStatus = { certDomains: ["studio.tail1234.ts.net"], userspaceNetworking: false, peers: [], - unidentifiedPeerAddresses: [], + unidentifiedPeers: [], }; const context = { executable: "tailscale", subcommand: "status", argumentCount: 2 } as const; diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index f0f1497d62b1e..fb3684625463f 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -233,7 +233,7 @@ describe("tailscale", () => { certDomains: [], userspaceNetworking: null, peers: [], - unidentifiedPeerAddresses: [], + unidentifiedPeers: [], users: [], }); }), @@ -283,7 +283,7 @@ describe("tailscale", () => { status.peers.map((peer) => peer.stableNodeId), ["nLaptop"], ); - assert.deepEqual(status.unidentifiedPeerAddresses, [null]); + assert.deepEqual(status.unidentifiedPeers, [{ key: "nodekey:b", address: null }]); }), ); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 2aa449b246473..a44ddea36be66 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -295,10 +295,13 @@ export interface TailscaleStatus { readonly userspaceNetworking: boolean | null; readonly peers: readonly TailscalePeer[]; /** - * The first tailnet address of each peer with no ID, or null with none. It cannot be - * tracked, but a scan of the tailnet policy must still read it. + * 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 unidentifiedPeerAddresses: ReadonlyArray; + 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 }>; } @@ -348,26 +351,32 @@ export const parseTailscaleStatus = ( decodeTailscaleStatusJson(rawStatusJson).pipe( Effect.map((parsed): TailscaleStatus => { const self = parsed.Self; - const rawPeers = + const rawPeers: ReadonlyArray = parsed.Peer === undefined || parsed.Peer === null ? [] : typeof parsed.Peer === "object" && !Array.isArray(parsed.Peer) - ? Object.values(parsed.Peer) - : [parsed.Peer]; - const decodedPeers = rawPeers.map((peer) => decodeTailscaleStatusPeer(peer)); - const peerEntries = decodedPeers.flatMap(Option.toArray); - const unidentifiedPeerAddresses = [ - ...peerEntries - .filter((peer) => nonEmptyOrNull(peer.ID) === null) - .map( - (peer) => - tailnetIpv4AddressesOf(peer.TailscaleIPs)[0] ?? - tailnetIpv6AddressesOf(peer.TailscaleIPs)[0] ?? - null, - ), + ? 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. - ...decodedPeers.filter(Option.isNone).map(() => null), - ]; + 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 []; @@ -411,7 +420,7 @@ export const parseTailscaleStatus = ( certDomains: parsed.CertDomains ?? [], userspaceNetworking: parsed.TUN === undefined ? null : !parsed.TUN, peers, - unidentifiedPeerAddresses, + unidentifiedPeers, users: Object.entries(parsed.User ?? {}).map(([userId, user]) => ({ userId, loginName: nonEmptyOrNull(user.LoginName), From 196884156290882b941000000df18d8af5009fa9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 21:17:54 -0700 Subject: [PATCH 037/114] test(server): say why the found grant stands after the laptop goes unreadable Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index c05b8b59a7b30..c27dc049ae08d 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -879,7 +879,8 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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 stands, not the older "no grants". + // 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 }, From 56cbb8aa00acf752b8c41f885d4f30180a68779c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 21:22:35 -0700 Subject: [PATCH 038/114] fix(tailscale): a grant a policy scan reads is kept the moment its lookup returns A scan cut short after it read a grant, as when the asking device disconnects, no longer drops it, so a later scan that cannot read that peer falls back to the grant, not to an older "no grants". Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 45 ++++++++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 21 ++++++---- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index c27dc049ae08d..9f88168c75b95 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -925,6 +925,51 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).pipe(Effect.provide(layerTest(tailnet))); }); + it.effect("a grant a scan saw stands when the scan is cut short", () => { + const STRANGER_ADDRESS = "100.88.1.30"; + const TABLET_ADDRESS = "100.88.1.9"; + const stranger = phoneNode({ + StableID: "nStranger", + User: "int64:999", + Addresses: [`${STRANGER_ADDRESS}/32`], + AllowedIPs: [`${STRANGER_ADDRESS}/32`], + }); + const tailnet = withLaptop(); + tailnet.status = hostStatus({ + Peer: { + ...peers, + stranger: { ID: "nStranger", TailscaleIPs: [STRANGER_ADDRESS], UserID: "int64:999" }, + 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 refused device leaves no sign-in code, so no background recheck scans meanwhile. + 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( "a peer with no ID that replaces another during a scan keeps it from deciding", () => { diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index f6ea29330d59b..482d14f007c29 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -3,6 +3,7 @@ import { type AuthSessionId, type AuthTailnetSignInResult, AuthStandardClientScopes, + TAILNET_APP_CAPABILITY, TAILNET_SUBJECT_PREFIX, TailnetAccessError, type TailnetDevice, @@ -238,15 +239,21 @@ export const make = Effect.gen(function* () { // A switch after this status is caught below. const status = (yield* Ref.get(scanFrom)) ?? (yield* host.status); const account = status.selfNodeId; + // A grant is kept the moment its lookup returns, so if the scan is cut short or its + // checks below cannot finish, a later scan that cannot read every peer falls back to + // it, never to an older "no grants". + const keepGrant = Clock.currentTimeMillis.pipe( + Effect.flatMap((at) => Ref.set(lastCleanScan, { account, granted: true, at })), + ); const { granted, unreadable } = yield* policyGrantedPeers(status, (address) => - host.whois(address, { fromServer: true }), + host + .whois(address, { fromServer: true }) + .pipe( + Effect.tap((node) => + Object.hasOwn(node.capabilities, TAILNET_APP_CAPABILITY) ? keepGrant : Effect.void, + ), + ), ); - // A grant seen is kept at once, so if the checks below cannot finish, a later scan that - // cannot read every peer falls back to it, never to an older "no grants". - if (granted.length > 0) { - const seenAt = yield* Clock.currentTimeMillis; - yield* Ref.set(lastCleanScan, { account, granted: true, at: seenAt }); - } // 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. From d42f6a4e6cf0c9ffabb2e1992ca26d6ac63ab6fe Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 21:25:24 -0700 Subject: [PATCH 039/114] test(server): share the refused-device fixture, and tidy the join-check doc and CLI wrap Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 40 +++++++--------------- apps/server/src/auth/tailnetGrants.ts | 1 - apps/server/src/cli/tailscale.ts | 3 +- 3 files changed, 14 insertions(+), 30 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 9f88168c75b95..fa95a93ae7301 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -636,6 +636,16 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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" }; const signInLaptop = (key: ReturnType) => signIn(key, { remoteAddress: LAPTOP_ADDRESS }).pipe( Effect.flatMap((grant) => exchange(grant.credential, key)), @@ -842,18 +852,6 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { const tabletDevice = { node: phoneNode({ StableID: "nTablet", Addresses: [`${TABLET_ADDRESS}/32`] }), }; - 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", - }; const tailnet = withLaptop(); tailnet.status = hostStatus({ Peer: { ...peers, stranger: strangerPeer } }); tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; @@ -892,18 +890,11 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }); it.effect("a grant a scan saw stands when the scan cannot finish its checks", () => { - const STRANGER_ADDRESS = "100.88.1.30"; - const stranger = phoneNode({ - StableID: "nStranger", - User: "int64:999", - Addresses: [`${STRANGER_ADDRESS}/32`], - AllowedIPs: [`${STRANGER_ADDRESS}/32`], - }); const tailnet = withLaptop(); tailnet.status = hostStatus({ Peer: { ...peers, - stranger: { ID: "nStranger", TailscaleIPs: [STRANGER_ADDRESS], UserID: "int64:999" }, + stranger: strangerPeer, }, }); tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; @@ -926,19 +917,12 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }); it.effect("a grant a scan saw stands when the scan is cut short", () => { - const STRANGER_ADDRESS = "100.88.1.30"; const TABLET_ADDRESS = "100.88.1.9"; - const stranger = phoneNode({ - StableID: "nStranger", - User: "int64:999", - Addresses: [`${STRANGER_ADDRESS}/32`], - AllowedIPs: [`${STRANGER_ADDRESS}/32`], - }); const tailnet = withLaptop(); tailnet.status = hostStatus({ Peer: { ...peers, - stranger: { ID: "nStranger", TailscaleIPs: [STRANGER_ADDRESS], UserID: "int64:999" }, + stranger: strangerPeer, tablet: { ID: "nTablet", TailscaleIPs: [TABLET_ADDRESS], UserID: `int64:${OWNER_ID}` }, }, }); diff --git a/apps/server/src/auth/tailnetGrants.ts b/apps/server/src/auth/tailnetGrants.ts index d4b7a7205fbf1..018637641640d 100644 --- a/apps/server/src/auth/tailnetGrants.ts +++ b/apps/server/src/auth/tailnetGrants.ts @@ -39,7 +39,6 @@ type PeerList = Pick & { * 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; diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 512550b07634c..0b42c55e8c305 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -132,7 +132,8 @@ const requireGrantCommand = Command.make("require-grant", { ] : read === "changed" ? [ - "Grant required: unknown. This host's Tailscale account or its devices changed during the check.", + "Grant required: unknown. This host's Tailscale account or its devices", + "changed during the check.", "Run this again.", ] : scan === null From fa83b49e7959c16c90f5fa35de5c3da187c91d77 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 21:32:57 -0700 Subject: [PATCH 040/114] fix(tailscale): a policy scan keeps only a grant the listed node holds The scan now keeps a grant once the node-ID check confirms it, not from the raw whois answer, so a grant read from another account's tailnet during a switch is never kept for this one. The sign-in test fake can answer a held whois from the tailnet as it is once released. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 65 ++++++++++++++++++---- apps/server/src/auth/TailnetSignIn.ts | 17 ++---- apps/server/src/auth/tailnetGrants.ts | 4 +- 3 files changed, 63 insertions(+), 23 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index fa95a93ae7301..41b6bc2af2972 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -76,11 +76,15 @@ interface Tailnet { loginName?: string; /** Other devices whois answers for, by address. */ devices?: Record; - /** Holds the next whois for `address` after it reads the tailnet, until `release`. */ + /** + * 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; }; } @@ -137,12 +141,12 @@ const layerTailscaleHost = (tailnet: Tailnet) => if (tailnet.rateLimited === true && options?.fromServer !== true) { return yield* new TailscaleHost.TailscaleWhoisRateLimitedError({}); } - const device = tailnet.devices?.[address] ?? { - node: tailnet.node, - capMap: tailnet.capMap, - }; - const json = - device.node === null + const answer = () => { + const device = tailnet.devices?.[address] ?? { + node: tailnet.node, + capMap: tailnet.capMap, + }; + return device.node === null ? "{}" : tailscaleJson({ Node: device.node, @@ -151,12 +155,16 @@ const layerTailscaleHost = (tailnet: Tailnet) => ? {} : { 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); }), }), @@ -856,8 +864,7 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { tailnet.status = hostStatus({ Peer: { ...peers, stranger: strangerPeer } }); tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; return Effect.gen(function* () { - // Another user's device is refused after a scan that finds no grants. Being refused, - // it leaves no sign-in code for the background recheck to scan on. + // A scan that finds no grants, from the refused stranger. expect(yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: STRANGER_ADDRESS }))).toBe( "other_user", ); @@ -899,7 +906,7 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }); tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; return Effect.gen(function* () { - // A refused device leaves no sign-in code, so no background recheck scans meanwhile. + // A scan that finds no grants, from the refused stranger. yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: STRANGER_ADDRESS })); tailnet.devices = { ...tailnet.devices, @@ -934,7 +941,7 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }, }; return Effect.gen(function* () { - // A refused device leaves no sign-in code, so no background recheck scans meanwhile. + // A scan that finds no grants, from the refused stranger. yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: STRANGER_ADDRESS })); tailnet.devices = { ...tailnet.devices, @@ -954,6 +961,42 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).pipe(Effect.provide(layerTest(tailnet))); }); + it.effect("a grant read from another account during a scan is not kept for this one", () => { + 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]: { + node: phoneNode({ StableID: "nLaptopAtWork", Addresses: [`${LAPTOP_ADDRESS}/32`] }), + capMap: granted({ scopes: ["read"] }), + }, + }; + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("policy_unknown"); + // Back home, with the laptop unreadable, the older "no grants" decides, not that grant. + tailnet.status = home; + tailnet.devices = { ...tailnet.devices, [LAPTOP_ADDRESS]: { node: null } }; + yield* TestClock.adjust("11 seconds"); + 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", () => { diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 482d14f007c29..ca3e49f83f31b 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -3,7 +3,6 @@ import { type AuthSessionId, type AuthTailnetSignInResult, AuthStandardClientScopes, - TAILNET_APP_CAPABILITY, TAILNET_SUBJECT_PREFIX, TailnetAccessError, type TailnetDevice, @@ -239,20 +238,16 @@ export const make = Effect.gen(function* () { // A switch after this status is caught below. const status = (yield* Ref.get(scanFrom)) ?? (yield* host.status); const account = status.selfNodeId; - // A grant is kept the moment its lookup returns, so if the scan is cut short or its - // checks below cannot finish, a later scan that cannot read every peer falls back to + // A grant is kept the moment its lookup confirms it, from the node the list names, so if + // the scan is cut short or its checks below cannot finish, a later scan that cannot read every peer falls back to // it, never to an older "no grants". const keepGrant = Clock.currentTimeMillis.pipe( Effect.flatMap((at) => Ref.set(lastCleanScan, { account, granted: true, at })), ); - const { granted, unreadable } = yield* policyGrantedPeers(status, (address) => - host - .whois(address, { fromServer: true }) - .pipe( - Effect.tap((node) => - Object.hasOwn(node.capabilities, TAILNET_APP_CAPABILITY) ? keepGrant : Effect.void, - ), - ), + 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 diff --git a/apps/server/src/auth/tailnetGrants.ts b/apps/server/src/auth/tailnetGrants.ts index 018637641640d..97dd121db0ab7 100644 --- a/apps/server/src/auth/tailnetGrants.ts +++ b/apps/server/src/auth/tailnetGrants.ts @@ -59,11 +59,12 @@ export const peersJoined = (before: PeerList, after: PeerList): boolean => { /** * 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. + * 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; @@ -98,6 +99,7 @@ export const policyGrantedPeers = Effect.fn("tailnetGrants.policyGrantedPeers")( : (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 }, From 7ce2edca0aae95618b5dcfab7daa73a8771a3171 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 21:38:59 -0700 Subject: [PATCH 041/114] fix(tailscale): the grant sign-in count is kept per Tailscale account A grant sign-in on one account no longer turns the owner rule off on another, where that account's own clean scan found no grants. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 21 +++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 31 +++++++++++++++------- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 41b6bc2af2972..9158b2050a79c 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -997,6 +997,27 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).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", () => { diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index ca3e49f83f31b..03f5377a88450 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -218,9 +218,12 @@ export const make = Effect.gen(function* () { const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; const requireGrantPath = path.join(config.stateDir, REQUIRE_GRANT_FILE); - // Sign-ins through a grant since the last scan that found none. The first stops the owner - // rule before the next scan. A scan that finds no grants clears it, unless one came meanwhile. - const grantSignIns = yield* Ref.make(0); + // Sign-ins through a grant since the last scan that found none, 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 grantSignIns = yield* Ref.make>(new Map()); + const grantSignInsOn = (account: string | null) => + Ref.get(grantSignIns).pipe(Effect.map((counts) => counts.get(account) ?? 0)); // 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 @@ -234,13 +237,13 @@ export const make = Effect.gen(function* () { const scanFrom = yield* Ref.make(null); const [scanPolicy, rescanPolicy] = yield* Effect.cachedInvalidateWithTTL( Effect.gen(function* () { - const signInsBefore = yield* Ref.get(grantSignIns); // A switch after this status is caught below. const status = (yield* Ref.get(scanFrom)) ?? (yield* host.status); const account = status.selfNodeId; - // A grant is kept the moment its lookup confirms it, from the node the list names, so if - // the scan is cut short or its checks below cannot finish, a later scan that cannot read every peer falls back to - // it, never to an older "no grants". + const signInsBefore = yield* grantSignInsOn(account); + // A grant is kept the moment its lookup confirms it, from the node the list names. So + // if the scan is cut short or its checks below cannot finish, a later scan that cannot + // read every peer falls back to it, never to an older "no grants". const keepGrant = Clock.currentTimeMillis.pipe( Effect.flatMap((at) => Ref.set(lastCleanScan, { account, granted: true, at })), ); @@ -264,7 +267,9 @@ export const make = Effect.gen(function* () { const found = granted.length > 0; yield* Ref.set(lastCleanScan, { account, granted: found, at: now }); if (!found) - yield* Ref.update(grantSignIns, (count) => (count === signInsBefore ? 0 : count)); + yield* Ref.update(grantSignIns, (counts) => + (counts.get(account) ?? 0) === signInsBefore ? new Map(counts).set(account, 0) : counts, + ); return { account, granted: found, ttl: ttlOf(found) }; } const last = yield* Ref.get(lastCleanScan); @@ -305,7 +310,7 @@ export const make = Effect.gen(function* () { Effect.map(Option.filter((scanned) => scanned.account === account)), ); if (Option.isSome(scan) && scan.value.granted) return "no_grant" as const; - if ((yield* Ref.get(grantSignIns)) > 0) return "no_grant" as const; + if ((yield* grantSignInsOn(account)) > 0) return "no_grant" as const; return Option.isNone(scan) ? ("policy_unknown" as const) : null; }); @@ -464,7 +469,13 @@ export const make = Effect.gen(function* () { }), ); // The policy names who gets in here now; owner-rule sessions end on the next check. - if (via === "grant" && (yield* Ref.getAndUpdate(grantSignIns, (count) => count + 1)) === 0) { + const firstOnAccount = + via === "grant" && + (yield* Ref.modify(grantSignIns, (counts) => { + const before = counts.get(status.selfNodeId) ?? 0; + return [before === 0, new Map(counts).set(status.selfNodeId, before + 1)] as const; + })); + if (firstOnAccount) { yield* recheckSessions.pipe(Effect.forkDetach); } return { credential: issued.credential, expiresAt: issued.expiresAt }; From 472bd475691a5fa38f3d2db3397d717179cf20d1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 21:43:38 -0700 Subject: [PATCH 042/114] fix(tailscale): a policy scan keeps a grant only while the host is on the account it read Another profile on the same tailnet sees the same peers with other grants, so the node-ID check alone cannot tell whose grant a lookup returned. The scan's first grant now costs one status read to confirm the account before it is kept; a later scan cut short still falls back to it. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 25 +++++++++++++--------- apps/server/src/auth/TailnetSignIn.ts | 17 +++++++++------ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 9158b2050a79c..d0ef9d6c1365e 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -913,8 +913,9 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, }; yield* TestClock.adjust("1 minute"); - // The scan sees the laptop's grant, then its account check cannot read Tailscale. - tailnet.failFreshReadAt = (tailnet.freshReads ?? 0) + 2; + // 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 } }; @@ -961,7 +962,17 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).pipe(Effect.provide(layerTest(tailnet))); }); - it.effect("a grant read from another account during a scan is not kept for this one", () => { + it.effect.each([ + { + name: "another device at the laptop's address", + 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. + { name: "the same laptop", atWork: { node: laptop, capMap: granted({ scopes: ["read"] }) } }, + ])("a grant read from another account ($name) during a scan is not kept here", ({ atWork }) => { const tailnet = withLaptop(); tailnet.status = hostStatus({ Peer: { ...peers, stranger: strangerPeer } }); tailnet.devices = { ...tailnet.devices, [STRANGER_ADDRESS]: { node: stranger } }; @@ -980,13 +991,7 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { Peer: peers, Self: { ...hostStatus().Self, ID: "nBoxAtWork" }, }); - tailnet.devices = { - ...tailnet.devices, - [LAPTOP_ADDRESS]: { - node: phoneNode({ StableID: "nLaptopAtWork", Addresses: [`${LAPTOP_ADDRESS}/32`] }), - capMap: granted({ scopes: ["read"] }), - }, - }; + 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, the older "no grants" decides, not that grant. diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 03f5377a88450..728807edbeb6e 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -241,12 +241,17 @@ export const make = Effect.gen(function* () { const status = (yield* Ref.get(scanFrom)) ?? (yield* host.status); const account = status.selfNodeId; const signInsBefore = yield* grantSignInsOn(account); - // A grant is kept the moment its lookup confirms it, from the node the list names. So - // if the scan is cut short or its checks below cannot finish, a later scan that cannot - // read every peer falls back to it, never to an older "no grants". - const keepGrant = Clock.currentTimeMillis.pipe( - Effect.flatMap((at) => Ref.set(lastCleanScan, { account, granted: true, at })), - ); + // 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; only the first grant reads it. + const grantKept = yield* Ref.make(false); + const keepGrant = Effect.gen(function* () { + if (yield* Ref.getAndSet(grantKept, true)) return; + const now = yield* host.freshStatus; + if (now.selfNodeId !== account) return; + const at = yield* Clock.currentTimeMillis; + yield* Ref.set(lastCleanScan, { account, granted: true, at }); + }).pipe(Effect.ignore); const { granted, unreadable } = yield* policyGrantedPeers( status, (address) => host.whois(address, { fromServer: true }), From b8b955726b02754493d8c7e26ec4eb5fb3def2c8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 21:49:08 -0700 Subject: [PATCH 043/114] fix(tailscale): confirming a grant drops an older "no grants" until it settles, and a switch it sees fails the scan - While a scan confirms a grant's account, an older "no grants" for that account is dropped, so a confirmation cut short or failed leaves nothing to fall back on and the owner rule stays off. A switch it sees means the grant was another account's, so the older result comes back. - A scan whose grant confirmation saw another account fails, even if the host is back by the scan's own account check. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 83 +++++++++++++++++++++- apps/server/src/auth/TailnetSignIn.ts | 16 ++++- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index d0ef9d6c1365e..bc3450c551f30 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -69,6 +69,14 @@ interface Tailnet { 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; @@ -130,9 +138,16 @@ const layerTailscaleHost = (tailnet: Tailnet) => tailscaleJson(withCaller(tailnet, tailnet.staleStatus ?? tailnet.status)), ), ), - freshStatus: Effect.suspend(() => { - tailnet.freshReads = (tailnet.freshReads ?? 0) + 1; - return tailnet.freshReads === tailnet.failFreshReadAt + 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))); }), @@ -1002,6 +1017,68 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).pipe(Effect.provide(layerTest(tailnet))); }); + it.effect('a scan cut short while it confirms a grant leaves no older "no grants"', () => { + 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. + 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); + 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))); + }); + it.effect("a grant sign-in on another account does not lock the owner out here", () => { const tailnet = withLaptop(); const home = tailnet.status; diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 728807edbeb6e..845c3674c3850 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -245,10 +245,20 @@ export const make = Effect.gen(function* () { // 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; only the first grant reads it. const grantKept = yield* Ref.make(false); + const switchSeen = yield* Ref.make(false); const keepGrant = Effect.gen(function* () { if (yield* Ref.getAndSet(grantKept, true)) return; + // Dropped until the read settles, so a read cut short or failed leaves no "no grants" + // to fall back on. A switch means the grant was another account's, so it comes back, + // and the scan fails below. + const before = yield* Ref.getAndUpdate(lastCleanScan, (last) => + last?.account === account && !last.granted ? null : last, + ); const now = yield* host.freshStatus; - if (now.selfNodeId !== account) return; + if (now.selfNodeId !== account) { + yield* Ref.set(lastCleanScan, before); + return yield* Ref.set(switchSeen, true); + } const at = yield* Clock.currentTimeMillis; yield* Ref.set(lastCleanScan, { account, granted: true, at }); }).pipe(Effect.ignore); @@ -261,7 +271,9 @@ export const make = Effect.gen(function* () { // 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; - if (after.selfNodeId !== account) return yield* Effect.fail(["this host"]); + if (after.selfNodeId !== account || (yield* Ref.get(switchSeen))) { + return yield* Effect.fail(["this host"]); + } if (granted.length === 0 && peersJoined(status, after)) { return yield* Effect.fail(["a device that joined during the check"]); } From 43991dad637a8ab40108a2a8afc365fb5aa0a0d3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 22:02:09 -0700 Subject: [PATCH 044/114] fix(tailscale): confirming a grant is not cut short, and a failed confirmation lets a later grant retry The confirmation's status read times out, so it runs uninterruptible: a scan stopped while it confirms still keeps the grant. If the read fails, the next grant in the scan tries again. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 14 +++++++++----- apps/server/src/auth/TailnetSignIn.ts | 7 ++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index bc3450c551f30..71a73b82ec6fc 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1017,7 +1017,7 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).pipe(Effect.provide(layerTest(tailnet))); }); - it.effect('a scan cut short while it confirms a grant leaves no older "no grants"', () => { + 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 } }; @@ -1030,16 +1030,20 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }; 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 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); - yield* Fiber.interrupt(phone); - // With the laptop unreadable, nothing proves "no grants", so the owner rule stays off. + 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("policy_unknown"); + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("no_grant"); }).pipe(Effect.provide(layerTest(tailnet))); }); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 845c3674c3850..67c2b19050820 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -261,7 +261,12 @@ export const make = Effect.gen(function* () { } const at = yield* Clock.currentTimeMillis; yield* Ref.set(lastCleanScan, { account, granted: true, at }); - }).pipe(Effect.ignore); + }).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 }), From 210387f0ea7bdb07da1b47da3f3f6dc85c506a88 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 22:05:34 -0700 Subject: [PATCH 045/114] test(server): a failed grant confirmation leaves no older "no grants" behind Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 71a73b82ec6fc..596db9405a51d 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1047,6 +1047,33 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).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 } }); From d4f88b91fcc9fcfbf276daabb166dfe09616e9a4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 22:12:09 -0700 Subject: [PATCH 046/114] fix(tailscale): a grant the session recheck sees turns the owner rule off, and a switch keeps "no grants" dropped - A grant the 30-second recheck reads counts like a grant sign-in on that account, so the owner rule is off until a full scan, even if that device becomes unreadable before the next one. - When confirming a grant sees a switch, the older "no grants" stays dropped until a full scan: the grant may have been read on this account before it. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 96 +++++++++++++++------- apps/server/src/auth/TailnetSignIn.ts | 20 +++-- 2 files changed, 77 insertions(+), 39 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 596db9405a51d..44d29cbbe4a29 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -285,6 +285,13 @@ const exchange = (credential: string, key: ReturnType) => 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( @@ -980,42 +987,52 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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. - { name: "the same laptop", atWork: { node: laptop, capMap: granted({ scopes: ["read"] }) } }, - ])("a grant read from another account ($name) during a scan is not kept here", ({ atWork }) => { - 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, the older "no grants" decides, not that grant. - tailnet.status = home; - tailnet.devices = { ...tailnet.devices, [LAPTOP_ADDRESS]: { node: null } }; - yield* TestClock.adjust("11 seconds"); - yield* signIn(makeDpopKey()); - }).pipe(Effect.provide(layerTest(tailnet))); - }); + // 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(); @@ -1110,6 +1127,23 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).pipe(Effect.provide(layerTest(tailnet))); }); + it.effect("a grant the session recheck sees turns the owner rule off", () => { + 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: ["read"] }) }, + }; + 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("a grant sign-in on another account does not lock the owner out here", () => { const tailnet = withLaptop(); const home = tailnet.status; diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 67c2b19050820..a772d35e0fd88 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -248,17 +248,14 @@ export const make = Effect.gen(function* () { const switchSeen = yield* Ref.make(false); const keepGrant = Effect.gen(function* () { if (yield* Ref.getAndSet(grantKept, true)) return; - // Dropped until the read settles, so a read cut short or failed leaves no "no grants" - // to fall back on. A switch means the grant was another account's, so it comes back, - // and the scan fails below. - const before = yield* Ref.getAndUpdate(lastCleanScan, (last) => + // 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) { - yield* Ref.set(lastCleanScan, before); - return yield* Ref.set(switchSeen, true); - } + if (now.selfNodeId !== account) return yield* Ref.set(switchSeen, true); const at = yield* Clock.currentTimeMillis; yield* Ref.set(lastCleanScan, { account, granted: true, at }); }).pipe( @@ -552,6 +549,13 @@ export const make = Effect.gen(function* () { const node = lookup.value; knownUsers.set(stableNodeId, node.loginName ?? node.userId); const decision = yield* decide(node, status); + // A grant the recheck sees turns the owner rule off here, as a grant sign-in does, until a + // full scan clears it. + if (decision._tag === "Allowed" && decision.via === "grant") { + yield* Ref.update(grantSignIns, (counts) => + new Map(counts).set(status.selfNodeId, (counts.get(status.selfNodeId) ?? 0) + 1), + ); + } 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; From 1e6abcd865f0284e61117d3b45e39ae6c54aa97d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 22:15:06 -0700 Subject: [PATCH 047/114] fix(server): the grant confirmation comment matches its retry Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index a772d35e0fd88..972bef8102642 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -243,7 +243,7 @@ export const make = Effect.gen(function* () { const signInsBefore = yield* grantSignInsOn(account); // 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; only the first grant reads it. + // 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* () { From 083efd2e7f25df9aa510adc0caa4bdb602f446e7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 22:20:29 -0700 Subject: [PATCH 048/114] fix(tailscale): the session recheck counts any grant it sees, on the account it confirms - A grant that names no usable scope still names the device in the policy, so the recheck counts it toward turning the owner rule off, as the scan does. - The recheck counts a grant only once a status read shows the host still on the account it started on; a switch means the grant may be either account's, so the older "no grants" is dropped and the next sign-in scans afresh. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 68 +++++++++++++++++----- apps/server/src/auth/TailnetSignIn.ts | 20 +++++-- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 44d29cbbe4a29..500b5481061b7 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1127,22 +1127,58 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).pipe(Effect.provide(layerTest(tailnet))); }); - it.effect("a grant the session recheck sees turns the owner rule off", () => { - 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: ["read"] }) }, - }; - 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))); - }); + // 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( + "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("a grant sign-in on another account does not lock the owner out here", () => { const tailnet = withLaptop(); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 972bef8102642..43f5e7657062f 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -549,12 +549,20 @@ export const make = Effect.gen(function* () { const node = lookup.value; knownUsers.set(stableNodeId, node.loginName ?? node.userId); const decision = yield* decide(node, status); - // A grant the recheck sees turns the owner rule off here, as a grant sign-in does, until a - // full scan clears it. - if (decision._tag === "Allowed" && decision.via === "grant") { - yield* Ref.update(grantSignIns, (counts) => - new Map(counts).set(status.selfNodeId, (counts.get(status.selfNodeId) ?? 0) + 1), - ); + // A grant the recheck sees, usable or not, turns the owner rule off here as a grant + // sign-in does, once a status read shows the host still on this account. A switch means + // the grant may be either account's, so no "no grants" stands until a full scan. + if ( + tailnetGrantScopes(node.capabilities) !== null && + (yield* grantSignInsOn(status.selfNodeId)) === 0 + ) { + const now = yield* host.freshStatus.pipe(Effect.option); + if (Option.isSome(now) && now.value.selfNodeId === status.selfNodeId) { + yield* Ref.update(grantSignIns, (counts) => new Map(counts).set(status.selfNodeId, 1)); + } else { + yield* Ref.update(lastCleanScan, (last) => (last?.granted === false ? null : last)); + yield* rescanPolicy; + } } if (decision._tag === "Refused") return { end: decision.reason, node } as const; if (yield* blocks.isBlocked({ stableNodeId })) return { end: "blocked", node } as const; From c31d6d7334f5ad9b77e38016c55f132b12e0c8cf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 22:23:21 -0700 Subject: [PATCH 049/114] fix(tailscale): a sign-in refused for an unusable grant still turns the owner rule off The policy names the device, so its grant counts toward turning the owner rule off on that account before the refusal returns, as a recheck and a scan already do. Sign-in and the recheck share one counter helper. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 21 ++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 32 ++++++++++++---------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 500b5481061b7..97d05880a2d75 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1180,6 +1180,27 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }, ); + it.effect("a sign-in refused for an unusable grant still turns the owner rule off", () => { + 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: ["raed"] }) }, + }; + expect(yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: LAPTOP_ADDRESS }))).toBe( + "grant_unusable", + ); + // 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("no_grant"); + }).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; diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 43f5e7657062f..16a77ae2f4cc2 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -218,12 +218,18 @@ export const make = Effect.gen(function* () { const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; const requireGrantPath = path.join(config.stateDir, REQUIRE_GRANT_FILE); - // Sign-ins through a grant since the last scan that found none, 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. + // 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 grantSignIns = yield* Ref.make>(new Map()); const grantSignInsOn = (account: string | null) => Ref.get(grantSignIns).pipe(Effect.map((counts) => counts.get(account) ?? 0)); + /** Counts a grant seen on `account`; true when it is the first there. */ + const countGrant = (account: string | null) => + Ref.modify(grantSignIns, (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 @@ -446,6 +452,14 @@ export const make = Effect.gen(function* () { return yield* refuse("tailscale_unavailable"); } 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); + } if (decision._tag === "Refused") { return yield* refuse( decision.reason, @@ -487,16 +501,6 @@ export const make = Effect.gen(function* () { scopes: scopes.join(" "), }), ); - // The policy names who gets in here now; owner-rule sessions end on the next check. - const firstOnAccount = - via === "grant" && - (yield* Ref.modify(grantSignIns, (counts) => { - const before = counts.get(status.selfNodeId) ?? 0; - return [before === 0, new Map(counts).set(status.selfNodeId, before + 1)] as const; - })); - if (firstOnAccount) { - yield* recheckSessions.pipe(Effect.forkDetach); - } return { credential: issued.credential, expiresAt: issued.expiresAt }; }, ); @@ -558,7 +562,7 @@ export const make = Effect.gen(function* () { ) { const now = yield* host.freshStatus.pipe(Effect.option); if (Option.isSome(now) && now.value.selfNodeId === status.selfNodeId) { - yield* Ref.update(grantSignIns, (counts) => new Map(counts).set(status.selfNodeId, 1)); + yield* countGrant(status.selfNodeId); } else { yield* Ref.update(lastCleanScan, (last) => (last?.granted === false ? null : last)); yield* rescanPolicy; From 7a7d51c6b6ce0910450f931eccc3fffe9991e4a0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 23:11:01 -0700 Subject: [PATCH 050/114] fix(tailscale): every grant the recheck confirms counts, so a scan running meanwhile cannot clear it The recheck skipped counting a grant while its account's count was already above zero, so a scan that started before it, found no grants, and ended after it reset the count to zero. Each grant the recheck sees now counts, with one status read per recheck to confirm the account. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 37 ++++++++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 25 +++++++++------ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 97d05880a2d75..2e1dc86130a0b 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1201,6 +1201,43 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).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("a grant sign-in on another account does not lock the owner out here", () => { const tailnet = withLaptop(); const home = tailnet.status; diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 16a77ae2f4cc2..6ef745c00aeec 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -535,6 +535,8 @@ export const make = Effect.gen(function* () { const recheckDevice = Effect.fn("TailnetSignIn.recheckDevice")(function* ( stableNodeId: string, status: TailscaleStatus, + /** Whether the host is still on `status`'s account; read 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); @@ -553,15 +555,11 @@ export const make = Effect.gen(function* () { const node = lookup.value; knownUsers.set(stableNodeId, node.loginName ?? node.userId); const decision = yield* decide(node, status); - // A grant the recheck sees, usable or not, turns the owner rule off here as a grant - // sign-in does, once a status read shows the host still on this account. A switch means - // the grant may be either account's, so no "no grants" stands until a full scan. - if ( - tailnetGrantScopes(node.capabilities) !== null && - (yield* grantSignInsOn(status.selfNodeId)) === 0 - ) { - const now = yield* host.freshStatus.pipe(Effect.option); - if (Option.isSome(now) && now.value.selfNodeId === status.selfNodeId) { + // Each grant the recheck sees, usable or not, counts as a grant sign-in does, so a scan + // running meanwhile cannot clear the count, once the host is shown still on this account. + // A switch means the grant may be either account's, so no "no grants" stands meanwhile. + if (tailnetGrantScopes(node.capabilities) !== null) { + if (yield* stillOnAccount) { yield* countGrant(status.selfNodeId); } else { yield* Ref.update(lastCleanScan, (last) => (last?.granted === false ? null : last)); @@ -640,10 +638,17 @@ export const make = Effect.gen(function* () { return; } 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); + 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); From e4c0d14bfb87a06a9c1da8882c163f57d691142b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 23:11:52 -0700 Subject: [PATCH 051/114] refactor(server): the grant counter is named for what it counts Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 6ef745c00aeec..1f7273b4ee6df 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -221,12 +221,12 @@ export const make = Effect.gen(function* () { // 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 grantSignIns = yield* Ref.make>(new Map()); - const grantSignInsOn = (account: string | null) => - Ref.get(grantSignIns).pipe(Effect.map((counts) => counts.get(account) ?? 0)); + const grantsSeen = yield* Ref.make>(new Map()); + const grantsSeenOn = (account: string | null) => + Ref.get(grantsSeen).pipe(Effect.map((counts) => counts.get(account) ?? 0)); /** Counts a grant seen on `account`; true when it is the first there. */ const countGrant = (account: string | null) => - Ref.modify(grantSignIns, (counts) => { + Ref.modify(grantsSeen, (counts) => { const before = counts.get(account) ?? 0; return [before === 0, new Map(counts).set(account, before + 1)] as const; }); @@ -246,7 +246,7 @@ export const make = 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 signInsBefore = yield* grantSignInsOn(account); + const seenBefore = yield* grantsSeenOn(account); // 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. @@ -292,8 +292,8 @@ export const make = Effect.gen(function* () { const found = granted.length > 0; yield* Ref.set(lastCleanScan, { account, granted: found, at: now }); if (!found) - yield* Ref.update(grantSignIns, (counts) => - (counts.get(account) ?? 0) === signInsBefore ? new Map(counts).set(account, 0) : counts, + yield* Ref.update(grantsSeen, (counts) => + (counts.get(account) ?? 0) === seenBefore ? new Map(counts).set(account, 0) : counts, ); return { account, granted: found, ttl: ttlOf(found) }; } @@ -335,7 +335,7 @@ export const make = Effect.gen(function* () { Effect.map(Option.filter((scanned) => scanned.account === account)), ); if (Option.isSome(scan) && scan.value.granted) return "no_grant" as const; - if ((yield* grantSignInsOn(account)) > 0) return "no_grant" as const; + if ((yield* grantsSeenOn(account)) > 0) return "no_grant" as const; return Option.isNone(scan) ? ("policy_unknown" as const) : null; }); @@ -579,7 +579,7 @@ export const make = Effect.gen(function* () { held.length === allowed.size && held.every((scope) => allowed.has(scope)); let statusFailingSince: number | null = null; - // The periodic recheck and the one a first grant sign-in starts share the state above. + // The periodic recheck and the one a sign-in's first grant starts share the state above. const recheckLock = yield* Semaphore.make(1); const recheckSessions: TailnetSignIn["Service"]["recheckSessions"] = Effect.gen(function* () { From b8e3547ed98e492fdc87a619aca7a7a9634b1f86 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 23:25:02 -0700 Subject: [PATCH 052/114] fix(tailscale): a grant the recheck cannot tie to an account keeps a running scan from ending on "no grants" Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 59 ++++++++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 12 ++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 2e1dc86130a0b..69a31eabab51b 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1238,6 +1238,65 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }, ); + it.effect( + "a grant the recheck cannot tie to an account keeps a running scan from deciding", + () => { + const DESK_ADDRESS = "100.88.1.40"; + const TABLET_ADDRESS = "100.88.1.9"; + 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 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 whose account check after the desk's grant cannot read Tailscale. + const recheckInDoubt = Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + tailnet.failFreshReadAt = (tailnet.freshReads ?? 0) + 2; + yield* service.recheckSessions; + }); + 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 grant sign-in on another account does not lock the owner out here", () => { const tailnet = withLaptop(); const home = tailnet.status; diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 1f7273b4ee6df..6e074738ca32e 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -224,6 +224,9 @@ export const make = Effect.gen(function* () { 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 grant is seen but its account cannot be confirmed: a scan already running + // then may not end on "no grants", as that grant may be this account's. + const grantsInDoubt = 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) => { @@ -247,6 +250,7 @@ export const make = Effect.gen(function* () { 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. @@ -282,8 +286,11 @@ export const make = Effect.gen(function* () { if (after.selfNodeId !== account || (yield* Ref.get(switchSeen))) { return yield* Effect.fail(["this host"]); } - if (granted.length === 0 && peersJoined(status, after)) { - return yield* Effect.fail(["a device that joined during the check"]); + 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. @@ -562,6 +569,7 @@ export const make = Effect.gen(function* () { if (yield* stillOnAccount) { yield* countGrant(status.selfNodeId); } else { + yield* Ref.update(grantsInDoubt, (count) => count + 1); yield* Ref.update(lastCleanScan, (last) => (last?.granted === false ? null : last)); yield* rescanPolicy; } From fc108dead74af3b7f57939f59e8873d23a6c627c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 23:33:50 -0700 Subject: [PATCH 053/114] fix(tailscale): no owner sign-in gets in while the recheck confirms a grant's account Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 21 +++++++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 19 ++++++++----------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 69a31eabab51b..23f9525103a6e 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1238,6 +1238,27 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }, ); + 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", () => { diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 6e074738ca32e..6d7c1f0047777 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -224,7 +224,7 @@ export const make = Effect.gen(function* () { 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 grant is seen but its account cannot be confirmed: a scan already running + // Bumped when the recheck sees a grant, before it knows the account: a scan already running // then may not end on "no grants", as that grant may be this account's. const grantsInDoubt = yield* Ref.make(0); /** Counts a grant seen on `account`; true when it is the first there. */ @@ -562,17 +562,14 @@ export const make = Effect.gen(function* () { const node = lookup.value; knownUsers.set(stableNodeId, node.loginName ?? node.userId); const decision = yield* decide(node, status); - // Each grant the recheck sees, usable or not, counts as a grant sign-in does, so a scan - // running meanwhile cannot clear the count, once the host is shown still on this account. - // A switch means the grant may be either account's, so no "no grants" stands meanwhile. + // A grant, usable or not, may be this account's, so no "no grants" stands from here, kept + // or from a scan running meanwhile. Once the host is shown still on this account, it + // counts as a grant sign-in does; after a switch it may be either account's. if (tailnetGrantScopes(node.capabilities) !== null) { - if (yield* stillOnAccount) { - yield* countGrant(status.selfNodeId); - } else { - yield* Ref.update(grantsInDoubt, (count) => count + 1); - yield* Ref.update(lastCleanScan, (last) => (last?.granted === false ? null : last)); - yield* rescanPolicy; - } + yield* Ref.update(grantsInDoubt, (count) => count + 1); + yield* Ref.update(lastCleanScan, (last) => (last?.granted === false ? null : last)); + yield* rescanPolicy; + 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; From c2c131535a7ebfb32edb6383fd9e8fb75d66c3c1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 23:35:41 -0700 Subject: [PATCH 054/114] refactor(server): the recheck's account comments cover a failed status read Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 6d7c1f0047777..66a4b31bc73a5 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -542,7 +542,7 @@ export const make = Effect.gen(function* () { const recheckDevice = Effect.fn("TailnetSignIn.recheckDevice")(function* ( stableNodeId: string, status: TailscaleStatus, - /** Whether the host is still on `status`'s account; read once per recheck. */ + /** 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. @@ -564,7 +564,7 @@ export const make = Effect.gen(function* () { const decision = yield* decide(node, status); // A grant, usable or not, may be this account's, so no "no grants" stands from here, kept // or from a scan running meanwhile. Once the host is shown still on this account, it - // counts as a grant sign-in does; after a switch it may be either account's. + // counts as a grant sign-in does; unconfirmed, it may be another account's. if (tailnetGrantScopes(node.capabilities) !== null) { yield* Ref.update(grantsInDoubt, (count) => count + 1); yield* Ref.update(lastCleanScan, (last) => (last?.granted === false ? null : last)); From 0f7716ab14392c2b3f44ec54811367c9ed791bf8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 23:44:22 -0700 Subject: [PATCH 055/114] fix(tailscale): a grant a refused sign-in saw, or a code exchanged during the recheck, no longer slips through A sign-in that sees a grant drops any "no grants" before its status read, so a failed read cannot leave the older result standing. The recheck revokes unused codes first and then reads sessions again, so a code exchanged during the pass loses its old scopes too. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 100 ++++++++++++++++----- apps/server/src/auth/TailnetSignIn.ts | 52 ++++++----- 2 files changed, 109 insertions(+), 43 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 23f9525103a6e..9f4844de07551 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -631,11 +631,12 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { yield* signIn(key); tailnet.node = phoneNode({ Tags: ["tag:agents"] }); expect(yield* refusalOf(signIn(key))).toBe("tagged_device"); - // A grant lets the tagged device in, and after that grants decide here. + // 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("no_grant"); + expect(yield* refusalOf(signIn(key))).toBe("tagged_device"); tailnet.node = null; expect(yield* refusalOf(signIn(key))).toBe("tailscale_unavailable"); }).pipe(Effect.provide(layerTest(tailnet))); @@ -795,6 +796,36 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).pipe(Effect.provide(layerTest(tailnet))); }); + it.effect("a sign-in code exchanged while the recheck narrows its grant ends too", () => { + 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 }); + // The grant narrows, and the code is exchanged after the recheck lists it. + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + 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* () { @@ -1180,26 +1211,51 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }, ); - it.effect("a sign-in refused for an unusable grant still turns the owner rule off", () => { - 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: ["raed"] }) }, - }; - expect(yield* refusalOf(signIn(makeDpopKey(), { remoteAddress: LAPTOP_ADDRESS }))).toBe( - "grant_unusable", - ); - // 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("no_grant"); - }).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", diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 66a4b31bc73a5..9f0c9ab8dd490 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -224,8 +224,8 @@ export const make = Effect.gen(function* () { 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 the recheck sees a grant, before it knows the account: a scan already running - // then may not end on "no grants", as that grant may be this account's. + // Bumped when a sign-in or the recheck sees a grant, before it knows the account: a scan + // already running then may not end on "no grants", as that grant may be this account's. const grantsInDoubt = yield* Ref.make(0); /** Counts a grant seen on `account`; true when it is the first there. */ const countGrant = (account: string | null) => @@ -319,6 +319,13 @@ export const make = Effect.gen(function* () { }), (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. + const doubtNoGrants = Effect.gen(function* () { + yield* Ref.update(grantsInDoubt, (count) => count + 1); + yield* Ref.update(lastCleanScan, (last) => (last?.granted === false ? null : last)); + yield* rescanPolicy; + }); /** * 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. @@ -438,6 +445,8 @@ export const make = Effect.gen(function* () { 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. @@ -562,13 +571,10 @@ export const make = Effect.gen(function* () { const node = lookup.value; knownUsers.set(stableNodeId, node.loginName ?? node.userId); const decision = yield* decide(node, status); - // A grant, usable or not, may be this account's, so no "no grants" stands from here, kept - // or from a scan running meanwhile. Once the host is shown still on this account, it - // counts as a grant sign-in does; unconfirmed, it may be another account's. + // 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* Ref.update(grantsInDoubt, (count) => count + 1); - yield* Ref.update(lastCleanScan, (last) => (last?.granted === false ? null : last)); - yield* rescanPolicy; + yield* doubtNoGrants; if (yield* stillOnAccount) yield* countGrant(status.selfNodeId); } if (decision._tag === "Refused") return { end: decision.reason, node } as const; @@ -593,7 +599,10 @@ export const make = Effect.gen(function* () { const devices = new Map< string, { - sessions: Array<{ id: AuthSessionId; scopes: ReadonlyArray }>; + sessions: Array<{ + readonly sessionId: AuthSessionId; + readonly scopes: ReadonlyArray; + }>; links: Array>; /** The sign-in label, "name" or "name (login)", for logs once Tailscale forgets the device. */ label: string | null; @@ -608,10 +617,7 @@ export const make = Effect.gen(function* () { }; for (const session of yield* sessions.listActive()) { if (session.subject.startsWith(TAILNET_SUBJECT_PREFIX)) { - deviceOf(session.subject, session.client.label ?? null).sessions.push({ - id: session.sessionId, - scopes: session.scopes, - }); + deviceOf(session.subject, session.client.label ?? null).sessions.push(session); } } for (const link of yield* pairingLinks.listActive({ now })) { @@ -657,16 +663,20 @@ export const make = Effect.gen(function* () { 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 stale = held.sessions.filter( - (session) => !sameScopes(session.scopes, result.allowed), - ); - yield* Effect.forEach(stale, (session) => sessions.revoke(session.id), { discard: true }); + const subject = tailnetSubject(stableNodeId); const links = held.links.some((scopes) => !sameScopes(scopes, result.allowed)) - ? yield* pairingLinks.revokeUnusedBySubject({ - subject: tailnetSubject(stableNodeId), - revokedAt: now, - }) + ? yield* pairingLinks.revokeUnusedBySubject({ subject, revokedAt: now }) : 0; + // A code exchanged since the list above made its session in the same transaction, so + // with codes listed, sessions are read again once unused codes are revoked. + const current = + held.links.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({ From 9b6cc9e3637cad0ae146a9d3dd2930339a59f9d3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 23:49:53 -0700 Subject: [PATCH 056/114] fix(tailscale): the recheck lists sign-in codes before sessions, so no exchange falls between them Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 90 +++++++++++++++------- apps/server/src/auth/TailnetSignIn.ts | 10 ++- 2 files changed, 67 insertions(+), 33 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 9f4844de07551..b0b87e45677b6 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -18,6 +18,7 @@ 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 SqlitePersistence from "../persistence/Sqlite.ts"; import * as TailscaleHost from "../tailscale/TailscaleHost.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -84,6 +85,11 @@ interface Tailnet { loginName?: string; /** Other devices whois answers for, by address. */ devices?: Record; + /** 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. @@ -185,11 +191,33 @@ const layerTailscaleHost = (tailnet: Tailnet) => }), ); +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 layerTest = ( tailnet: Tailnet, overrides: Partial = {}, ) => TailnetSignIn.layer.pipe( + Layer.provide(layerCodeList(tailnet)), Layer.provideMerge(EnvironmentAuth.layer), Layer.provideMerge(SqlitePersistence.layerMemory), Layer.provideMerge(ServerSecretStore.layer), @@ -796,35 +824,39 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).pipe(Effect.provide(layerTest(tailnet))); }); - it.effect("a sign-in code exchanged while the recheck narrows its grant ends too", () => { - 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 }); - // The grant narrows, and the code is exchanged after the recheck lists it. - tailnet.devices = { - [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, - }; - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - 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.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(); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 9f0c9ab8dd490..623ccb5ae9788 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -615,15 +615,17 @@ export const make = Effect.gen(function* () { 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.scopes); + } for (const session of yield* sessions.listActive()) { if (session.subject.startsWith(TAILNET_SUBJECT_PREFIX)) { deviceOf(session.subject, session.client.label ?? null).sessions.push(session); } } - for (const link of yield* pairingLinks.listActive({ now })) { - if (link.subject.startsWith(TAILNET_SUBJECT_PREFIX)) - deviceOf(link.subject, link.label).links.push(link.scopes); - } const nameOf = (stableNodeId: string, node?: TailscaleWhois): DeviceName => ({ device: node?.computedName ?? devices.get(stableNodeId)?.label ?? stableNodeId, user: node?.loginName ?? node?.userId ?? knownUsers.get(stableNodeId) ?? null, From d39afd6b22daab5f142866dadfded3aac74855a7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 23:51:38 -0700 Subject: [PATCH 057/114] fix(tailscale): a sign-in dropped partway still drops the older "no grants" Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 623ccb5ae9788..0839e0c705325 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -320,12 +320,12 @@ export const make = Effect.gen(function* () { (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. + // 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. From d86399bac8ea6db1b344eb3f492b0d50db593ed5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:00:03 -0700 Subject: [PATCH 058/114] fix(tailscale): a recheck that first sees a grant runs again, and keeps current sign-in codes A pass that sees a grant while none was counted on its account may already have let owner-rule devices stay, so it runs once more under the same lock. A grant change now revokes only the codes from the older grant, so a device's current code stays usable. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 40 ++++++++++++++++++++-- apps/server/src/auth/TailnetSignIn.ts | 38 ++++++++++++++------ 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index b0b87e45677b6..0a1d5e45849ec 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -774,13 +774,20 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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 }); @@ -1211,6 +1218,29 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }, ); + it.effect("an owner-rule sign-in during the recheck that first sees a grant ends in it", () => { + const tailnet = withLaptop(); + return Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + // The laptop signs in by the owner rule, then gets a grant. + yield* signInLaptop(makeDpopKey()); + tailnet.devices = { + [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + }; + // While the recheck looks the laptop up, the phone signs in by the owner rule. + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + 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); + 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", () => { @@ -1378,11 +1408,17 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { [TABLET_ADDRESS]: { node: ownDevice("nTablet", TABLET_ADDRESS) }, }; }; - // A recheck whose account check after the desk's grant cannot read Tailscale. + // 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; - tailnet.failFreshReadAt = (tailnet.freshReads ?? 0) + 2; + 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); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 0839e0c705325..c22b7c23bd64d 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -593,7 +593,8 @@ export const make = Effect.gen(function* () { // The periodic recheck and the one a sign-in's first grant starts share the state above. const recheckLock = yield* Semaphore.make(1); - const recheckSessions: TailnetSignIn["Service"]["recheckSessions"] = Effect.gen(function* () { + /** One recheck pass; true when it saw a grant while none was counted on its account. */ + const recheckPass = Effect.gen(function* () { // Unused sign-in credentials count too: exchanging one would bring the device back. const now = yield* DateTime.now; const devices = new Map< @@ -603,7 +604,10 @@ export const make = Effect.gen(function* () { readonly sessionId: AuthSessionId; readonly scopes: ReadonlyArray; }>; - links: Array>; + links: Array<{ + readonly id: string; + readonly scopes: ReadonlyArray; + }>; /** The sign-in label, "name" or "name (login)", for logs once Tailscale forgets the device. */ label: string | null; } @@ -619,7 +623,7 @@ export const make = Effect.gen(function* () { // 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.scopes); + deviceOf(link.subject, link.label).links.push(link); } for (const session of yield* sessions.listActive()) { if (session.subject.startsWith(TAILNET_SUBJECT_PREFIX)) { @@ -634,7 +638,7 @@ export const make = Effect.gen(function* () { for (const id of knownUsers.keys()) if (!devices.has(id)) knownUsers.delete(id); if (devices.size === 0) { statusFailingSince = null; - return; + return false; } // A daemon that is starting or logged out lists no peers, which proves nothing for a // while. Past the grace period nothing vouches for these devices, so their access ends. @@ -642,16 +646,18 @@ export const make = Effect.gen(function* () { if (status._tag === "None" || status.value.backendState !== "Running") { const nowMs = DateTime.toEpochMillis(now); statusFailingSince ??= nowMs; - if (nowMs - statusFailingSince < Duration.toMillis(LOOKUP_GRACE)) return; + if (nowMs - statusFailingSince < Duration.toMillis(LOOKUP_GRACE)) return false; yield* Effect.forEach( devices.keys(), (id) => endAccess(id, "tailscale_unavailable", nameOf(id)), { discard: true }, ); - return; + return false; } statusFailingSince = null; const account = status.value.selfNodeId; + const ownerRuleWasOn = (yield* grantsSeenOn(account)) === 0; + const doubtBefore = yield* Ref.get(grantsInDoubt); const stillOnAccount = yield* Effect.cached( host.freshStatus.pipe( Effect.map((now) => now.selfNodeId === account), @@ -666,13 +672,16 @@ export const make = Effect.gen(function* () { 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); - const links = held.links.some((scopes) => !sameScopes(scopes, result.allowed)) - ? yield* pairingLinks.revokeUnusedBySubject({ subject, revokedAt: now }) - : 0; + // 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 codes listed, sessions are read again once unused codes are revoked. + // with stale codes listed, sessions are read again once those are revoked. const current = - held.links.length === 0 + staleLinks.length === 0 ? held.sessions : (yield* sessions.listActive()).filter((session) => session.subject === subject); const stale = current.filter((session) => !sameScopes(session.scopes, result.allowed)); @@ -693,6 +702,13 @@ export const make = Effect.gen(function* () { }), { concurrency: 8, discard: true }, ); + return ownerRuleWasOn && (yield* Ref.get(grantsInDoubt)) !== doubtBefore; + }); + + const recheckSessions: TailnetSignIn["Service"]["recheckSessions"] = Effect.gen(function* () { + // A pass that first sees a grant may already have let owner-rule devices stay, so it + // runs once more. + if (yield* recheckPass) yield* recheckPass; }).pipe( Effect.catchCause((cause) => Effect.logWarning("Could not recheck Tailscale sessions.", { cause }), From 62e3f7dd019be710cf0a8817a4715c6cd28a6206 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:01:03 -0700 Subject: [PATCH 059/114] refactor(server): the recheck's label comment says a code's label comes first Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index c22b7c23bd64d..81f7f7eb776b6 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -608,7 +608,7 @@ export const make = Effect.gen(function* () { readonly id: string; readonly scopes: ReadonlyArray; }>; - /** The sign-in label, "name" or "name (login)", for logs once Tailscale forgets the device. */ + /** The sign-in label, a code's before a session's, for logs once Tailscale forgets it. */ label: string | null; } >(); From c4bb226aca2b812b97645bb23d5e0bb1a81bb202 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:08:48 -0700 Subject: [PATCH 060/114] fix(tailscale): a grant a policy scan finds also makes the recheck run again Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 74 +++++++++++++--------- apps/server/src/auth/TailnetSignIn.ts | 5 +- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 0a1d5e45849ec..bffd98e224742 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -705,6 +705,15 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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)), @@ -1218,28 +1227,43 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }, ); - it.effect("an owner-rule sign-in during the recheck that first sees a grant ends in it", () => { - const tailnet = withLaptop(); - return Effect.gen(function* () { - const service = yield* TailnetSignIn.TailnetSignIn; - // The laptop signs in by the owner rule, then gets a grant. - yield* signInLaptop(makeDpopKey()); - tailnet.devices = { - [LAPTOP_ADDRESS]: { node: laptop, capMap: granted({ scopes: ["read"] }) }, + it.effect.each([{ via: "its own lookup" }, { via: "a policy scan" }])( + "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) } }); + const desk = ownDevice("nDesk", DESK_ADDRESS); + tailnet.devices = { ...tailnet.devices, [DESK_ADDRESS]: { node: desk } }; + const grant = (address: string, node: JsonObject) => { + tailnet.devices = { + ...tailnet.devices, + [address]: { node, capMap: granted({ scopes: ["read"] }) }, + }; }; - // While the recheck looks the laptop up, the phone signs in by the owner rule. - const started = yield* Deferred.make(); - const release = yield* Deferred.make(); - 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); - yield* Deferred.succeed(release, undefined); - yield* Fiber.join(recheck); - expect(yield* revoked(phone.access_token)).toBe(true); - }).pipe(Effect.provide(layerTest(tailnet))); - }); + return Effect.gen(function* () { + const service = yield* TailnetSignIn.TailnetSignIn; + yield* signInLaptop(makeDpopKey()); + if (via === "its own lookup") grant(LAPTOP_ADDRESS, laptop); + // While the recheck looks the laptop up, the phone signs in by the owner rule. + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + 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 === "a policy scan") { + // The idle desk gets a grant, and the kept "no grants" expires, so the laptop's + // decision scans afresh. + grant(DESK_ADDRESS, desk); + yield* TestClock.adjust("61 seconds"); + } + 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", @@ -1380,15 +1404,7 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { it.effect( "a grant the recheck cannot tie to an account keeps a running scan from deciding", () => { - const DESK_ADDRESS = "100.88.1.40"; const TABLET_ADDRESS = "100.88.1.9"; - 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 tailnet = withLaptop(); tailnet.status = hostStatus({ Peer: { diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 81f7f7eb776b6..32b998c996fa1 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -224,8 +224,8 @@ export const make = Effect.gen(function* () { 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 or the recheck sees a grant, before it knows the account: a scan - // already running then may not end on "no grants", as that grant may be this account's. + // 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); /** Counts a grant seen on `account`; true when it is the first there. */ const countGrant = (account: string | null) => @@ -258,6 +258,7 @@ export const make = Effect.gen(function* () { 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. From 4c45d21f1ee293e1b3e5094c1f78a684bc39d45d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:19:55 -0700 Subject: [PATCH 061/114] fix(tailscale): blocks publish committed revocations, rechecks count grants seen while listing, refusal grants name only this device Blocking a device commits the block, then revokes its sessions in their own write, so an open socket that wakes on the revocation reads it committed. A recheck pass reads its grant counters before it lists credentials. A refused device's suggested grant names it and this machine by address, as the "Grant this device" step says, instead of a login or tag that covers other devices or machines. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 50 ++++++++++++++++------ apps/server/src/auth/TailnetSignIn.ts | 23 ++++------ 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index bffd98e224742..f262081e8066c 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -615,18 +615,23 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).pipe(Effect.provide(layerTest({ status: hostStatus(), node: phoneNode() }))), ); - it.effect("a shared-in device's suggested grant names its address, not its login", () => + 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 by address only", ({ node }) => Effect.gen(function* () { yield* requireGrantOn; const error = yield* Effect.flip(signIn(makeDpopKey())); - expect(isRefusal(error) ? error.suggestedGrant : error).toContain( - `"src": ["${PHONE_ADDRESS}"]`, - ); + const grant = isRefusal(error) ? error.suggestedGrant : undefined; + expect(grant).toContain(`"src": ["${PHONE_ADDRESS}"]`); + expect(grant).toContain(`"dst": ["${HOST_ADDRESS}"]`); }).pipe( Effect.provide( layerTest({ - status: hostStatus(), - node: phoneNode({ Sharer: "int64:555" }), + status: hostStatus({ + Self: { ...hostStatus().Self, Tags: ["tag:t3-box"] }, + }), + node, loginName: "friend@other.example", }), ), @@ -1227,13 +1232,23 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }, ); - it.effect.each([{ via: "its own lookup" }, { via: "a policy scan" }])( + 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) } }); + 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 } }; + tailnet.devices = { + ...tailnet.devices, + [DESK_ADDRESS]: { node: desk }, + [STRANGER_ADDRESS]: { node: stranger }, + }; const grant = (address: string, node: JsonObject) => { tailnet.devices = { ...tailnet.devices, @@ -1244,20 +1259,27 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { const service = yield* TailnetSignIn.TailnetSignIn; yield* signInLaptop(makeDpopKey()); if (via === "its own lookup") grant(LAPTOP_ADDRESS, laptop); - // While the recheck looks the laptop up, the phone signs in by the owner rule. + // 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(); - tailnet.hold = { address: LAPTOP_ADDRESS, started, release }; + 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 === "a policy scan") { - // The idle desk gets a grant, and the kept "no grants" expires, so the laptop's - // decision scans afresh. + 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); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 32b998c996fa1..3d28f2f1532bd 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -30,7 +30,6 @@ 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 SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts"; @@ -212,7 +211,6 @@ export const make = Effect.gen(function* () { const blocks = yield* AuthTailnetDeviceBlocks.AuthTailnetDeviceBlockRepository; const secretStore = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; - const sql = yield* SqlClient.SqlClient; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; @@ -482,14 +480,8 @@ export const make = Effect.gen(function* () { decision.reason, undefined, GRANT_FIXES.has(decision.reason) - ? tailnetGrantSnippet( - // A login from another tailnet is not one this policy can name. - node.tags[0] ?? - (node.sharerUserId === null ? node.loginName : null) ?? - remoteAddress, - status.selfTags[0] ?? localAddress, - localPort, - ) + ? // This device on this machine only: a login or tag would cover others too. + tailnetGrantSnippet(remoteAddress, localAddress, localPort) : undefined, ); } @@ -596,6 +588,9 @@ export const make = Effect.gen(function* () { /** One recheck pass; true when it saw a grant while none was counted on its account. */ const recheckPass = Effect.gen(function* () { + // Read before the credentials are listed, so a grant seen while they are counts. + const countsBefore = yield* Ref.get(grantsSeen); + const doubtBefore = yield* Ref.get(grantsInDoubt); // Unused sign-in credentials count too: exchanging one would bring the device back. const now = yield* DateTime.now; const devices = new Map< @@ -657,8 +652,7 @@ export const make = Effect.gen(function* () { } statusFailingSince = null; const account = status.value.selfNodeId; - const ownerRuleWasOn = (yield* grantsSeenOn(account)) === 0; - const doubtBefore = yield* Ref.get(grantsInDoubt); + const ownerRuleWasOn = (countsBefore.get(account) ?? 0) === 0; const stillOnAccount = yield* Effect.cached( host.freshStatus.pipe( Effect.map((now) => now.selfNodeId === account), @@ -768,12 +762,13 @@ export const make = Effect.gen(function* () { (session) => session.subject === subject, )?.client.label ?? null; const blockedAt = yield* DateTime.now; - // Shares the writer with the grant exchange, so no session for this device survives. + // 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. return yield* blocks .block({ stableNodeId, label, blockedAt }) .pipe( Effect.andThen(sessions.revokeBySubject(subject)), - sql.withTransaction, Effect.mapError(accessError("Could not block the device.")), ); }); From 4d7e96f3d8c9591b4af8ca707778824ef4cd8798 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:25:48 -0700 Subject: [PATCH 062/114] fix(tailscale): a revocation by device always notifies open sockets, and the listener drops an address Tailscale gave up Revoking a device's sessions runs uninterruptible, so its open sockets always hear of the write; stopped between the two, a retry found nothing to revoke. The Tailscale-only listener reads its interface addresses again once they are a few seconds old, before accepting, so an address another VPN took stops matching. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/SessionStore.ts | 19 ++++++++++++------- apps/server/src/startupAccess.test.ts | 7 ++++++- apps/server/src/startupAccess.ts | 13 +++++++------ 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 1e29b568b5a96..825fcd4f4de6e 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -1065,13 +1065,18 @@ const make = Effect.gen(function* () { 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); - }); + )( + 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, diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 158f2664519e3..aa1c36922a651 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -133,7 +133,7 @@ describe("makeTailnetConnectionFilter", () => { expect(accepts({ localAddress, remoteAddress })).toBe(expected); }); - it("picks up Tailscale coming up after the server started", () => { + it("follows Tailscale's addresses as it comes up and goes down", () => { let now = 0; const interfaces: Record> = { en0: [ipv4("192.168.1.20")], @@ -147,6 +147,11 @@ describe("makeTailnetConnectionFilter", () => { 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); }); }); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 95541d06ee444..8586e49b53f8d 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -137,8 +137,9 @@ const TAILSCALE_ADDRESS_REFRESH_MS = 5_000; /** * {@link acceptsTailnetConnection} against this machine's Tailscale interface - * addresses. A refused connection reads them again at most every few seconds, - * so Tailscale coming up after the server starts is picked up. + * 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; @@ -148,10 +149,10 @@ export const makeTailnetConnectionFilter = (options: { let addresses = tailscaleInterfaceIpv4Addresses(readInterfaces()); let readAt = options.now(); return (socket: Parameters[0]): boolean => { - if (acceptsTailnetConnection(socket, addresses)) return true; - if (options.now() - readAt < TAILSCALE_ADDRESS_REFRESH_MS) return false; - addresses = tailscaleInterfaceIpv4Addresses(readInterfaces()); - readAt = options.now(); + if (options.now() - readAt >= TAILSCALE_ADDRESS_REFRESH_MS) { + addresses = tailscaleInterfaceIpv4Addresses(readInterfaces()); + readAt = options.now(); + } return acceptsTailnetConnection(socket, addresses); }; }; From c85a4c50a77e5ad5c7d5e5f07399b8fd909be7aa Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:30:55 -0700 Subject: [PATCH 063/114] fix(tailscale): the recheck runs again when it saw a grant and an owner-rule admission, and refusal grants name their device A recheck pass runs once more when, while it ran, a grant was seen and the owner rule let a device in, rather than only when its account had no grant counted at its start; a count a scan cleared mid-pass no longer hides that case. A refused device's suggested grant carries a comment naming the device and this machine, as addresses do not say whose they are. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/EnvironmentAuth.ts | 4 +-- apps/server/src/auth/TailnetSignIn.test.ts | 4 ++- apps/server/src/auth/TailnetSignIn.ts | 39 +++++++++++++--------- docs/user/remote-access.md | 2 +- packages/contracts/src/tailnet.ts | 11 ++++-- 5 files changed, 39 insertions(+), 21 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 1bb8ddbc5b0d2..8f67f3f964a7b 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -949,8 +949,8 @@ export const make = Effect.gen(function* () { }; // Consuming the grant (which checks the Tailscale block list) and creating the - // session share one write transaction. Blocking a device takes the same writer, - // so a block either lands before the grant is spent or revokes the new session. + // 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, { diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index f262081e8066c..3bc721a327dfa 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -618,13 +618,15 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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 by address only", ({ node }) => + ])("$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({ diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 3d28f2f1532bd..71457cf33241d 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -225,6 +225,8 @@ export const make = Effect.gen(function* () { // 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, by sign-in or recheck. + 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) => { @@ -370,13 +372,13 @@ export const make = Effect.gen(function* () { const ownerUserId = TailscaleHost.tailnetOwnerUserId(status, config.tailscaleOwnerUserId); if (ownerUserId === null) return refused("owner_unknown"); const refusal = refuseNode(node, ownerUserId); - return refusal === null - ? ({ - _tag: "Allowed", - scopes: AuthStandardClientScopes, - via: "owner", - } satisfies AccessDecision) - : refused(refusal); + if (refusal !== null) return refused(refusal); + yield* Ref.update(ownerRuleAdmissions, (count) => count + 1); + return { + _tag: "Allowed", + scopes: AuthStandardClientScopes, + via: "owner", + } satisfies AccessDecision; }); const refuse = (reason: TailnetSignInRefusedReason, cause?: unknown, suggestedGrant?: string) => @@ -481,7 +483,12 @@ export const make = Effect.gen(function* () { undefined, GRANT_FIXES.has(decision.reason) ? // This device on this machine only: a login or tag would cover others too. - tailnetGrantSnippet(remoteAddress, localAddress, localPort) + tailnetGrantSnippet( + remoteAddress, + localAddress, + localPort, + `${label(node, status) ?? remoteAddress} on ${status.magicDnsName?.split(".")[0] ?? localAddress}`, + ) : undefined, ); } @@ -586,11 +593,11 @@ export const make = Effect.gen(function* () { // 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 it saw a grant while none was counted on its account. */ + /** 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 a grant seen while they are counts. - const countsBefore = yield* Ref.get(grantsSeen); + // 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< @@ -652,7 +659,6 @@ export const make = Effect.gen(function* () { } statusFailingSince = null; const account = status.value.selfNodeId; - const ownerRuleWasOn = (countsBefore.get(account) ?? 0) === 0; const stillOnAccount = yield* Effect.cached( host.freshStatus.pipe( Effect.map((now) => now.selfNodeId === account), @@ -697,12 +703,15 @@ export const make = Effect.gen(function* () { }), { concurrency: 8, discard: true }, ); - return ownerRuleWasOn && (yield* Ref.get(grantsInDoubt)) !== doubtBefore; + return ( + (yield* Ref.get(grantsInDoubt)) !== doubtBefore && + (yield* Ref.get(ownerRuleAdmissions)) !== admissionsBefore + ); }); const recheckSessions: TailnetSignIn["Service"]["recheckSessions"] = Effect.gen(function* () { - // A pass that first sees a grant may already have let owner-rule devices stay, so it - // runs once more. + // 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) => diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index de99acc47e3fe..489fe6b731a0e 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -211,7 +211,7 @@ 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. +device shows the grant to add, for that device and this machine only. A grant goes in the `grants` section of your tailnet policy. This one lets your devices sign in to machines tagged `tag:t3-box`: diff --git a/packages/contracts/src/tailnet.ts b/packages/contracts/src/tailnet.ts index c808c409ab099..6d8681ba3d4f5 100644 --- a/packages/contracts/src/tailnet.ts +++ b/packages/contracts/src/tailnet.ts @@ -68,11 +68,18 @@ export const tailnetScopeDenial = (scope: string): string => { /** * 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. + * 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): string => +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}"],`, From 1908d99d5828f743ac47c116001dab5c0cea6c97 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:36:11 -0700 Subject: [PATCH 064/114] fix(tailscale): a blocked device hears it is blocked, and single-session revocation always notifies A blocked device is refused as blocked before any other refusal, so it is not told to add a grant that would not let it in. Revoking one session runs uninterruptible, as revoking a device's sessions does. The docs say an address-based grant goes when either device does. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/SessionStore.ts | 2 ++ apps/server/src/auth/TailnetSignIn.test.ts | 13 +++++++++---- apps/server/src/auth/TailnetSignIn.ts | 10 +++++----- docs/user/remote-access.md | 4 +++- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 825fcd4f4de6e..ce25a7f430031 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -1020,6 +1020,8 @@ const make = Effect.gen(function* () { } return revoked; }, + // Stopped between the write and the notice, the session's open sockets would keep it. + Effect.uninterruptible, ); // Untraced: it runs inside the revokeAllExcept and revokeBySubject spans. diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 3bc721a327dfa..1043a98bf123c 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1611,8 +1611,9 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }); }); - it.effect("blocking ends live sessions and refuses a grant minted before the block", () => - Effect.gen(function* () { + 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(); @@ -1627,6 +1628,10 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { "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 }, ]); @@ -1637,6 +1642,6 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { expect(yield* service.listDevices("bearer:laptop")).toMatchObject([ { stableNodeId: "nPhone", blockedAt: null, activeSessionCount: 1, current: false }, ]); - }).pipe(Effect.provide(layerTest({ status: hostStatus(), node: phoneNode() }))), - ); + }).pipe(Effect.provide(layerTest(tailnet))); + }); }); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 71457cf33241d..4823320259b0a 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -477,6 +477,11 @@ export const make = Effect.gen(function* () { ) { yield* recheckSessions.pipe(Effect.forkDetach); } + // Checked before any refusal, as a grant it suggests would not let a blocked device in. + const blocked = yield* blocks + .isBlocked({ stableNodeId: node.stableNodeId }) + .pipe(Effect.mapError((cause) => new TailnetDeviceBlockLookupError({ cause }))); + if (blocked) return yield* refuse("blocked"); if (decision._tag === "Refused") { return yield* refuse( decision.reason, @@ -494,11 +499,6 @@ export const make = Effect.gen(function* () { } const { scopes, via } = decision; - const blocked = yield* blocks - .isBlocked({ stableNodeId: node.stableNodeId }) - .pipe(Effect.mapError((cause) => new TailnetDeviceBlockLookupError({ cause }))); - if (blocked) return yield* refuse("blocked"); - knownUsers.set(node.stableNodeId, node.loginName ?? node.userId); const deviceLabel = label(node, status); const issued = yield* environmentAuth.createPairingLink({ diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 489fe6b731a0e..982b7b5aac314 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -211,7 +211,9 @@ 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. +device shows the grant to add, for that device and this machine only. It names +both by Tailscale address, so remove it when you retire either one: 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`: From 08183d77a10cea9b12f6605884a7a3f22ebeb7fb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:38:02 -0700 Subject: [PATCH 065/114] fix(tailscale): an owner-rule sign-in counts once its code exists Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 4823320259b0a..be7da6a63b893 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -225,7 +225,7 @@ export const make = Effect.gen(function* () { // 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, by sign-in or recheck. + // 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) => @@ -373,7 +373,6 @@ export const make = Effect.gen(function* () { if (ownerUserId === null) return refused("owner_unknown"); const refusal = refuseNode(node, ownerUserId); if (refusal !== null) return refused(refusal); - yield* Ref.update(ownerRuleAdmissions, (count) => count + 1); return { _tag: "Allowed", scopes: AuthStandardClientScopes, @@ -508,6 +507,8 @@ export const make = Effect.gen(function* () { scopes, ...(deviceLabel === null ? {} : { label: deviceLabel }), }); + // 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({ @@ -571,6 +572,9 @@ export const make = Effect.gen(function* () { 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) { From b656eb6bee6e9c12d11cf75833db13fa0dc6bd82 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:42:51 -0700 Subject: [PATCH 066/114] fix(tailscale): a dropped block request still revokes, and the recheck ends a blocked device it cannot look up Blocking runs its block and its revocation uninterruptible, still as two commits. A recheck that cannot look a device up ends it at once when it is blocked, instead of waiting out the lookup grace. Revoking other sessions runs uninterruptible like the other revocations. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/SessionStore.ts | 32 ++++++++++++---------- apps/server/src/auth/TailnetSignIn.test.ts | 20 ++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 5 +++- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index ce25a7f430031..d470e89582c2a 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -1050,20 +1050,24 @@ const make = Effect.gen(function* () { 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); - }); + )( + 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", diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 1043a98bf123c..52b88f59759cb 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -19,6 +19,7 @@ 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 TailscaleHost from "../tailscale/TailscaleHost.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -1642,6 +1643,25 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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. + const blockList = yield* AuthTailnetDeviceBlocks.AuthTailnetDeviceBlockRepository; + yield* blockList.block({ + stableNodeId: "nPhone", + label: null, + blockedAt: yield* DateTime.now, + }); + tailnet.status = hostStatus({ + Peer: { + phone: { ID: "nPhone", TailscaleIPs: [PHONE_ADDRESS], UserID: `int64:${OWNER_ID}` }, + }, + }); + tailnet.node = null; + yield* service.recheckSessions; + expect((yield* Effect.flip(sessions.verify(renewed.access_token)))._tag).toBe( + "SessionTokenRevokedError", + ); }).pipe(Effect.provide(layerTest(tailnet))); }); }); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index be7da6a63b893..df371914bcbcf 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -561,6 +561,8 @@ export const make = Effect.gen(function* () { if (address === undefined) return { end: "not_on_tailnet" } 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); @@ -777,11 +779,12 @@ export const make = Effect.gen(function* () { 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. + // wakes on the revocation must read it committed. A dropped request still does both. return yield* blocks .block({ stableNodeId, label, blockedAt }) .pipe( Effect.andThen(sessions.revokeBySubject(subject)), + Effect.uninterruptible, Effect.mapError(accessError("Could not block the device.")), ); }); From 57401aea980125610301536b60eb9c732787feb9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:44:32 -0700 Subject: [PATCH 067/114] fix(tailscale): a refusal's suggested grant says to remove it when either device leaves Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index df371914bcbcf..28af9887a2eb3 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -491,7 +491,7 @@ export const make = Effect.gen(function* () { remoteAddress, localAddress, localPort, - `${label(node, status) ?? remoteAddress} on ${status.magicDnsName?.split(".")[0] ?? localAddress}`, + `${label(node, status) ?? remoteAddress} on ${status.magicDnsName?.split(".")[0] ?? localAddress}; remove when either leaves, as addresses get reused`, ) : undefined, ); From 850c39e016d28acd19368fe8e1e2177ea702c338 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:48:46 -0700 Subject: [PATCH 068/114] fix(tailscale): blocks hold when Tailscale cannot be read, and a block revokes unused codes A recheck that cannot read Tailscale still ends blocked devices, as a block needs no answer from it. Blocking a device revokes its unused sign-in codes too, so a quick unblock does not bring one back with older scopes. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 42 +++++++++++++--------- apps/server/src/auth/TailnetSignIn.ts | 15 +++++++- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 52b88f59759cb..77aa5eec43251 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1638,6 +1638,10 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { ]); 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([ @@ -1645,23 +1649,29 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { ]); // A block whose revocation never ran ends the device's sessions at the next recheck, - // even when Tailscale cannot look the device up. + // even when Tailscale cannot look the device up, or cannot be read at all. const blockList = yield* AuthTailnetDeviceBlocks.AuthTailnetDeviceBlockRepository; - yield* blockList.block({ - stableNodeId: "nPhone", - label: null, - blockedAt: yield* DateTime.now, - }); - tailnet.status = hostStatus({ - Peer: { - phone: { ID: "nPhone", TailscaleIPs: [PHONE_ADDRESS], UserID: `int64:${OWNER_ID}` }, - }, - }); - tailnet.node = null; - yield* service.recheckSessions; - expect((yield* Effect.flip(sessions.verify(renewed.access_token)))._tag).toBe( - "SessionTokenRevokedError", - ); + 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); + } }).pipe(Effect.provide(layerTest(tailnet))); }); }); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 28af9887a2eb3..b12ee1766f79f 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -655,7 +655,18 @@ export const make = Effect.gen(function* () { if (status._tag === "None" || status.value.backendState !== "Running") { const nowMs = DateTime.toEpochMillis(now); statusFailingSince ??= nowMs; - if (nowMs - statusFailingSince < Duration.toMillis(LOOKUP_GRACE)) return false; + 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; + } yield* Effect.forEach( devices.keys(), (id) => endAccess(id, "tailscale_unavailable", nameOf(id)), @@ -780,9 +791,11 @@ export const make = Effect.gen(function* () { // 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.")), From ce8a5828eddd4a36ca60d4f9bcaa28ec7978c1d8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:49:33 -0700 Subject: [PATCH 069/114] docs(tailscale): the grant note and the guide both say to remove it when either device leaves the tailnet Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.ts | 2 +- docs/user/remote-access.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index b12ee1766f79f..cc3d1459af45b 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -491,7 +491,7 @@ export const make = Effect.gen(function* () { remoteAddress, localAddress, localPort, - `${label(node, status) ?? remoteAddress} on ${status.magicDnsName?.split(".")[0] ?? localAddress}; remove when either leaves, as addresses get reused`, + `${label(node, status) ?? remoteAddress} on ${status.magicDnsName?.split(".")[0] ?? localAddress}; remove when either leaves the tailnet, as addresses get reused`, ) : undefined, ); diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 982b7b5aac314..eeb96f88d226f 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -212,8 +212,8 @@ 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 you retire either one: Tailscale can -give that address to a new device. +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`: From 09252d665fac354fb7c8e7547497d4d0f2d394d3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 00:56:04 -0700 Subject: [PATCH 070/114] fix(tailscale): a sign-in that raced a block gets no code A sign-in checks the block list again after writing its code; when a block landed since its first check, it revokes that code and refuses as blocked. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 37 ++++++++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 11 +++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 77aa5eec43251..4026b18c54167 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -86,6 +86,11 @@ interface Tailnet { 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; @@ -213,12 +218,34 @@ const layerCodeList = (tailnet: Tailnet) => }), ); +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), Layer.provideMerge(SqlitePersistence.layerMemory), Layer.provideMerge(ServerSecretStore.layer), @@ -1672,6 +1699,16 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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 index cc3d1459af45b..6616822137a80 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -476,11 +476,11 @@ export const make = Effect.gen(function* () { ) { yield* recheckSessions.pipe(Effect.forkDetach); } - // Checked before any refusal, as a grant it suggests would not let a blocked device in. - const blocked = yield* blocks + const isBlocked = blocks .isBlocked({ stableNodeId: node.stableNodeId }) .pipe(Effect.mapError((cause) => new TailnetDeviceBlockLookupError({ cause }))); - if (blocked) return yield* refuse("blocked"); + // Checked before any refusal, as a grant it suggests would not let a blocked device in. + if (yield* isBlocked) return yield* refuse("blocked"); if (decision._tag === "Refused") { return yield* refuse( decision.reason, @@ -507,6 +507,11 @@ export const make = Effect.gen(function* () { 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; From cc1dd9e552c47d25a1976f4e56b0fc677b348143 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:01:15 -0700 Subject: [PATCH 071/114] fix(tailscale): rechecks distrust a Tailscale too old for sign-in, and setup and docs say what holds The session recheck treats a Tailscale version that sign-in refuses like one that cannot be read: blocks still end at once, other sessions end after the grace. `t3 up` with a custom port no longer says other devices find the machine. The guide says the desktop app refuses LAN connections only while Network access is off, and that a block leaves an open preview or device screen streaming until it closes. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 12 ++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 9 +++++++-- apps/server/src/cli/up.ts | 5 ++++- docs/user/remote-access.md | 11 +++++++---- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 4026b18c54167..0bbe2791461c1 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -856,6 +856,18 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).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"] }; diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 6616822137a80..67bf7bbef0d34 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -655,9 +655,14 @@ export const make = Effect.gen(function* () { return false; } // A daemon that is starting or logged out lists no peers, which proves nothing for a - // while. Past the grace period nothing vouches for these devices, so their access ends. + // 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") { + 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)) { diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 46ec6eb438db4..0dddf0863fff4 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -505,7 +505,10 @@ const formatTailscale = ( `Add this machine: ${directUrl}`, // A QR code is noise in a log. ...(process.stdout.isTTY ? [renderTerminalQrCode(directUrl)] : []), - "Tailnet devices find this machine automatically.", + // Other devices look for machines only on the default port. + ...(port === ServerConfig.DEFAULT_PORT + ? ["Tailnet devices find this machine automatically."] + : []), "", ...(requireGrant ? [ diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index eeb96f88d226f..09c400c3c469d 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -254,13 +254,16 @@ 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 and 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. +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 any device connected to that machine, and **Block** it under **Tailscale devices on -**. Its sessions there end now, and **Unblock** undoes it. A device that -rejoins Tailscale gets a new ID, so block it again or remove it from your tailnet. +**. Its sessions there end now, and **Unblock** undoes it. A browser +preview or device screen it already has open keeps streaming 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. From dec93da7e9a30e5a84c8b9a74f1433bcea9015c4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:05:15 -0700 Subject: [PATCH 072/114] fix(tailscale): sessions a too-old Tailscale ends are logged as outdated Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 67bf7bbef0d34..15f58a854c863 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -677,11 +677,13 @@ export const make = Effect.gen(function* () { ); return false; } - yield* Effect.forEach( - devices.keys(), - (id) => endAccess(id, "tailscale_unavailable", nameOf(id)), - { discard: true }, - ); + 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; From bbd20ff928a6a0a6728eaf13a20ad9e2b1b991d7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:08:10 -0700 Subject: [PATCH 073/114] fix(tailscale): rechecks end devices whose node key expired, and require-grant says when Tailscale is too old A device whose Tailscale node key expired is off the tailnet until it signs in again, so the recheck ends its access rather than leave it reachable over a LAN route. `t3 tailscale require-grant` on a Tailscale too old for sign-in says no device signs in and to update, instead of describing who could. The guide says an open preview or device screen keeps taking input after a block, not only streaming. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 23 ++++--- apps/server/src/auth/TailnetSignIn.ts | 2 + apps/server/src/cli/tailscale.ts | 72 +++++++++++++--------- docs/user/remote-access.md | 3 +- 4 files changed, 61 insertions(+), 39 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 0bbe2791461c1..89822c2981ead 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -842,17 +842,24 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { "ServerAuthInvalidCredentialError", ); - // So does leaving the tailnet, but a Tailscale that is starting proves nothing. + // 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"] }) }, }; - 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: { phone: peers.phone } }); - yield* service.recheckSessions; - expect(yield* revoked(last.access_token)).toBe(true); + 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))); }); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 15f58a854c863..66227e27cd6d6 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -564,6 +564,8 @@ export const make = Effect.gen(function* () { 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. diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 0b42c55e8c305..42f07f3e3c666 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -16,6 +16,10 @@ 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, readTailscaleHealth } from "../environment/TailscaleHealth.ts"; +import { + isSupportedTailscaleVersion, + MINIMUM_TAILSCALE_VERSION, +} from "../tailscale/TailscaleHost.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; const encodeStatusJson = Schema.encodeEffect(fromJsonStringPretty(ServerTailscaleStatus)); @@ -69,6 +73,7 @@ const readPolicyGrantedPeers = readTailscaleStatus("active").pipe( ...scan, tagged: status.selfTags.length > 0, noNode: status.selfNodeId === null, + version: status.version, }, ), ), @@ -120,46 +125,53 @@ const requireGrantCommand = Command.make("require-grant", { const scan = read === "changed" ? null : read; const granted = scan?.granted ?? []; const lines = - since !== null + scan !== null && !isSupportedTailscaleVersion(scan.version) ? [ - `Grant required: yes, turned on ${since}.`, - "Turn off with: t3 tailscale require-grant off", + `Tailscale ${scan.version ?? "(unknown version)"} is too old for Tailscale sign-in, so no device`, + `signs in here. Update to ${MINIMUM_TAILSCALE_VERSION} or newer.`, + ...(since === null ? [] : [`Grant required: yes, turned on ${since}.`]), ] - : granted.length > 0 + : since !== null ? [ - `Grant required: yes, because your tailnet policy grants ${TAILNET_APP_CAPABILITY} here to: ${granted.join(", ")}.`, - "Remove those grants to let your own untagged devices in without one.", + `Grant required: yes, turned on ${since}.`, + "Turn off with: t3 tailscale require-grant off", ] - : read === "changed" + : granted.length > 0 ? [ - "Grant required: unknown. This host's Tailscale account or its devices", - "changed during the check.", - "Run this again.", + `Grant required: yes, because your tailnet policy grants ${TAILNET_APP_CAPABILITY} here to: ${granted.join(", ")}.`, + "Remove those grants to let your own untagged devices in without one.", ] - : scan === null - ? ["Grant required: yes, because Tailscale is not answering."] - : scan.noNode - ? [ - "Grant required: yes, because Tailscale names no node for this host.", - "Run `tailscale up` to sign in.", - ] - : scan.unreadable.length > 0 + : read === "changed" + ? [ + "Grant required: unknown. This host's Tailscale account or its devices", + "changed during the check.", + "Run this again.", + ] + : scan === null + ? ["Grant required: yes, because Tailscale is not answering."] + : scan.noNode ? [ - `Grant required: probably. Tailscale could not read ${scan.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.", + "Grant required: yes, because Tailscale names no node for this host.", + "Run `tailscale up` to sign in.", ] - : scan.tagged && !(yield* hasOwner(config.baseDir, config.tailscaleOwnerUserId)) + : scan.unreadable.length > 0 ? [ - "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: probably. Tailscale could not read ${scan.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.", ] - : [ - "Grant required: no. Your tailnet policy grants T3 Code here to no device,", - "so 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", - ]; + : scan.tagged && + !(yield* hasOwner(config.baseDir, config.tailscaleOwnerUserId)) + ? [ + "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,", + "so 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, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 09c400c3c469d..c00def42244eb 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -261,7 +261,8 @@ laptop by name with that port. To cut off one device on one machine, open **Settings → Environments** on any device connected to that machine, and **Block** it under **Tailscale devices on **. Its sessions there end now, and **Unblock** undoes it. A browser -preview or device screen it already has open keeps streaming until it closes. A +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. From 20a5abdc555332f25242d7278f35304603892283 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:13:03 -0700 Subject: [PATCH 074/114] fix(tailscale): sign-in refuses a peer whose key expired, and require-grant uses t3 up's host checks Sign-in requires the caller's peer entry to have a live key, as the recheck does. `t3 tailscale require-grant` and `t3 up` share one check for why sign-in cannot work on this host (not running, userspace networking, too old), so require-grant never tells owners they can sign in where the server refuses everyone, and it says whether the switch is on or off. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 14 +++++++++++ apps/server/src/auth/TailnetSignIn.ts | 7 +++--- apps/server/src/cli/tailscale.ts | 23 +++++++++++++----- apps/server/src/cli/up.ts | 27 +++------------------- 4 files changed, 38 insertions(+), 33 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 89822c2981ead..65354c392c46e 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -412,6 +412,20 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { status: hostStatus({ Version: "1.62.0" }), refused: "tailscale_outdated", }, + { + name: "the device's node key expired", + status: hostStatus({ + Peer: { + phone: { + ID: "nPhone", + TailscaleIPs: [PHONE_ADDRESS], + UserID: `int64:${OWNER_ID}`, + Expired: true, + }, + }, + }), + refused: "tailscale_unavailable", + }, { name: "the host is tagged with no owner set", status: hostStatus({ diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 66227e27cd6d6..f2b425861e994 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -455,14 +455,15 @@ export const make = Effect.gen(function* () { ); const refusal = hostRefusal(status); if (refusal !== null) return yield* refuse(refusal); - // The caller must be the peer this status names at its address, as the recheck asks, - // so a whois answered before an account switch is not judged by the new account. + // The caller must be the peer this status names at its address, with a live key, as the + // recheck asks, so a whois answered before an account switch is not judged by the new one. if ( node.stableNodeId !== status.selfNodeId && !status.peers.some( (peer) => peer.stableNodeId === node.stableNodeId && - peer.tailnetIpv4Addresses.includes(remoteAddress), + peer.tailnetIpv4Addresses.includes(remoteAddress) && + !peer.keyExpired, ) ) { return yield* refuse("tailscale_unavailable"); diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 42f07f3e3c666..aacc91cfb65a3 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -2,7 +2,7 @@ import * as NodeUtil from "node:util"; import { ServerTailscaleStatus, TAILNET_APP_CAPABILITY } from "@t3tools/contracts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; -import { readTailscaleStatus, readTailscaleWhois } from "@t3tools/tailscale"; +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"; @@ -55,6 +55,16 @@ export const turnOnRequireGrant = Effect.fn("cli.tailscale.turnOnRequireGrant")( ); }); +/** Why Tailscale sign-in cannot work on this host, or null when it can. */ +export const signInBlocker = (status: TailscaleStatus): string | null => + status.backendState !== "Running" + ? `Tailscale is ${status.backendState ?? "not running"}. Run \`sudo tailscale up\`.` + : status.userspaceNetworking === true + ? "Tailscale sign-in needs device addresses, and userspace networking hides them. Give the container /dev/net/tun and run tailscaled without --tun=userspace-networking." + : !isSupportedTailscaleVersion(status.version) + ? `Tailscale ${status.version ?? "(unknown version)"} is too old for Tailscale sign-in. Update to ${MINIMUM_TAILSCALE_VERSION} or newer.` + : null; + /** * Peers the tailnet policy grants T3 Code here, and whether this host is tagged. Null when * Tailscale cannot say, and "changed" when the account or its devices changed during the @@ -73,7 +83,7 @@ const readPolicyGrantedPeers = readTailscaleStatus("active").pipe( ...scan, tagged: status.selfTags.length > 0, noNode: status.selfNodeId === null, - version: status.version, + blocker: signInBlocker(status), }, ), ), @@ -125,11 +135,12 @@ const requireGrantCommand = Command.make("require-grant", { const scan = read === "changed" ? null : read; const granted = scan?.granted ?? []; const lines = - scan !== null && !isSupportedTailscaleVersion(scan.version) + scan !== null && scan.blocker !== null ? [ - `Tailscale ${scan.version ?? "(unknown version)"} is too old for Tailscale sign-in, so no device`, - `signs in here. Update to ${MINIMUM_TAILSCALE_VERSION} or newer.`, - ...(since === null ? [] : [`Grant required: yes, turned on ${since}.`]), + `No device signs in here. ${scan.blocker}`, + since === null + ? "The require-grant switch is off." + : `Grant required: yes, turned on ${since}.`, ] : since !== null ? [ diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 0dddf0863fff4..682e7815af74e 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -30,10 +30,6 @@ import { renderTerminalQrCode } from "../startupAccess.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerOwnership from "../serverOwnership.ts"; import * as TailscaleServe from "../tailscaleServe.ts"; -import { - isSupportedTailscaleVersion, - MINIMUM_TAILSCALE_VERSION, -} from "../tailscale/TailscaleHost.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; import { connectSubcommands, @@ -45,7 +41,7 @@ import { } from "./connect.ts"; import { resolveHostCliCommand } from "./invocation.ts"; import * as CliService from "./service.ts"; -import { turnOnRequireGrant } from "./tailscale.ts"; +import { signInBlocker, turnOnRequireGrant } from "./tailscale.ts"; type Route = "tailscale" | "connect" | "lan"; type Routes = Readonly>; @@ -358,25 +354,8 @@ const checkTailscale = Effect.fn("cli.up.checkTailscale")(function* () { } as const; } const { value } = status; - if (value.backendState !== "Running") { - return { - ok: false, - reason: `Tailscale is ${value.backendState ?? "not running"}. Run \`sudo tailscale up\`.`, - } as const; - } - if (value.userspaceNetworking === true) { - return { - ok: false, - reason: - "Tailscale sign-in needs device addresses, and userspace networking hides them. Give the container /dev/net/tun and run tailscaled without --tun=userspace-networking.", - } as const; - } - if (!isSupportedTailscaleVersion(value.version)) { - return { - ok: false, - reason: `Tailscale ${value.version ?? "(unknown version)"} is too old for Tailscale sign-in. Update to ${MINIMUM_TAILSCALE_VERSION} or newer.`, - } as const; - } + const blocker = signInBlocker(value); + if (blocker !== null) return { ok: false, reason: blocker } as const; const address = value.tailnetIpv4Addresses[0]; if (address === undefined) { return { ok: false, reason: "Tailscale reported no IPv4 address for this host." } as const; From 80369ef4e1268d59f108b9c85b68c43861089ad5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:15:58 -0700 Subject: [PATCH 075/114] fix(tailscale): Tailscale health flags a version too old for sign-in, and require-grant skips the scan where sign-in cannot work `t3 tailscale status` and the clients' Tailscale health now report a Tailscale too old for sign-in, with the version to update to, instead of "nothing to fix" while sign-in refuses. `t3 tailscale require-grant` looks no device up when sign-in cannot work on this host, and leaves out the grant scopes line there. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/tailscale.ts | 12 ++++++++++-- apps/server/src/environment/TailscaleHealth.test.ts | 6 ++++++ apps/server/src/environment/TailscaleHealth.ts | 6 ++++++ docs/user/remote-access.md | 5 ++--- packages/contracts/src/server.ts | 3 +++ 5 files changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index aacc91cfb65a3..914060c140778 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -72,7 +72,11 @@ export const signInBlocker = (status: TailscaleStatus): string | null => */ const readPolicyGrantedPeers = readTailscaleStatus("active").pipe( Effect.flatMap((status) => - policyGrantedPeers(status, readTailscaleWhois).pipe( + // Where sign-in cannot work, the policy does not matter, so no device is looked up. + (signInBlocker(status) === null + ? policyGrantedPeers(status, readTailscaleWhois) + : Effect.succeed({ granted: [], unreadable: [] }) + ).pipe( Effect.flatMap((scan) => readTailscaleStatus("active").pipe( Effect.map((after) => @@ -186,7 +190,11 @@ const requireGrantCommand = Command.make("require-grant", { yield* Console.log( [ ...lines, - "Devices with a grant get the scopes it names: read, standard, admin, or single scopes.", + ...(scan?.blocker == null + ? [ + "Devices with a grant get the scopes it names: read, standard, admin, or single scopes.", + ] + : []), ].join("\n"), ); }), diff --git a/apps/server/src/environment/TailscaleHealth.test.ts b/apps/server/src/environment/TailscaleHealth.test.ts index 40f8d8eb14021..bb675e55d6df7 100644 --- a/apps/server/src/environment/TailscaleHealth.test.ts +++ b/apps/server/src/environment/TailscaleHealth.test.ts @@ -129,6 +129,12 @@ describe("classifyTailscaleHealth", () => { "running", ["health_warning", "https_off", "magic_dns_off", "userspace_networking"], ], + [ + "too old for sign-in", + Result.succeed({ ...running, version: "1.62.0" }), + "running", + ["outdated"], + ], [ "an older CLI that reports no capabilities or TUN", Result.succeed({ diff --git a/apps/server/src/environment/TailscaleHealth.ts b/apps/server/src/environment/TailscaleHealth.ts index 59d43b6bf8b11..8b01308afd354 100644 --- a/apps/server/src/environment/TailscaleHealth.ts +++ b/apps/server/src/environment/TailscaleHealth.ts @@ -125,6 +125,12 @@ export function classifyTailscaleHealth( fix: "Run tailscaled with a TUN device, so T3 Code can see which device connects.", }); } + if (!TailscaleHost.isSupportedTailscaleVersion(status.version)) { + issues.push({ + cause: "outdated", + fix: `Update Tailscale to ${TailscaleHost.MINIMUM_TAILSCALE_VERSION} or newer.`, + }); + } } return { state, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index c00def42244eb..b5d57d7ebf6e6 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -262,9 +262,8 @@ To cut off one device on one machine, open **Settings → Environments** on any device connected to that machine, and **Block** it under **Tailscale devices on **. Its sessions there end now, and **Unblock** undoes it. 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. +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. diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index cf1e3e9bf7ae1..3dbd8d667c337 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -622,6 +622,7 @@ export const ServerTailscaleIssueCause = Schema.Literals([ "https_off", "magic_dns_off", "userspace_networking", + "outdated", ]); export type ServerTailscaleIssueCause = typeof ServerTailscaleIssueCause.Type; @@ -698,6 +699,8 @@ export function tailscaleIssueHeadline( 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`; } } From 44f3554db49687163e59929d7231b5d5fb55a191 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:20:50 -0700 Subject: [PATCH 076/114] fix(tailscale): an expired device key asks that device to log in again, IPv6-only hosts are named, and clients show an outdated host A sign-in from a peer whose key expired gets `not_direct_tailnet`, whose steps now say to log in to Tailscale again on this device, instead of pointing at the host. The shared host check refuses a host with no Tailscale IPv4 address, so `require-grant` matches `t3 up` there. The host notice the clients show includes a Tailscale too old for sign-in. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 2 +- apps/server/src/auth/TailnetSignIn.ts | 23 +++++++++---------- apps/server/src/cli/tailscale.ts | 4 +++- apps/server/src/cli/up.ts | 6 ++--- .../src/connection/diagnosis.test.ts | 4 ++++ .../src/connection/diagnosis.ts | 1 + .../client-runtime/src/connection/tailnet.ts | 2 +- 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 65354c392c46e..19ef0e0717258 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -424,7 +424,7 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }, }, }), - refused: "tailscale_unavailable", + refused: "not_direct_tailnet", }, { name: "the host is tagged with no owner set", diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index f2b425861e994..efa34c7891542 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -455,18 +455,17 @@ export const make = Effect.gen(function* () { ); const refusal = hostRefusal(status); if (refusal !== null) return yield* refuse(refusal); - // The caller must be the peer this status names at its address, with a live key, as the - // recheck asks, so a whois answered before an account switch is not judged by the new one. - if ( - node.stableNodeId !== status.selfNodeId && - !status.peers.some( - (peer) => - peer.stableNodeId === node.stableNodeId && - peer.tailnetIpv4Addresses.includes(remoteAddress) && - !peer.keyExpired, - ) - ) { - return yield* refuse("tailscale_unavailable"); + // The caller must be the peer this status names at its address, as the recheck asks, so + // a whois answered before an account switch is not judged by the new account. + if (node.stableNodeId !== status.selfNodeId) { + const peer = status.peers.find( + (candidate) => + candidate.stableNodeId === node.stableNodeId && + candidate.tailnetIpv4Addresses.includes(remoteAddress), + ); + if (peer === undefined) return yield* refuse("tailscale_unavailable"); + // Off the tailnet until it logs in to Tailscale again, which only the device can do. + if (peer.keyExpired) return yield* refuse("not_direct_tailnet"); } const decision = yield* decide(node, status); // The policy names this device, so it decides here now, even when the grant is refused diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 914060c140778..b5e101a77bec2 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -63,7 +63,9 @@ export const signInBlocker = (status: TailscaleStatus): string | null => ? "Tailscale sign-in needs device addresses, and userspace networking hides them. Give the container /dev/net/tun and run tailscaled without --tun=userspace-networking." : !isSupportedTailscaleVersion(status.version) ? `Tailscale ${status.version ?? "(unknown version)"} is too old for Tailscale sign-in. Update to ${MINIMUM_TAILSCALE_VERSION} or newer.` - : null; + : status.tailnetIpv4Addresses.length === 0 + ? "Tailscale reported no IPv4 address for this host, and sign-in needs one." + : null; /** * Peers the tailnet policy grants T3 Code here, and whether this host is tagged. Null when diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 682e7815af74e..8ee48c8336c26 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -355,10 +355,10 @@ const checkTailscale = Effect.fn("cli.up.checkTailscale")(function* () { } const { value } = status; const blocker = signInBlocker(value); - if (blocker !== null) return { ok: false, reason: blocker } as const; + // signInBlocker already refuses a host with no IPv4 address. const address = value.tailnetIpv4Addresses[0]; - if (address === undefined) { - return { ok: false, reason: "Tailscale reported no IPv4 address for this host." } as const; + 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; }); diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index 511ede30d896c..d007b10f0f4ec 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -338,6 +338,10 @@ describe("Tailscale facts in the diagnosis", () => { 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", + ); }); }); diff --git a/packages/client-runtime/src/connection/diagnosis.ts b/packages/client-runtime/src/connection/diagnosis.ts index ccbb36febc9b3..0c5e12bcfab54 100644 --- a/packages/client-runtime/src/connection/diagnosis.ts +++ b/packages/client-runtime/src/connection/diagnosis.ts @@ -362,6 +362,7 @@ const HOST_TAILSCALE_NOTICE: ReadonlySet = new Set([ ...HOST_TAILSCALE_DOWN, "key_expiring", "health_warning", + "outdated", ]); /** " (as of 2h ago)" for facts a minute old or more. */ diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index 45429ea732334..8161979748d46 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -183,7 +183,7 @@ export function tailnetRefusalVerdict( return { headline: `${host} did not see this device on its Tailscale address`, steps: [ - "Turn on Tailscale on this device. T3 Code retries on its own.", + "Turn on Tailscale on this device, and log in to it again if it asks. T3 Code retries on its own.", `Add ${host} by its Tailscale name or 100.x address, not a LAN or HTTPS address.`, ], }; From cf4c9d7b40b37c0d5af4ef12c3f11abaaee50493 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:23:42 -0700 Subject: [PATCH 077/114] fix(tailscale): sign-in host checks name the Tailscale state in plain words, and require-grant stops at a blocker `signInBlocker` describes a Tailscale that is not running with the same headline and per-platform fix as `t3 tailscale status`. `require-grant` reads nothing more once it finds a blocker, so it cannot report a change during a check it skipped, and it names the require-grant setting one way. The guide states the minimum Tailscale version. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/tailscale.ts | 86 ++++++++++--------- apps/server/src/cli/up.ts | 2 +- .../server/src/environment/TailscaleHealth.ts | 5 +- docs/user/remote-access.md | 5 +- 4 files changed, 54 insertions(+), 44 deletions(-) diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index b5e101a77bec2..3267965741bd1 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -1,6 +1,12 @@ import * as NodeUtil from "node:util"; -import { ServerTailscaleStatus, TAILNET_APP_CAPABILITY } from "@t3tools/contracts"; +import { + 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"; @@ -15,7 +21,11 @@ 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, readTailscaleHealth } from "../environment/TailscaleHealth.ts"; +import { + formatTailscaleHealth, + readTailscaleHealth, + stateFix, +} from "../environment/TailscaleHealth.ts"; import { isSupportedTailscaleVersion, MINIMUM_TAILSCALE_VERSION, @@ -56,49 +66,45 @@ export const turnOnRequireGrant = Effect.fn("cli.tailscale.turnOnRequireGrant")( }); /** Why Tailscale sign-in cannot work on this host, or null when it can. */ -export const signInBlocker = (status: TailscaleStatus): string | null => - status.backendState !== "Running" - ? `Tailscale is ${status.backendState ?? "not running"}. Run \`sudo tailscale up\`.` - : status.userspaceNetworking === true - ? "Tailscale sign-in needs device addresses, and userspace networking hides them. Give the container /dev/net/tun and run tailscaled without --tun=userspace-networking." - : !isSupportedTailscaleVersion(status.version) - ? `Tailscale ${status.version ?? "(unknown version)"} is too old for Tailscale sign-in. Update to ${MINIMUM_TAILSCALE_VERSION} or newer.` - : status.tailnetIpv4Addresses.length === 0 - ? "Tailscale reported no IPv4 address for this host, and sign-in needs one." - : null; +export const signInBlocker = ( + status: TailscaleStatus, + platform: NodeJS.Platform, +): string | null => { + const state = tailscaleStateFromBackend(status.backendState); + if (state !== "running") { + const fix = stateFix(state, platform); + return `${tailscaleIssueHeadline("this machine", { cause: state, fix }, null, 0)}. ${fix}`; + } + return status.userspaceNetworking === true + ? "Tailscale sign-in needs device addresses, and userspace networking hides them. Give the container /dev/net/tun and run tailscaled without --tun=userspace-networking." + : !isSupportedTailscaleVersion(status.version) + ? `Tailscale ${status.version ?? "(unknown version)"} is too old for Tailscale sign-in. Update to ${MINIMUM_TAILSCALE_VERSION} or newer.` + : status.tailnetIpv4Addresses.length === 0 + ? "Tailscale reported no IPv4 address for this host, and sign-in needs one." + : null; +}; /** * Peers the tailnet policy grants T3 Code here, and whether this host is tagged. Null when * Tailscale cannot say, and "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 = readTailscaleStatus("active").pipe( - Effect.flatMap((status) => - // Where sign-in cannot work, the policy does not matter, so no device is looked up. - (signInBlocker(status) === null - ? policyGrantedPeers(status, readTailscaleWhois) - : Effect.succeed({ granted: [], unreadable: [] }) - ).pipe( - Effect.flatMap((scan) => - readTailscaleStatus("active").pipe( - Effect.map((after) => - after.selfNodeId !== status.selfNodeId || - (scan.granted.length === 0 && peersJoined(status, after)) - ? ("changed" as const) - : { - ...scan, - tagged: status.selfTags.length > 0, - noNode: status.selfNodeId === null, - blocker: signInBlocker(status), - }, - ), - ), - ), - ), - ), - Effect.option, - Effect.map(Option.getOrNull), -); +const readPolicyGrantedPeers = Effect.gen(function* () { + const status = yield* readTailscaleStatus("active"); + const facts = { + tagged: status.selfTags.length > 0, + noNode: status.selfNodeId === null, + blocker: signInBlocker(status, yield* HostProcess.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 after = yield* readTailscaleStatus("active"); + return after.selfNodeId !== status.selfNodeId || + (scan.granted.length === 0 && peersJoined(status, after)) + ? ("changed" as const) + : { ...scan, ...facts }; +}).pipe(Effect.option, Effect.map(Option.getOrNull)); /** Whether this tagged host has an owner, from `t3 up --owner` or this shell's environment. */ const hasOwner = Effect.fn("cli.tailscale.hasOwner")(function* ( @@ -145,7 +151,7 @@ const requireGrantCommand = Command.make("require-grant", { ? [ `No device signs in here. ${scan.blocker}`, since === null - ? "The require-grant switch is off." + ? "Grant required: not turned on." : `Grant required: yes, turned on ${since}.`, ] : since !== null diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 8ee48c8336c26..12a87bc46f795 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -354,7 +354,7 @@ const checkTailscale = Effect.fn("cli.up.checkTailscale")(function* () { } as const; } const { value } = status; - const blocker = signInBlocker(value); + const blocker = signInBlocker(value, yield* HostProcess.Platform); // signInBlocker already refuses a host with no IPv4 address. const address = value.tailnetIpv4Addresses[0]; if (blocker !== null || address === undefined) { diff --git a/apps/server/src/environment/TailscaleHealth.ts b/apps/server/src/environment/TailscaleHealth.ts index 8b01308afd354..3739a0688347f 100644 --- a/apps/server/src/environment/TailscaleHealth.ts +++ b/apps/server/src/environment/TailscaleHealth.ts @@ -36,7 +36,10 @@ export type TailscaleStatusRead = Result.Result< /** 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"; -function stateFix(state: Exclude, platform: NodeJS.Platform): string { +export function stateFix( + state: Exclude, + platform: NodeJS.Platform, +): string { const app = usesApp(platform); switch (state) { case "not_installed": diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b5d57d7ebf6e6..89e22361527ab 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -288,8 +288,9 @@ too: With Tailscale sign-in on and the local network off, T3 Code accepts only loopback and tailnet connections. -Tailscale sign-in does not work with userspace networking, which is common in -LXC and other containers; `t3 up` tells you when it finds it. +Tailscale sign-in needs Tailscale 1.80 or newer on the machine, and does not work +with userspace networking, which is common in LXC and other containers; `t3 up` +tells you when it finds either. ### Tailscale HTTPS From 936041e1134c1f583199d8d4f0bffeb0ddd05505 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:30:04 -0700 Subject: [PATCH 078/114] fix(tailscale): host checks word read failures and missing IPv4 the same everywhere `t3 up` and `t3 tailscale require-grant` describe a failed `tailscale status` read the way `t3 tailscale status` does (timeouts, permission errors, per-platform fixes), and require-grant still says whether its setting is on. A host with no Tailscale IPv4 address is a health issue clients show, and the environment no longer advertises sign-in there. The "not on its Tailscale address" refusal no longer promises a retry the add-machine form does not make. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/tailscale.ts | 33 +++++++++++++++---- apps/server/src/cli/up.ts | 17 +++++----- .../src/environment/ServerEnvironment.ts | 3 +- .../src/environment/TailscaleHealth.test.ts | 6 ++++ .../server/src/environment/TailscaleHealth.ts | 8 ++++- .../src/connection/diagnosis.ts | 1 + .../client-runtime/src/connection/tailnet.ts | 2 +- packages/contracts/src/server.ts | 3 ++ 8 files changed, 54 insertions(+), 19 deletions(-) diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 3267965741bd1..86f36e6043566 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -1,6 +1,7 @@ import * as NodeUtil from "node:util"; import { + type ServerTailscaleIssue, ServerTailscaleStatus, TAILNET_APP_CAPABILITY, tailscaleIssueHeadline, @@ -14,6 +15,7 @@ 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"; @@ -23,6 +25,7 @@ import { SERVICE_ENV_FILE } from "../cloud/serviceProtocol.ts"; import { peersJoined, policyGrantedPeers } from "../auth/tailnetGrants.ts"; import { formatTailscaleHealth, + readErrorFacts, readTailscaleHealth, stateFix, } from "../environment/TailscaleHealth.ts"; @@ -65,16 +68,23 @@ export const turnOnRequireGrant = Effect.fn("cli.tailscale.turnOnRequireGrant")( ); }); +/** 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") { - const fix = stateFix(state, platform); - return `${tailscaleIssueHeadline("this machine", { cause: state, fix }, null, 0)}. ${fix}`; - } + if (state !== "running") return issueLine({ cause: state, fix: stateFix(state, platform) }); return status.userspaceNetworking === true ? "Tailscale sign-in needs device addresses, and userspace networking hides them. Give the container /dev/net/tun and run tailscaled without --tun=userspace-networking." : !isSupportedTailscaleVersion(status.version) @@ -90,11 +100,17 @@ export const signInBlocker = ( * check: lookups after a switch read another tailnet, and a device that joined was not read. */ const readPolicyGrantedPeers = Effect.gen(function* () { - const status = yield* readTailscaleStatus("active"); + 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 }; + } + const status = read.success; const facts = { tagged: status.selfTags.length > 0, noNode: status.selfNodeId === null, - blocker: signInBlocker(status, yield* HostProcess.Platform), + 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 }; @@ -171,7 +187,10 @@ const requireGrantCommand = Command.make("require-grant", { "Run this again.", ] : scan === null - ? ["Grant required: yes, because Tailscale is not answering."] + ? [ + "No device signs in here while Tailscale does not answer.", + "Grant required: not turned on.", + ] : scan.noNode ? [ "Grant required: yes, because Tailscale names no node for this host.", diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 12a87bc46f795..af84dd8d14ae5 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -12,6 +12,7 @@ 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"; @@ -41,7 +42,7 @@ import { } from "./connect.ts"; import { resolveHostCliCommand } from "./invocation.ts"; import * as CliService from "./service.ts"; -import { signInBlocker, turnOnRequireGrant } from "./tailscale.ts"; +import { readFailure, signInBlocker, turnOnRequireGrant } from "./tailscale.ts"; type Route = "tailscale" | "connect" | "lan"; type Routes = Readonly>; @@ -346,15 +347,13 @@ export const resolveOwner = Effect.fn("cli.up.resolveOwner")(function* ( /** Tailscale's state here, or why Tailscale sign-in cannot work on this machine. */ const checkTailscale = Effect.fn("cli.up.checkTailscale")(function* () { - const status = yield* readTailscaleStatus("active").pipe(Effect.option); - if (Option.isNone(status)) { - return { - ok: false, - reason: "Tailscale did not answer. Install it and run `sudo tailscale up`.", - } as const; + 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 } = status; - const blocker = signInBlocker(value, yield* HostProcess.Platform); + 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) { diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 9f518915728ec..a2952354a2242 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -291,7 +291,8 @@ export const make = Effect.gen(function* () { (status) => status.backendState === "Running" && status.userspaceNetworking !== true && - TailscaleHost.isSupportedTailscaleVersion(status.version), + TailscaleHost.isSupportedTailscaleVersion(status.version) && + status.tailnetIpv4Addresses.length > 0, ), Effect.orElseSucceed(() => false), ) diff --git a/apps/server/src/environment/TailscaleHealth.test.ts b/apps/server/src/environment/TailscaleHealth.test.ts index bb675e55d6df7..efb4346d6f156 100644 --- a/apps/server/src/environment/TailscaleHealth.test.ts +++ b/apps/server/src/environment/TailscaleHealth.test.ts @@ -135,6 +135,12 @@ describe("classifyTailscaleHealth", () => { "running", ["outdated"], ], + [ + "no IPv4 address", + Result.succeed({ ...running, tailnetIpv4Addresses: [] }), + "running", + ["no_ipv4"], + ], [ "an older CLI that reports no capabilities or TUN", Result.succeed({ diff --git a/apps/server/src/environment/TailscaleHealth.ts b/apps/server/src/environment/TailscaleHealth.ts index 3739a0688347f..ad4aafb8f2658 100644 --- a/apps/server/src/environment/TailscaleHealth.ts +++ b/apps/server/src/environment/TailscaleHealth.ts @@ -59,7 +59,7 @@ export function stateFix( } } -function readErrorFacts( +export function readErrorFacts( error: TailscaleCommandError | TailscaleStatusParseError, platform: NodeJS.Platform, ): ServerTailscaleIssue & { readonly cause: Exclude } { @@ -128,6 +128,12 @@ export function classifyTailscaleHealth( fix: "Run tailscaled with a TUN device, so T3 Code can see which device connects.", }); } + 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 (!TailscaleHost.isSupportedTailscaleVersion(status.version)) { issues.push({ cause: "outdated", diff --git a/packages/client-runtime/src/connection/diagnosis.ts b/packages/client-runtime/src/connection/diagnosis.ts index 0c5e12bcfab54..5c276e933ffba 100644 --- a/packages/client-runtime/src/connection/diagnosis.ts +++ b/packages/client-runtime/src/connection/diagnosis.ts @@ -363,6 +363,7 @@ const HOST_TAILSCALE_NOTICE: ReadonlySet = new Set([ "key_expiring", "health_warning", "outdated", + "no_ipv4", ]); /** " (as of 2h ago)" for facts a minute old or more. */ diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index 8161979748d46..c3e00d5df16c2 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -183,7 +183,7 @@ export function tailnetRefusalVerdict( 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. T3 Code retries on its own.", + "Turn on Tailscale on this device, and log in to it again if it asks. Saved machines reconnect on their own.", `Add ${host} by its Tailscale name or 100.x address, not a LAN or HTTPS address.`, ], }; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3dbd8d667c337..5c15118f2ee15 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -623,6 +623,7 @@ export const ServerTailscaleIssueCause = Schema.Literals([ "magic_dns_off", "userspace_networking", "outdated", + "no_ipv4", ]); export type ServerTailscaleIssueCause = typeof ServerTailscaleIssueCause.Type; @@ -701,6 +702,8 @@ export function tailscaleIssueHeadline( 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`; } } From 4abee7f3279bc5070835130c24f44d6ed18d42b7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:32:54 -0700 Subject: [PATCH 079/114] fix(tailscale): a host too old or without IPv4 leads its health issues, and refusal logs name their cause Health lists `outdated` and `no_ipv4` right after the Tailscale state, as each stops sign-in, so a common warning cannot hide them on a host's row. The sign-in host check words a missing IPv4 address with the same fix as health. A refused sign-in logs its cause, so an expired node key shows as such. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.ts | 4 +-- apps/server/src/cli/tailscale.ts | 3 ++- .../src/environment/TailscaleHealth.test.ts | 6 +++++ .../server/src/environment/TailscaleHealth.ts | 25 ++++++++++--------- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index efa34c7891542..c9087afc52bcc 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -385,7 +385,7 @@ export const make = Effect.gen(function* () { (reason === "disabled" || reason === "browser_request" ? Effect.logDebug : Effect.logWarning)( "Refused Tailscale sign-in.", ).pipe( - Effect.annotateLogs({ reason }), + Effect.annotateLogs({ reason, ...(cause === undefined ? {} : { cause }) }), Effect.andThen( Effect.fail( new TailnetSignInRefusedError({ @@ -465,7 +465,7 @@ export const make = Effect.gen(function* () { ); if (peer === undefined) return yield* refuse("tailscale_unavailable"); // Off the tailnet until it logs in to Tailscale again, which only the device can do. - if (peer.keyExpired) return yield* refuse("not_direct_tailnet"); + if (peer.keyExpired) return yield* refuse("not_direct_tailnet", "node key expired"); } const decision = yield* decide(node, status); // The policy names this device, so it decides here now, even when the grant is refused diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 86f36e6043566..762a7fa5f3576 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -25,6 +25,7 @@ import { SERVICE_ENV_FILE } from "../cloud/serviceProtocol.ts"; import { peersJoined, policyGrantedPeers } from "../auth/tailnetGrants.ts"; import { formatTailscaleHealth, + NO_IPV4_FIX, readErrorFacts, readTailscaleHealth, stateFix, @@ -90,7 +91,7 @@ export const signInBlocker = ( : !isSupportedTailscaleVersion(status.version) ? `Tailscale ${status.version ?? "(unknown version)"} is too old for Tailscale sign-in. Update to ${MINIMUM_TAILSCALE_VERSION} or newer.` : status.tailnetIpv4Addresses.length === 0 - ? "Tailscale reported no IPv4 address for this host, and sign-in needs one." + ? issueLine({ cause: "no_ipv4", fix: NO_IPV4_FIX }) : null; }; diff --git a/apps/server/src/environment/TailscaleHealth.test.ts b/apps/server/src/environment/TailscaleHealth.test.ts index efb4346d6f156..97cd3994ec6c5 100644 --- a/apps/server/src/environment/TailscaleHealth.test.ts +++ b/apps/server/src/environment/TailscaleHealth.test.ts @@ -141,6 +141,12 @@ describe("classifyTailscaleHealth", () => { "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({ diff --git a/apps/server/src/environment/TailscaleHealth.ts b/apps/server/src/environment/TailscaleHealth.ts index ad4aafb8f2658..57fd1aabfdf5f 100644 --- a/apps/server/src/environment/TailscaleHealth.ts +++ b/apps/server/src/environment/TailscaleHealth.ts @@ -33,6 +33,9 @@ export type TailscaleStatusRead = Result.Result< TailscaleCommandError | TailscaleStatusParseError >; +export const NO_IPV4_FIX = + "Allow IPv4 for this machine in your tailnet policy. Tailscale sign-in needs it."; + /** 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"; @@ -90,6 +93,16 @@ export function classifyTailscaleHealth( 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" && !TailscaleHost.isSupportedTailscaleVersion(status.version)) { + issues.push({ + cause: "outdated", + fix: `Update Tailscale to ${TailscaleHost.MINIMUM_TAILSCALE_VERSION} or newer.`, + }); + } + if (state === "running" && status.tailnetIpv4Addresses.length === 0) { + issues.push({ cause: "no_ipv4", fix: NO_IPV4_FIX }); + } // NaN when the key never expires, which fails both comparisons. const expiresAt = status.keyExpiry === null ? Number.NaN : Date.parse(status.keyExpiry); @@ -128,18 +141,6 @@ export function classifyTailscaleHealth( fix: "Run tailscaled with a TUN device, so T3 Code can see which device connects.", }); } - 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 (!TailscaleHost.isSupportedTailscaleVersion(status.version)) { - issues.push({ - cause: "outdated", - fix: `Update Tailscale to ${TailscaleHost.MINIMUM_TAILSCALE_VERSION} or newer.`, - }); - } } return { state, From 13e6fc05d8902bea752c344a8082147f00544a38 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:37:45 -0700 Subject: [PATCH 080/114] fix(tailscale): require-grant reports a failed second read, and refusal steps promise no retry the add form skips `t3 tailscale require-grant` words a failed second status read like the first, with the setting state, and says owner devices sign in without a grant only with Tailscale sign-in on. The policy-unknown and rate-limited refusals say saved machines retry, not that every attempt does. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/tailscale.ts | 8 ++++++-- packages/client-runtime/src/connection/tailnet.ts | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 762a7fa5f3576..ea2bf03fc8100 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -116,7 +116,11 @@ const readPolicyGrantedPeers = Effect.gen(function* () { // 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 after = yield* readTailscaleStatus("active"); + const read2 = yield* Effect.result(readTailscaleStatus("active")); + if (Result.isFailure(read2)) { + return { granted: [], unreadable: [], ...facts, blocker: readFailure(read2.failure, platform) }; + } + const after = read2.success; return after.selfNodeId !== status.selfNodeId || (scan.granted.length === 0 && peersJoined(status, after)) ? ("changed" as const) @@ -211,7 +215,7 @@ const requireGrantCommand = Command.make("require-grant", { ] : [ "Grant required: no. Your tailnet policy grants T3 Code here to no device,", - "so your own untagged devices sign in without one.", + "so, with Tailscale sign-in 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", ]; diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index c3e00d5df16c2..e1fa00d1e736f 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -246,7 +246,7 @@ export function tailnetRefusalVerdict( 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. T3 Code retries on its own.`, + `On ${host}, run \`t3 tailscale require-grant\` to see why. Saved machines retry on their own.`, grant, ], }; @@ -261,7 +261,7 @@ export function tailnetRefusalVerdict( case "rate_limited": return { headline: `${host} asked this device to slow down`, - steps: ["Wait a minute. T3 Code retries on its own."], + steps: ["Wait a minute, then try again. Saved machines retry on their own."], }; } } From cbf96259b561ab17e44d1c3dbb1103a4c0fe22b1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:40:03 -0700 Subject: [PATCH 081/114] fix(tailscale): every sign-in blocker leads health, and require-grant says only what this shell saw Userspace networking joins the issues listed right after the Tailscale state, as it stops sign-in too. A `tailscale status` read that fails in `t3 tailscale require-grant` is worded as this shell's read, since the server may read Tailscale fine, and no grant-scopes line follows it. The guide lists the IPv4 address among sign-in's needs. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/tailscale.ts | 10 ++++++---- apps/server/src/environment/TailscaleHealth.test.ts | 2 +- apps/server/src/environment/TailscaleHealth.ts | 12 ++++++------ docs/user/remote-access.md | 6 +++--- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index ea2bf03fc8100..6d15146ed9f22 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -105,7 +105,7 @@ const readPolicyGrantedPeers = Effect.gen(function* () { 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 }; + return { granted: [], unreadable: [], tagged: false, noNode: false, blocker, readFailed: true }; } const status = read.success; const facts = { @@ -118,7 +118,8 @@ const readPolicyGrantedPeers = Effect.gen(function* () { const scan = yield* policyGrantedPeers(status, readTailscaleWhois); const read2 = yield* Effect.result(readTailscaleStatus("active")); if (Result.isFailure(read2)) { - return { granted: [], unreadable: [], ...facts, blocker: readFailure(read2.failure, platform) }; + const blocker = readFailure(read2.failure, platform); + return { granted: [], unreadable: [], ...facts, blocker, readFailed: true }; } const after = read2.success; return after.selfNodeId !== status.selfNodeId || @@ -170,7 +171,8 @@ const requireGrantCommand = Command.make("require-grant", { const lines = scan !== null && scan.blocker !== null ? [ - `No device signs in here. ${scan.blocker}`, + // 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}.`, @@ -222,7 +224,7 @@ const requireGrantCommand = Command.make("require-grant", { yield* Console.log( [ ...lines, - ...(scan?.blocker == null + ...(scan !== null && scan.blocker === null ? [ "Devices with a grant get the scopes it names: read, standard, admin, or single scopes.", ] diff --git a/apps/server/src/environment/TailscaleHealth.test.ts b/apps/server/src/environment/TailscaleHealth.test.ts index 97cd3994ec6c5..be6c5c795c71b 100644 --- a/apps/server/src/environment/TailscaleHealth.test.ts +++ b/apps/server/src/environment/TailscaleHealth.test.ts @@ -127,7 +127,7 @@ describe("classifyTailscaleHealth", () => { userspaceNetworking: true, }), "running", - ["health_warning", "https_off", "magic_dns_off", "userspace_networking"], + ["userspace_networking", "health_warning", "https_off", "magic_dns_off"], ], [ "too old for sign-in", diff --git a/apps/server/src/environment/TailscaleHealth.ts b/apps/server/src/environment/TailscaleHealth.ts index 57fd1aabfdf5f..488b8564979c9 100644 --- a/apps/server/src/environment/TailscaleHealth.ts +++ b/apps/server/src/environment/TailscaleHealth.ts @@ -103,6 +103,12 @@ export function classifyTailscaleHealth( if (state === "running" && status.tailnetIpv4Addresses.length === 0) { issues.push({ cause: "no_ipv4", fix: NO_IPV4_FIX }); } + if (state === "running" && status.userspaceNetworking === true) { + issues.push({ + cause: "userspace_networking", + fix: "Run tailscaled with a TUN device, so T3 Code can see which device connects.", + }); + } // NaN when the key never expires, which fails both comparisons. const expiresAt = status.keyExpiry === null ? Number.NaN : Date.parse(status.keyExpiry); @@ -135,12 +141,6 @@ export function classifyTailscaleHealth( if (status.magicDnsEnabled === false) { issues.push({ cause: "magic_dns_off", fix: `Turn on MagicDNS at ${ADMIN_DNS_URL}.` }); } - if (status.userspaceNetworking === true) { - issues.push({ - cause: "userspace_networking", - fix: "Run tailscaled with a TUN device, so T3 Code can see which device connects.", - }); - } } return { state, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 89e22361527ab..322e3301ec10e 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -288,9 +288,9 @@ too: 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 does not work -with userspace networking, which is common in LXC and other containers; `t3 up` -tells you when it finds either. +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 From d2255bb27fa39804443c8e0736e662ff7d210d5a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:44:23 -0700 Subject: [PATCH 082/114] fix(tailscale): an unusable grant asks for the intended scope, and require-grant and host rows say more of what holds A refusal for a grant with no scope T3 Code knows no longer suggests a `standard` grant, which carries shell access; it asks the operator to fix the scopes. `t3 tailscale require-grant` judges sign-in blockers on its last status read, and says its policy scan is this shell's and how long the server takes to notice a grant change. Client host rows show userspace networking, as it stops sign-in. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 18 ++++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 6 ++++-- apps/server/src/cli/tailscale.ts | 13 +++++++++++-- .../src/connection/diagnosis.test.ts | 4 ++++ .../client-runtime/src/connection/diagnosis.ts | 1 + .../client-runtime/src/connection/tailnet.ts | 2 +- 6 files changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 19ef0e0717258..7c58e808ee2a7 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -682,6 +682,24 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { ), ); + 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()); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index c9087afc52bcc..bde51c0ccf67f 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -166,10 +166,12 @@ interface DeviceName { readonly user: string | null; } -/** Refusals that a tailnet policy grant for this device would turn into a sign-in. */ +/** + * 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", - "grant_unusable", "policy_unknown", "owner_unknown", "tagged_device", diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 6d15146ed9f22..b53a798c44f0d 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -122,8 +122,11 @@ const readPolicyGrantedPeers = Effect.gen(function* () { return { granted: [], unreadable: [], ...facts, blocker, readFailed: true }; } const after = read2.success; - return after.selfNodeId !== status.selfNodeId || - (scan.granted.length === 0 && peersJoined(status, after)) + 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 }; }).pipe(Effect.option, Effect.map(Option.getOrNull)); @@ -227,6 +230,12 @@ const requireGrantCommand = Command.make("require-grant", { ...(scan !== null && scan.blocker === null ? [ "Devices with a grant get the scopes it names: read, standard, admin, or single scopes.", + ...(since === null + ? [ + "This is this shell's scan. The server notices a first grant within a minute,", + "and the last grant's removal within five minutes.", + ] + : []), ] : []), ].join("\n"), diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index d007b10f0f4ec..870194bcf4889 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -342,6 +342,10 @@ describe("Tailscale facts in the diagnosis", () => { 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", + ); }); }); diff --git a/packages/client-runtime/src/connection/diagnosis.ts b/packages/client-runtime/src/connection/diagnosis.ts index 5c276e933ffba..742435477c889 100644 --- a/packages/client-runtime/src/connection/diagnosis.ts +++ b/packages/client-runtime/src/connection/diagnosis.ts @@ -364,6 +364,7 @@ const HOST_TAILSCALE_NOTICE: ReadonlySet = new Set([ "health_warning", "outdated", "no_ipv4", + "userspace_networking", ]); /** " (as of 2h ago)" for facts a minute old or more. */ diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index e1fa00d1e736f..85820922d8799 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -239,7 +239,7 @@ export function tailnetRefusalVerdict( 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}, set the grant's value to \`{ "scopes": ["standard"] }\`. The list takes "read", "standard", "admin", or single scopes such as "orchestration:read".`, + `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": From 387159e0c0c15e1c99c8a365ea32e080961612c3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:47:06 -0700 Subject: [PATCH 083/114] refactor(tailscale): health and the CLI share one list of what stops sign-in `signInIssues` lists what stops Tailscale sign-in on a running Tailscale, in one order and one wording, for health and for `t3 up` and `require-grant`. require-grant drops a branch that can no longer run. The refusals all say saved machines "retry", and the owner line reads plainly. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/tailscale.ts | 59 +++++++------------ .../server/src/environment/TailscaleHealth.ts | 41 +++++++------ .../client-runtime/src/connection/tailnet.ts | 2 +- 3 files changed, 47 insertions(+), 55 deletions(-) diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index b53a798c44f0d..909f8df573403 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -25,15 +25,11 @@ import { SERVICE_ENV_FILE } from "../cloud/serviceProtocol.ts"; import { peersJoined, policyGrantedPeers } from "../auth/tailnetGrants.ts"; import { formatTailscaleHealth, - NO_IPV4_FIX, readErrorFacts, readTailscaleHealth, + signInIssues, stateFix, } from "../environment/TailscaleHealth.ts"; -import { - isSupportedTailscaleVersion, - MINIMUM_TAILSCALE_VERSION, -} from "../tailscale/TailscaleHost.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; const encodeStatusJson = Schema.encodeEffect(fromJsonStringPretty(ServerTailscaleStatus)); @@ -86,18 +82,13 @@ export const signInBlocker = ( ): string | null => { const state = tailscaleStateFromBackend(status.backendState); if (state !== "running") return issueLine({ cause: state, fix: stateFix(state, platform) }); - return status.userspaceNetworking === true - ? "Tailscale sign-in needs device addresses, and userspace networking hides them. Give the container /dev/net/tun and run tailscaled without --tun=userspace-networking." - : !isSupportedTailscaleVersion(status.version) - ? `Tailscale ${status.version ?? "(unknown version)"} is too old for Tailscale sign-in. Update to ${MINIMUM_TAILSCALE_VERSION} or newer.` - : status.tailnetIpv4Addresses.length === 0 - ? issueLine({ cause: "no_ipv4", fix: NO_IPV4_FIX }) - : null; + const [first] = signInIssues(status); + return first === undefined ? null : issueLine(first); }; /** - * Peers the tailnet policy grants T3 Code here, and whether this host is tagged. Null when - * Tailscale cannot say, and "changed" when the account or its devices changed during the + * 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* () { @@ -129,7 +120,7 @@ const readPolicyGrantedPeers = Effect.gen(function* () { return scan.granted.length === 0 && peersJoined(status, after) ? ("changed" as const) : { ...scan, ...facts }; -}).pipe(Effect.option, Effect.map(Option.getOrNull)); +}); /** Whether this tagged host has an owner, from `t3 up --owner` or this shell's environment. */ const hasOwner = Effect.fn("cli.tailscale.hasOwner")(function* ( @@ -196,34 +187,28 @@ const requireGrantCommand = Command.make("require-grant", { "changed during the check.", "Run this again.", ] - : scan === null + : read.noNode ? [ - "No device signs in here while Tailscale does not answer.", - "Grant required: not turned on.", + "Grant required: yes, because Tailscale names no node for this host.", + "Run `tailscale up` to sign in.", ] - : scan.noNode + : read.unreadable.length > 0 ? [ - "Grant required: yes, because Tailscale names no node for this host.", - "Run `tailscale up` to sign in.", + `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.", ] - : scan.unreadable.length > 0 + : read.tagged && !(yield* hasOwner(config.baseDir, config.tailscaleOwnerUserId)) ? [ - `Grant required: probably. Tailscale could not read ${scan.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.", + "Grant required: no, but this tagged host has no owner, so only granted", + "devices get in. Name its owner with: t3 up --owner ", ] - : scan.tagged && - !(yield* hasOwner(config.baseDir, config.tailscaleOwnerUserId)) - ? [ - "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,", - "so, with Tailscale sign-in 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", - ]; + : [ + "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, diff --git a/apps/server/src/environment/TailscaleHealth.ts b/apps/server/src/environment/TailscaleHealth.ts index 488b8564979c9..970b4115723d7 100644 --- a/apps/server/src/environment/TailscaleHealth.ts +++ b/apps/server/src/environment/TailscaleHealth.ts @@ -33,8 +33,29 @@ export type TailscaleStatusRead = Result.Result< TailscaleCommandError | TailscaleStatusParseError >; -export const NO_IPV4_FIX = - "Allow IPv4 for this machine in your tailnet policy. Tailscale sign-in needs it."; +/** 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"; @@ -94,21 +115,7 @@ export function classifyTailscaleHealth( 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" && !TailscaleHost.isSupportedTailscaleVersion(status.version)) { - issues.push({ - cause: "outdated", - fix: `Update Tailscale to ${TailscaleHost.MINIMUM_TAILSCALE_VERSION} or newer.`, - }); - } - if (state === "running" && status.tailnetIpv4Addresses.length === 0) { - issues.push({ cause: "no_ipv4", fix: NO_IPV4_FIX }); - } - if (state === "running" && status.userspaceNetworking === true) { - issues.push({ - cause: "userspace_networking", - fix: "Run tailscaled with a TUN device, so T3 Code can see which device connects.", - }); - } + 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); diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index 85820922d8799..1c767bf8e0cc3 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -183,7 +183,7 @@ export function tailnetRefusalVerdict( 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 reconnect on their own.", + "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.`, ], }; From 24bff7d29b31af3441cddd8d83f207074d8eb9cc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:50:49 -0700 Subject: [PATCH 084/114] fix(tailscale): require-grant reads tags from its last status, and states the slower grant delay Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/tailscale.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 909f8df573403..827e3bf110e49 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -119,7 +119,7 @@ const readPolicyGrantedPeers = Effect.gen(function* () { if (lateBlocker !== null) return { granted: [], unreadable: [], ...facts, blocker: lateBlocker }; return scan.granted.length === 0 && peersJoined(status, after) ? ("changed" as const) - : { ...scan, ...facts }; + : { ...scan, ...facts, tagged: after.selfTags.length > 0 }; }); /** Whether this tagged host has an owner, from `t3 up --owner` or this shell's environment. */ @@ -217,8 +217,8 @@ const requireGrantCommand = Command.make("require-grant", { "Devices with a grant get the scopes it names: read, standard, admin, or single scopes.", ...(since === null ? [ - "This is this shell's scan. The server notices a first grant within a minute,", - "and the last grant's removal within five minutes.", + "This is this shell's scan. The server notices a first grant within a minute", + "(five if it cannot read that device), and the last grant's removal within five.", ] : []), ] From 57e241bb2264fbf9afbe00fe6238e0814da011ab Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:52:49 -0700 Subject: [PATCH 085/114] fix(tailscale): require-grant's no-grant answer and delay line read cleanly, and the descriptor uses the shared sign-in checks Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/tailscale.ts | 5 +++-- apps/server/src/environment/ServerEnvironment.ts | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 827e3bf110e49..cf6de7a816ed6 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -204,7 +204,7 @@ const requireGrantCommand = Command.make("require-grant", { "devices get in. Name its owner with: t3 up --owner ", ] : [ - "Grant required: no. Your tailnet policy grants T3 Code here to no device,", + "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", @@ -218,7 +218,8 @@ const requireGrantCommand = Command.make("require-grant", { ...(since === null ? [ "This is this shell's scan. The server notices a first grant within a minute", - "(five if it cannot read that device), and the last grant's removal within five.", + "(five if it cannot read that device). After you remove the last grant, the", + "owner rule returns within five minutes.", ] : []), ] diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a2952354a2242..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"; @@ -25,6 +26,7 @@ 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", @@ -289,10 +291,8 @@ export const make = Effect.gen(function* () { ? tailscaleHost.status.pipe( Effect.map( (status) => - status.backendState === "Running" && - status.userspaceNetworking !== true && - TailscaleHost.isSupportedTailscaleVersion(status.version) && - status.tailnetIpv4Addresses.length > 0, + tailscaleStateFromBackend(status.backendState) === "running" && + signInIssues(status).length === 0, ), Effect.orElseSucceed(() => false), ) From f6649c4f810812e779165a6da884287398d618d2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:57:17 -0700 Subject: [PATCH 086/114] fix(tailscale): a host tagged during a policy scan does not admit its former owner A policy scan fails when the host became tagged or untagged between its two status reads, as that changes who owns the host, so an owner-rule sign-in judged by the earlier owner is refused `policy_unknown`. The CLI and the guide say the owner rule returns after the last grant's removal once the machine can read every device. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 18 ++++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 8 +++++++- apps/server/src/cli/tailscale.ts | 2 +- docs/user/remote-access.md | 3 ++- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 7c58e808ee2a7..d2e9acd3d1422 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1561,6 +1561,24 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }, ); + 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; diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index bde51c0ccf67f..40f40bfb19c35 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -286,7 +286,13 @@ export const make = Effect.gen(function* () { // 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; - if (after.selfNodeId !== account || (yield* Ref.get(switchSeen))) { + // 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)) || + (after.selfTags.length === 0) !== (status.selfTags.length === 0) + ) { return yield* Effect.fail(["this host"]); } if ( diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index cf6de7a816ed6..fc08de6162c98 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -219,7 +219,7 @@ const requireGrantCommand = Command.make("require-grant", { ? [ "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, the", - "owner rule returns within five minutes.", + "owner rule returns within five minutes, once it can read every device.", ] : []), ] diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 322e3301ec10e..094c428ab3caf 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -198,7 +198,8 @@ Who gets in: 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, the owner rule returns within five minutes. + the last grant, the owner rule returns within five minutes, once the machine + can read every device on your tailnet. - **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. From 9e88e9d77d989b2b57395ce15e89be23194af862 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:59:21 -0700 Subject: [PATCH 087/114] docs(tailscale): say "your own untagged devices", not "owner rule", and skip that line on an ownerless host Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/tailscale.ts | 16 +++++++++++++--- docs/user/remote-access.md | 4 ++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index fc08de6162c98..308574fee7342 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -161,6 +161,11 @@ const requireGrantCommand = Command.make("require-grant", { 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 @@ -198,7 +203,7 @@ const requireGrantCommand = Command.make("require-grant", { "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.", ] - : read.tagged && !(yield* hasOwner(config.baseDir, config.tailscaleOwnerUserId)) + : ownerless ? [ "Grant required: no, but this tagged host has no owner, so only granted", "devices get in. Name its owner with: t3 up --owner ", @@ -218,8 +223,13 @@ const requireGrantCommand = Command.make("require-grant", { ...(since === null ? [ "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, the", - "owner rule returns within five minutes, once it can read every device.", + "(five if it cannot read that device).", + ...(ownerless + ? [] + : [ + "After you remove the last grant, your own untagged devices can sign in again", + "within five minutes, once the server can read every device.", + ]), ] : []), ] diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 094c428ab3caf..02745f0d4be10 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -198,8 +198,8 @@ Who gets in: 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, the owner rule returns within five minutes, once the machine - can read every device on your tailnet. + the last grant, your own untagged devices can sign in again within five + minutes, once the machine can read every device on your tailnet. - **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. From 3442e415f8f7aac39f7d44d09a7cf8a1afc02e8c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:03:39 -0800 Subject: [PATCH 088/114] fix(tailscale): require-grant on an ownerless host with grants says grants decide either way Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/tailscale.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 308574fee7342..6e6399acb53d9 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -184,7 +184,9 @@ const requireGrantCommand = Command.make("require-grant", { : granted.length > 0 ? [ `Grant required: yes, because your tailnet policy grants ${TAILNET_APP_CAPABILITY} here to: ${granted.join(", ")}.`, - "Remove those grants to let your own untagged devices in without one.", + ownerless + ? "This tagged host has no owner, so only granted devices get in either way." + : "Remove those grants to let your own untagged devices in without one.", ] : read === "changed" ? [ From 30c58e0335ed74627200af3963572111a5a10372 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:06:31 -0800 Subject: [PATCH 089/114] fix(tailscale): a device tagged during its sign-in is refused, and setup stops suggesting require-grant off on an ownerless host Sign-in refuses a caller whose fresh status disagrees with its lookup on whether it is tagged, so a device tagged meanwhile is not judged as the owner's untagged device. The policy scan's final check compares the host's owner itself before and after. `t3 up` on a tagged host with no owner no longer suggests `require-grant off`, and `require-grant` skips the untagged-devices line where Tailscale names no node. The test host lists a caller's tags, as a real one does. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 16 ++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 7 ++++++- apps/server/src/cli/tailscale.ts | 2 +- apps/server/src/cli/up.ts | 7 ++++++- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index d2e9acd3d1422..209acab481f88 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -135,6 +135,7 @@ const withCaller = (tailnet: Tailnet, status: JsonObject): JsonObject => { ID: node.StableID, TailscaleIPs: addresses.map((address) => address.replace(/\/\d+$/u, "")), UserID: node.User, + ...(node.Tags === undefined ? {} : { Tags: node.Tags }), }, }, }; @@ -412,6 +413,20 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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 node key expired", status: hostStatus({ @@ -912,6 +927,7 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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* () { diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 40f40bfb19c35..69d839cbe6c50 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -291,7 +291,8 @@ export const make = Effect.gen(function* () { if ( after.selfNodeId !== account || (yield* Ref.get(switchSeen)) || - (after.selfTags.length === 0) !== (status.selfTags.length === 0) + TailscaleHost.tailnetOwnerUserId(after, config.tailscaleOwnerUserId) !== + TailscaleHost.tailnetOwnerUserId(status, config.tailscaleOwnerUserId) ) { return yield* Effect.fail(["this host"]); } @@ -474,6 +475,10 @@ export const make = Effect.gen(function* () { if (peer === undefined) return yield* refuse("tailscale_unavailable"); // Off the tailnet until it logs in to Tailscale again, which only the device can do. if (peer.keyExpired) return yield* refuse("not_direct_tailnet", "node key expired"); + // Tagged or untagged since its lookup, the device would be judged as it no longer is. + if ((peer.tags.length === 0) !== (node.tags.length === 0)) { + return yield* refuse("tailscale_unavailable", "device tags changed during sign-in"); + } } const decision = yield* decide(node, status); // The policy names this device, so it decides here now, even when the grant is refused diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 6e6399acb53d9..d6821f3dadee6 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -226,7 +226,7 @@ const requireGrantCommand = Command.make("require-grant", { ? [ "This is this shell's scan. The server notices a first grant within a minute", "(five if it cannot read that device).", - ...(ownerless + ...(ownerless || scan.noNode ? [] : [ "After you remove the last grant, your own untagged devices can sign in again", diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index af84dd8d14ae5..29e4136394c35 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -492,7 +492,12 @@ const formatTailscale = ( ? [ "Add this grant to your tailnet policy:", indent(tailnetGrantSnippet(src, dst, port)), - "Let your untagged devices in without a grant while your policy grants no device here: `t3 tailscale require-grant off`.", + // 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.", From d461ca5cebd667411645051776b9736c6eaec04b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:11:03 -0800 Subject: [PATCH 090/114] fix(tailscale): sign-in compares the caller's full tag set, and checks it again after an owner-rule scan The caller check compares the device's tags in the status with its lookup's as sets, so a tag swap (say admin to reader) is refused rather than keeping the older grant. An owner-rule decision waits on a policy scan, so sign-in reads the status again after it and checks the caller once more before issuing a code. `require-grant` on a host Tailscale names no node for says only granted devices get in. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 32 ++++++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 35 +++++++++++++++------- apps/server/src/cli/tailscale.ts | 4 ++- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 209acab481f88..2ff4896625a51 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -427,6 +427,22 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }), 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 node key expired", status: hostStatus({ @@ -1577,6 +1593,22 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }, ); + 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("a host tagged during a scan does not admit its former owner", () => { const tailnet = withLaptop(); return Effect.gen(function* () { diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 69d839cbe6c50..55ea29ebd7c14 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -194,6 +194,9 @@ const label = (node: TailscaleWhois, status: TailscaleStatus): string | null => 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, @@ -464,22 +467,24 @@ export const make = Effect.gen(function* () { ); const refusal = hostRefusal(status); if (refusal !== null) return yield* refuse(refusal); - // The caller must be the peer this status names at its address, as the recheck asks, so - // a whois answered before an account switch is not judged by the new account. - if (node.stableNodeId !== status.selfNodeId) { - const peer = status.peers.find( + // 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 yield* refuse("tailscale_unavailable"); + 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 yield* refuse("not_direct_tailnet", "node key expired"); - // Tagged or untagged since its lookup, the device would be judged as it no longer is. - if ((peer.tags.length === 0) !== (node.tags.length === 0)) { - return yield* refuse("tailscale_unavailable", "device tags changed during sign-in"); - } - } + if (peer.keyExpired) return refuse("not_direct_tailnet", "node key expired"); + return sameTags(peer.tags, node.tags) + ? Effect.void + : refuse("tailscale_unavailable", "device tags 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. @@ -510,6 +515,14 @@ export const make = Effect.gen(function* () { ); } const { scopes, via } = decision; + // The owner rule waited on a policy scan, during which the device may have changed. + if (via === "owner") { + yield* checkCaller( + yield* host.freshStatus.pipe( + Effect.catch((cause) => refuse("tailscale_unavailable", cause)), + ), + ); + } knownUsers.set(node.stableNodeId, node.loginName ?? node.userId); const deviceLabel = label(node, status); diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index d6821f3dadee6..ba452aef58340 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -186,7 +186,9 @@ const requireGrantCommand = Command.make("require-grant", { `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." - : "Remove those grants to let your own untagged devices in without one.", + : read !== "changed" && read.noNode + ? "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" ? [ From d47e910d9c6abc8928974ab13c40bec32c8584f3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:12:49 -0800 Subject: [PATCH 091/114] docs(tailscale): grant delays print only where a grant change moves anyone, and the guide names the ownerless case Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/tailscale.ts | 13 +++++-------- docs/user/remote-access.md | 2 +- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index ba452aef58340..861a16337f8f8 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -224,16 +224,13 @@ const requireGrantCommand = Command.make("require-grant", { ...(scan !== null && scan.blocker === null ? [ "Devices with a grant get the scopes it names: read, standard, admin, or single scopes.", - ...(since === null + // Where only grants decide, a grant change moves no one, 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).", - ...(ownerless || scan.noNode - ? [] - : [ - "After you remove the last grant, your own untagged devices can sign in again", - "within five minutes, once the server can read every device.", - ]), + "(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.", ] : []), ] diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 02745f0d4be10..f528f86313eed 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -513,7 +513,7 @@ 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. Command-line +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 From fdc45ba9ce417437c46954a0d45596e1f9cbfa4b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:17:46 -0800 Subject: [PATCH 092/114] fix(tailscale): an owner-rule sign-in rechecks its host and the require-grant setting after its scan, and the caller's user After an owner-rule decision's policy scan, sign-in refuses when the host's account or owner changed (`policy_unknown`) or require-grant was turned on (`no_grant`), as well as when the caller changed. The caller check also compares its user between the lookup and the status. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 40 ++++++++++++++++++++++ apps/server/src/auth/TailnetSignIn.ts | 25 ++++++++++---- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 2ff4896625a51..035a882bc6917 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -443,6 +443,15 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { 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({ @@ -1609,6 +1618,37 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).pipe(Effect.provide(layerTest(tailnet))); }); + it.effect.each([{ change: "require-grant turned on" }, { change: "the host tagged" }])( + "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 owner-rule scan's two. + 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"] }, + }); + } + }; + expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("policy_unknown"); + } else { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + tailnet.holdFreshRead = { at: last, started, release }; + const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* requireGrantOn; + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(phone)).toBe("no_grant"); + } + }).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* () { diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 55ea29ebd7c14..92f0d14aa87a1 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -480,9 +480,10 @@ export const make = Effect.gen(function* () { 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) + return sameTags(peer.tags, node.tags) && + (peer.userId === null || peer.userId === node.userId) ? Effect.void - : refuse("tailscale_unavailable", "device tags changed during sign-in"); + : refuse("tailscale_unavailable", "device changed during sign-in"); }; yield* checkCaller(status); const decision = yield* decide(node, status); @@ -515,13 +516,23 @@ export const make = Effect.gen(function* () { ); } const { scopes, via } = decision; - // The owner rule waited on a policy scan, during which the device may have changed. + // The owner rule waited on a policy scan, during which the device, this host's owner, or + // the require-grant setting may have changed. if (via === "owner") { - yield* checkCaller( - yield* host.freshStatus.pipe( - Effect.catch((cause) => refuse("tailscale_unavailable", cause)), - ), + const final = yield* host.freshStatus.pipe( + Effect.catch((cause) => refuse("tailscale_unavailable", cause)), ); + yield* checkCaller(final); + if ( + final.selfNodeId !== status.selfNodeId || + TailscaleHost.tailnetOwnerUserId(final, config.tailscaleOwnerUserId) !== + TailscaleHost.tailnetOwnerUserId(status, config.tailscaleOwnerUserId) + ) { + return yield* refuse("policy_unknown", "this host changed during sign-in"); + } + if (yield* fs.exists(requireGrantPath).pipe(Effect.orElseSucceed(() => true))) { + return yield* refuse("no_grant"); + } } knownUsers.set(node.stableNodeId, node.loginName ?? node.userId); From 938864069fcdb46da239273f6ea5bd5d4afa8f4e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:19:44 -0800 Subject: [PATCH 093/114] fix(tailscale): the post-scan read also runs the host checks, and two comments say what holds Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.ts | 6 ++++-- apps/server/src/cli/tailscale.ts | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 92f0d14aa87a1..388cf3069b461 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -516,12 +516,14 @@ export const make = Effect.gen(function* () { ); } const { scopes, via } = decision; - // The owner rule waited on a policy scan, during which the device, this host's owner, or - // the require-grant setting may have changed. + // 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); yield* checkCaller(final); if ( final.selfNodeId !== status.selfNodeId || diff --git a/apps/server/src/cli/tailscale.ts b/apps/server/src/cli/tailscale.ts index 861a16337f8f8..ae280129197e7 100644 --- a/apps/server/src/cli/tailscale.ts +++ b/apps/server/src/cli/tailscale.ts @@ -186,7 +186,7 @@ const requireGrantCommand = Command.make("require-grant", { `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." - : read !== "changed" && read.noNode + : 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.", ] @@ -224,7 +224,7 @@ const requireGrantCommand = Command.make("require-grant", { ...(scan !== null && scan.blocker === null ? [ "Devices with a grant get the scopes it names: read, standard, admin, or single scopes.", - // Where only grants decide, a grant change moves no one, so no delay applies. + // 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", From fe6e701d30af191d27fbdd21b323fc04fa68f2c5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:23:41 -0800 Subject: [PATCH 094/114] fix(tailscale): a sign-in refused by require-grant mid-scan carries the grant to add, and the guide notes grants are seen through their devices Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 8 ++++++-- apps/server/src/auth/TailnetSignIn.ts | 12 ++++++------ docs/user/remote-access.md | 4 +++- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 035a882bc6917..d00aeeca40505 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1639,11 +1639,15 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { const started = yield* Deferred.make(); const release = yield* Deferred.make(); tailnet.holdFreshRead = { at: last, started, release }; - const phone = yield* refusalOf(signIn(makeDpopKey())).pipe(Effect.forkChild); + const phone = yield* Effect.flip(signIn(makeDpopKey())).pipe(Effect.forkChild); yield* Deferred.await(started); yield* requireGrantOn; yield* Deferred.succeed(release, undefined); - expect(yield* Fiber.join(phone)).toBe("no_grant"); + // Refused as any no-grant refusal is, with the grant to add. + const error = yield* Fiber.join(phone); + expect( + isRefusal(error) ? [error.reason, error.suggestedGrant !== undefined] : error, + ).toEqual(["no_grant", true]); } }).pipe(Effect.provide(layerTest(tailnet))); }, diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 388cf3069b461..6fc75a445b9f1 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -500,11 +500,11 @@ export const make = Effect.gen(function* () { .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"); - if (decision._tag === "Refused") { - return yield* refuse( - decision.reason, + const refuseWithGrant = (reason: TailnetSignInRefusedReason) => + refuse( + reason, undefined, - GRANT_FIXES.has(decision.reason) + GRANT_FIXES.has(reason) ? // This device on this machine only: a login or tag would cover others too. tailnetGrantSnippet( remoteAddress, @@ -514,7 +514,7 @@ export const make = Effect.gen(function* () { ) : 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. @@ -533,7 +533,7 @@ export const make = Effect.gen(function* () { return yield* refuse("policy_unknown", "this host changed during sign-in"); } if (yield* fs.exists(requireGrantPath).pipe(Effect.orElseSucceed(() => true))) { - return yield* refuse("no_grant"); + return yield* refuseWithGrant("no_grant"); } } diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index f528f86313eed..5ec3dca14ea08 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -199,7 +199,9 @@ Who gets in: 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. + 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 them 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. From 2d96159e2615920fb6509ab3816596f15db45ed6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:25:42 -0800 Subject: [PATCH 095/114] fix(tailscale): a host change found after the scan refuses with the grant to add, and a test comment counts reads right Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 2 +- apps/server/src/auth/TailnetSignIn.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index d00aeeca40505..5209eca48f5c2 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1623,7 +1623,7 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { ({ change }) => { const tailnet = withLaptop(); return Effect.gen(function* () { - // The third read is sign-in's last, after its owner-rule scan's two. + // 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) => { diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 6fc75a445b9f1..81b1e2cf7d372 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -500,10 +500,10 @@ export const make = Effect.gen(function* () { .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) => + const refuseWithGrant = (reason: TailnetSignInRefusedReason, cause?: unknown) => refuse( reason, - undefined, + cause, GRANT_FIXES.has(reason) ? // This device on this machine only: a login or tag would cover others too. tailnetGrantSnippet( @@ -530,7 +530,7 @@ export const make = Effect.gen(function* () { TailscaleHost.tailnetOwnerUserId(final, config.tailscaleOwnerUserId) !== TailscaleHost.tailnetOwnerUserId(status, config.tailscaleOwnerUserId) ) { - return yield* refuse("policy_unknown", "this host changed during sign-in"); + 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"); From 480ea6a66b47ba8f1f18af325b38ca97c9a476ca Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:31:33 -0800 Subject: [PATCH 096/114] fix(tailscale): a block landing during the post-scan read refuses as blocked, and the guide says who require-grant keeps out Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 5 ++++- apps/server/src/auth/TailnetSignIn.ts | 2 ++ docs/user/remote-access.md | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index 5209eca48f5c2..c68df2277e64d 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1634,7 +1634,10 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }); } }; - expect(yield* refusalOf(signIn(makeDpopKey()))).toBe("policy_unknown"); + 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(); diff --git a/apps/server/src/auth/TailnetSignIn.ts b/apps/server/src/auth/TailnetSignIn.ts index 81b1e2cf7d372..1391604159541 100644 --- a/apps/server/src/auth/TailnetSignIn.ts +++ b/apps/server/src/auth/TailnetSignIn.ts @@ -524,6 +524,8 @@ export const make = Effect.gen(function* () { ); 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 || diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 5ec3dca14ea08..0239532db3f80 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -201,7 +201,8 @@ Who gets in: 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 them out. + 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. From aa37dd2cb21611ec5a5a8759173fb4f5d758a010 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:33:22 -0800 Subject: [PATCH 097/114] docs(tailscale): require-grant's help says pairing codes still work, and the guide covers another user's machine Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.ts | 2 +- docs/user/remote-access.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 29e4136394c35..683ccb0bd96cb 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -954,7 +954,7 @@ export const upCommand = Command.make("up", { { command: "t3 up --no-lan", description: "Stop LAN access." }, { command: "t3 up --tailscale --no-connect --no-lan --require-grant", - description: "Allow only Tailscale devices your policy grants.", + description: "Require a grant for Tailscale sign-in. Pairing codes still work.", }, { command: "t3 up --tailscale --owner you@example.com", diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 0239532db3f80..5673cfc17f83e 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -245,7 +245,8 @@ 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. + sign-in and adds them. It skips another user's untagged machine, 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 From d377e08ae2ceefc1e628e890972ad0c64da319c6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:38:23 -0800 Subject: [PATCH 098/114] docs(tailscale): block and unblock name who can manage access, and t3 up says discovery covers your own devices Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.ts | 4 +++- docs/user/remote-access.md | 6 +++--- packages/client-runtime/src/connection/diagnosis.test.ts | 2 +- packages/client-runtime/src/connection/tailnet.ts | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 683ccb0bd96cb..87a9ed54f7fc0 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -485,7 +485,9 @@ const formatTailscale = ( ...(process.stdout.isTTY ? [renderTerminalQrCode(directUrl)] : []), // Other devices look for machines only on the default port. ...(port === ServerConfig.DEFAULT_PORT - ? ["Tailnet devices find this machine automatically."] + ? [ + "Your own tailnet devices find this machine automatically; others add it by the address above.", + ] : []), "", ...(requireGrant diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 5673cfc17f83e..6e7b532b37cf0 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -263,9 +263,9 @@ 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 any -device connected to that machine, and **Block** it under **Tailscale devices on -**. Its sessions there end now, and **Unblock** undoes it. A browser +To cut off one device on one machine, open **Settings → Environments** on a +device that can manage access there (your own devices, or one whose grant +includes `admin`), and **Block** it under **Tailscale devices on **. Its sessions there end now, and **Unblock** undoes it. 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. diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index 870194bcf4889..64747d48ecbd1 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -223,7 +223,7 @@ describe("presentConnectionDiagnosis", () => { expect(refused("blocked")).toMatchObject({ headline: "Mac mini blocked this device", steps: [ - "On another device connected to Mac mini, open Settings > Environments and unblock this device.", + "On another device that can manage access on Mac mini (one of yours, or one granted admin), open Settings > Environments and unblock this device.", "Or run `t3 pair` on Mac mini, pair this device with the code, then unblock it.", ], moreInfoUrl: expect.stringMatching(/#tailscale-troubleshooting$/), diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index 1c767bf8e0cc3..3330b33d473c7 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -254,7 +254,7 @@ export function tailnetRefusalVerdict( return { headline: `${host} blocked this device`, steps: [ - `On another device connected to ${host}, open Settings > Environments and unblock this device.`, + `On another device that can manage access on ${host} (one of yours, or one granted admin), open Settings > Environments and unblock this device.`, `Or run \`t3 pair\` on ${host}, pair this device with the code, then unblock it.`, ], }; From 8ba8365d41dcb28505f0da37ff7b59c883e9706a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:40:53 -0800 Subject: [PATCH 099/114] test(tailscale): a block landing after an owner-rule scan answers blocked, and the guide names shared-in machines Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/TailnetSignIn.test.ts | 77 +++++++++++----------- docs/user/remote-access.md | 5 +- 2 files changed, 43 insertions(+), 39 deletions(-) diff --git a/apps/server/src/auth/TailnetSignIn.test.ts b/apps/server/src/auth/TailnetSignIn.test.ts index c68df2277e64d..2f07f58e173d4 100644 --- a/apps/server/src/auth/TailnetSignIn.test.ts +++ b/apps/server/src/auth/TailnetSignIn.test.ts @@ -1618,43 +1618,46 @@ it.layer(NodeServices.layer)("TailnetSignIn", (it) => { }).pipe(Effect.provide(layerTest(tailnet))); }); - it.effect.each([{ change: "require-grant turned on" }, { change: "the host tagged" }])( - "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; - yield* Deferred.succeed(release, undefined); - // Refused as any no-grant refusal is, with the grant to add. - const error = yield* Fiber.join(phone); - expect( - isRefusal(error) ? [error.reason, error.suggestedGrant !== undefined] : error, - ).toEqual(["no_grant", true]); - } - }).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(); diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 6e7b532b37cf0..eaf312f033634 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -245,8 +245,9 @@ 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, so if a grant - lets you into one, type its name in **Add environment**. + 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 From bb402fb0df8095884eff0d9f43d4b04ca92c230b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:45:11 -0800 Subject: [PATCH 100/114] docs(tailscale): name who really manages access, and t3 up's discovery line fits tagged hosts Blocking and unblocking need access-management scope, which your own devices hold only while the policy grants nothing; the guide and the blocked refusal now name a device paired with a code, one granted admin, or yours while nothing is granted. On a tagged host, `t3 up` says tailnet devices find it, as discovery shows tagged hosts to everyone. Co-Authored-By: Claude Opus 5.5 --- docs/user/remote-access.md | 5 +++-- packages/client-runtime/src/connection/diagnosis.test.ts | 2 +- packages/client-runtime/src/connection/tailnet.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index eaf312f033634..f68600cb6ecf6 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -265,8 +265,9 @@ 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 (your own devices, or one whose grant -includes `admin`), and **Block** it under **Tailscale devices on **. Its sessions there end now, and **Unblock** undoes it. A browser +device that can manage access there (one paired with a code, one whose grant +includes `admin`, or your own devices while your policy grants none), and +**Block** it under **Tailscale devices on **. Its sessions there end now, and **Unblock** undoes it. 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. diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index 64747d48ecbd1..a62c4dc49cf00 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -223,7 +223,7 @@ describe("presentConnectionDiagnosis", () => { expect(refused("blocked")).toMatchObject({ headline: "Mac mini blocked this device", steps: [ - "On another device that can manage access on Mac mini (one of yours, or one granted admin), open Settings > Environments and unblock this device.", + "On another device that can manage access on Mac mini (one paired with a code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.", "Or run `t3 pair` on Mac mini, pair this device with the code, then unblock it.", ], moreInfoUrl: expect.stringMatching(/#tailscale-troubleshooting$/), diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index 3330b33d473c7..b853f6b84aa1d 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -254,7 +254,7 @@ export function tailnetRefusalVerdict( return { headline: `${host} blocked this device`, steps: [ - `On another device that can manage access on ${host} (one of yours, or one granted admin), open Settings > Environments and unblock this device.`, + `On another device that can manage access on ${host} (one paired with a code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.`, `Or run \`t3 pair\` on ${host}, pair this device with the code, then unblock it.`, ], }; From ac064e249863696065459f668f246bcbf6fbda37 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:46:51 -0800 Subject: [PATCH 101/114] fix(cli): t3 up's discovery line fits a tagged host, which every tailnet device finds Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 87a9ed54f7fc0..3e86937bec1a0 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -486,7 +486,10 @@ const formatTailscale = ( // Other devices look for machines only on the default port. ...(port === ServerConfig.DEFAULT_PORT ? [ - "Your own tailnet devices find this machine automatically; others add it by the address above.", + // Discovery shows a tagged host to everyone, an untagged one only to its user. + tagged + ? "Tailnet devices find this machine automatically." + : "Your own tailnet devices find this machine automatically; others add it by the address above.", ] : []), "", From a593274dac412dd95a427afbefd577b3078ad61c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:50:23 -0800 Subject: [PATCH 102/114] fix(connection): a pairing code to a one-word name keeps HTTPS, a QR on port 80 keeps its port, and t3 up says phones add it by address Typing a one-word name with a pairing code sent the code to `http://:3773`, where `main` used HTTPS; a one-word name could resolve on a LAN, so with a code only a 100.x address or a MagicDNS name takes the tailnet address. The mobile pairing parser keeps a typed `:80`, which URL drops, so a `t3 up` QR on port 80 is not read as 3773. `t3 up` says desktops find the machine and a phone adds it by the address shown. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/connection/pairing.test.ts | 5 +++++ apps/mobile/src/features/connection/pairing.ts | 5 ++++- apps/server/src/cli/up.ts | 4 ++-- packages/client-runtime/src/connection/onboarding.test.ts | 6 ++++++ packages/client-runtime/src/connection/onboarding.ts | 7 +++++-- 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 938f844c1d606..19fb9105fcf26 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -37,6 +37,11 @@ describe("parsePairingUrl", () => { 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", () => { 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/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 3e86937bec1a0..a95104c2d7621 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -488,8 +488,8 @@ const formatTailscale = ( ? [ // Discovery shows a tagged host to everyone, an untagged one only to its user. tagged - ? "Tailnet devices find this machine automatically." - : "Your own tailnet devices find this machine automatically; others add it by the address above.", + ? "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.", ] : []), "", diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index b93c74801ce83..a93bbeb17a615 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -323,6 +323,12 @@ describe("connection onboarding", () => { pairingCode: "pairing-token", }).pipe(Effect.provide(layerPairingOnboarding())); expect(tailnet.profile.httpBaseUrl).toBe("http://100.88.1.2:3773/"); + // A one-word name could be a LAN host, so the code goes over HTTPS. + 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"); diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index a877e8c152d46..7a77670aed8d3 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -117,8 +117,11 @@ export class ConnectionOnboarding extends Context.Service< const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.resolvePairingTarget")( function* (input: PairingConnectionInput) { - // A bare tailnet name or 100.x address means T3 Code's port, with a code as without one. - const tailnetHost = input.host === undefined ? null : tailnetSignInBaseUrl(input.host); + // A 100.x address or a MagicDNS name means T3 Code's port on the tailnet. A one-word name + // with a code keeps the HTTPS default: it could resolve on a LAN, and the code is a secret. + const tailnetUrl = input.host === undefined ? null : tailnetSignInBaseUrl(input.host); + const tailnetHost = + tailnetUrl !== null && new URL(tailnetUrl).hostname.includes(".") ? tailnetUrl : null; return yield* Effect.try({ try: () => resolveRemotePairingTarget(tailnetHost === null ? input : { ...input, host: tailnetHost }), From 145cc6d1afa6cb90476000d4189bfc5e9c6f04e3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:54:47 -0800 Subject: [PATCH 103/114] fix(connection): an address with a pairing code resolves as on main, so the code keeps HTTPS This PR had rewritten a bare 100.x address, MagicDNS name, or one-word name to T3 Code's plain-HTTP tailnet port even with a pairing code; none of those proves the client is on Tailscale, and a pasted port-80 link lost its port that way. With a code, the address now resolves exactly as on `main`; an address alone still signs in through Tailscale. The "does not offer Tailscale sign-in" prompt asks for a pairing link, which carries the port, instead of a bare code. Unblock guidance names the `access:write` scope. Co-Authored-By: Claude Opus 5.5 --- .../src/state/use-remote-environment-registry.ts | 3 ++- .../src/components/settings/AddEnvironmentDialog.tsx | 4 +++- docs/user/remote-access.md | 5 +++-- .../client-runtime/src/connection/diagnosis.test.ts | 2 +- .../client-runtime/src/connection/onboarding.test.ts | 4 ++-- packages/client-runtime/src/connection/onboarding.ts | 10 +++------- packages/client-runtime/src/connection/tailnet.ts | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 17c1e1b18eac8..b612c617d6af5 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -100,7 +100,8 @@ export async function signInOverTailnet( if (Option.isNone(result.value)) { return { ok: false, - error: "That machine does not offer Tailscale sign-in. Enter its pairing code.", + error: + "That machine does not offer Tailscale sign-in. Paste a pairing link from it instead.", }; } appAtomRegistry.set(connectionPairingUrlAtom, ""); diff --git a/apps/web/src/components/settings/AddEnvironmentDialog.tsx b/apps/web/src/components/settings/AddEnvironmentDialog.tsx index e67246da9f8b3..fc05e00ef3282 100644 --- a/apps/web/src/components/settings/AddEnvironmentDialog.tsx +++ b/apps/web/src/components/settings/AddEnvironmentDialog.tsx @@ -229,7 +229,9 @@ export function AddEnvironmentDialog({ onErrorChange(error instanceof Error ? error.message : "Failed to add backend."); } if (result._tag === "Success") { - onErrorChange("That machine does not offer Tailscale sign-in. Enter its pairing code."); + onErrorChange( + "That machine does not offer Tailscale sign-in. Paste a pairing link from it instead.", + ); } setIsAddingSavedBackend(false); return; diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index f68600cb6ecf6..cefc91cfed1f5 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -265,8 +265,9 @@ 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 (one paired with a code, one whose grant -includes `admin`, or your own devices while your policy grants none), and +device that can manage access there (its scopes include `access:write`: one +paired with a full code, one whose grant includes `admin`, or your own devices +while your policy grants none), and **Block** it under **Tailscale devices on **. Its sessions there end now, and **Unblock** undoes it. 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 diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index a62c4dc49cf00..0362446f0050b 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -223,7 +223,7 @@ describe("presentConnectionDiagnosis", () => { expect(refused("blocked")).toMatchObject({ headline: "Mac mini blocked this device", steps: [ - "On another device that can manage access on Mac mini (one paired with a code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.", + "On another device on Mac mini whose access includes managing access (the access:write scope: one paired with a full code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.", "Or run `t3 pair` on Mac mini, pair this device with the code, then unblock it.", ], moreInfoUrl: expect.stringMatching(/#tailscale-troubleshooting$/), diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index a93bbeb17a615..2c7f03f405a1e 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -322,8 +322,8 @@ describe("connection onboarding", () => { host: "100.88.1.2", pairingCode: "pairing-token", }).pipe(Effect.provide(layerPairingOnboarding())); - expect(tailnet.profile.httpBaseUrl).toBe("http://100.88.1.2:3773/"); - // A one-word name could be a LAN host, so the code goes over HTTPS. + // 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", diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 7a77670aed8d3..4a7dbca2c7346 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -117,14 +117,10 @@ export class ConnectionOnboarding extends Context.Service< const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.resolvePairingTarget")( function* (input: PairingConnectionInput) { - // A 100.x address or a MagicDNS name means T3 Code's port on the tailnet. A one-word name - // with a code keeps the HTTPS default: it could resolve on a LAN, and the code is a secret. - const tailnetUrl = input.host === undefined ? null : tailnetSignInBaseUrl(input.host); - const tailnetHost = - tailnetUrl !== null && new URL(tailnetUrl).hostname.includes(".") ? tailnetUrl : null; + // 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(tailnetHost === null ? input : { ...input, host: tailnetHost }), + try: () => resolveRemotePairingTarget(input), catch: (cause) => new ConnectionBlockedError({ reason: "configuration", diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index b853f6b84aa1d..c64c661d84b18 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -254,7 +254,7 @@ export function tailnetRefusalVerdict( return { headline: `${host} blocked this device`, steps: [ - `On another device that can manage access on ${host} (one paired with a code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.`, + `On another device on ${host} whose access includes managing access (the access:write scope: one paired with a full code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.`, `Or run \`t3 pair\` on ${host}, pair this device with the code, then unblock it.`, ], }; From c2c7c143eef70aae6c3137ea2e0416a55b7d101a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 01:59:55 -0800 Subject: [PATCH 104/114] docs(tailscale): unblock copy names a default t3 pair code and reads plainly Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/use-remote-environment-registry.ts | 2 +- docs/user/remote-access.md | 2 +- packages/client-runtime/src/connection/diagnosis.test.ts | 2 +- packages/client-runtime/src/connection/tailnet.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index b612c617d6af5..929bf005686e3 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -84,7 +84,7 @@ export async function pairEnvironment( /** * Signs in through Tailscale at a machine name, 100.x address, or `t3 up` QR - * URL. Asks for a pairing code when the machine does not offer it. + * URL. Asks for a pairing link when the machine does not offer it. */ export async function signInOverTailnet( address: string, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index cefc91cfed1f5..21e9536e7bf40 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -266,7 +266,7 @@ 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 (its scopes include `access:write`: one -paired with a full code, one whose grant includes `admin`, or your own devices +paired with a default `t3 pair` code, one whose grant includes `admin`, or your own devices while your policy grants none), and **Block** it under **Tailscale devices on **. Its sessions there end now, and **Unblock** undoes it. A browser preview or device screen it already has open keeps working, clicks and typing diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index 0362446f0050b..c401880f04d4c 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -223,7 +223,7 @@ describe("presentConnectionDiagnosis", () => { expect(refused("blocked")).toMatchObject({ headline: "Mac mini blocked this device", steps: [ - "On another device on Mac mini whose access includes managing access (the access:write scope: one paired with a full code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.", + "On another device with access:write on Mac mini (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.", "Or run `t3 pair` on Mac mini, pair this device with the code, then unblock it.", ], moreInfoUrl: expect.stringMatching(/#tailscale-troubleshooting$/), diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index c64c661d84b18..d47f3fe2349ef 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -254,7 +254,7 @@ export function tailnetRefusalVerdict( return { headline: `${host} blocked this device`, steps: [ - `On another device on ${host} whose access includes managing access (the access:write scope: one paired with a full code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.`, + `On another device with access:write on ${host} (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.`, `Or run \`t3 pair\` on ${host}, pair this device with the code, then unblock it.`, ], }; From ea09b4b048c85535951153975ffcfd82c708fbbf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:01:25 -0800 Subject: [PATCH 105/114] docs(tailscale): reflow the block paragraph Co-Authored-By: Claude Opus 5.5 --- docs/user/remote-access.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 21e9536e7bf40..5ed418eba3e28 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -266,12 +266,12 @@ 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 (its scopes include `access:write`: one -paired with a default `t3 pair` code, one whose grant includes `admin`, or your own devices -while your policy grants none), and -**Block** it under **Tailscale devices on **. Its sessions there end now, and **Unblock** undoes it. 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. +paired with a default `t3 pair` code, one whose grant includes `admin`, or your +own devices while your policy grants none), and **Block** it under **Tailscale +devices on **. Its sessions there end now, and **Unblock** undoes it. 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. From a058198bd240066c53a60320c071f267bf1248a3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:07:08 -0800 Subject: [PATCH 106/114] fix(connection): an address with and without its default port is the same machine, and unblock and status copy says what holds A route saved at `http://host/` and one signed in at `http://host:80/` now key the same, so adding the same machine again no longer reads as a different address. The blocked refusal names both scopes Settings needs, and its `t3 pair` recovery step says to revoke that pairing afterwards, so a full-access session does not outlive the unblock. With Tailscale sign-in on, `t3 status` says a pairing code guards LAN devices, not every peer. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.ts | 4 +++- .../client-runtime/src/connection/diagnosis.test.ts | 4 ++-- .../client-runtime/src/connection/routes.test.ts | 8 ++++++++ packages/client-runtime/src/connection/routes.ts | 12 +++++++++++- packages/client-runtime/src/connection/tailnet.ts | 4 ++-- 5 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index a95104c2d7621..8c9be88fa3148 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -231,7 +231,9 @@ const formatSetup = (setup: Setup) => { const notes = [ ...(setup.host === undefined && settings.lan ? [ - "Anyone on your LAN or tailnet can reach the port. A pairing code is the only lock.", + 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`.", ] : []), diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index c401880f04d4c..d2651c6b9dc9a 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -223,8 +223,8 @@ describe("presentConnectionDiagnosis", () => { expect(refused("blocked")).toMatchObject({ headline: "Mac mini blocked this device", steps: [ - "On another device with access:write on Mac mini (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.", - "Or run `t3 pair` on Mac mini, pair this device with the code, then unblock it.", + "On another device with access:write and orchestration:read on Mac mini (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.", + "Or run `t3 pair` on Mac mini, pair this device with the code, unblock it, then revoke that pairing in Settings > Environments so it signs in through Tailscale again.", ], moreInfoUrl: expect.stringMatching(/#tailscale-troubleshooting$/), }); diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts index 855a8df24140e..afebc69e71b5f 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]); diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index ed49dae63bf2d..f7bee2245693f 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -143,11 +143,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: diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index d47f3fe2349ef..4c6484581572e 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -254,8 +254,8 @@ export function tailnetRefusalVerdict( return { headline: `${host} blocked this device`, steps: [ - `On another device with access:write on ${host} (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.`, - `Or run \`t3 pair\` on ${host}, pair this device with the code, then unblock it.`, + `On another device with access:write and orchestration:read on ${host} (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.`, + `Or run \`t3 pair\` on ${host}, pair this device with the code, unblock it, then revoke that pairing in Settings > Environments so it signs in through Tailscale again.`, ], }; case "rate_limited": From b140cb33a05e5d7ca0226c50ae1f1edfaba0317d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:13:08 -0800 Subject: [PATCH 107/114] fix(connection): discovery treats an address with its default port as known, and recovery and lock copy say what holds Route discovery keys saved routes like the same-address check, so a host reported at `http://host/` is known when saved at `http://host:80/`. The blocked refusal's recovery step revokes the recovery pairing on the host with `t3 auth session`, as a device cannot revoke its own session. `t3 up --help` and the guide note Tailscale sign-in beside "the only lock", and the guide names `orchestration:read` with `access:write`. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/up.ts | 2 +- docs/user/remote-access.md | 5 +++-- .../client-runtime/src/connection/diagnosis.test.ts | 2 +- packages/client-runtime/src/connection/routes.test.ts | 11 +++++++++++ packages/client-runtime/src/connection/routes.ts | 2 +- packages/client-runtime/src/connection/tailnet.ts | 2 +- 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 8c9be88fa3148..2c9617408d015 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -923,7 +923,7 @@ export const upCommand = Command.make("up", { ), lan: routeFlag( "lan", - "Allow LAN and tailnet connections. Anyone there can reach the port; a pairing code is the only lock. Turn off with --no-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."), diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 5ed418eba3e28..33faa39efa7c5 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -83,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: @@ -265,7 +266,7 @@ 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 (its scopes include `access:write`: one +device that can manage access there (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 **Block** it under **Tailscale devices on **. Its sessions there end now, and **Unblock** undoes it. A diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index d2651c6b9dc9a..9271f868b97ff 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -224,7 +224,7 @@ describe("presentConnectionDiagnosis", () => { headline: "Mac mini blocked this device", steps: [ "On another device with access:write and orchestration:read on Mac mini (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.", - "Or run `t3 pair` on Mac mini, pair this device with the code, unblock it, then revoke that pairing in Settings > Environments so it signs in through Tailscale again.", + "Or run `t3 pair` on Mac mini, pair this device with the code, and unblock it. Then, on Mac mini, revoke that pairing (`t3 auth session list`, then `t3 auth session revoke `) so it signs in through Tailscale again.", ], moreInfoUrl: expect.stringMatching(/#tailscale-troubleshooting$/), }); diff --git a/packages/client-runtime/src/connection/routes.test.ts b/packages/client-runtime/src/connection/routes.test.ts index afebc69e71b5f..f7ab5b25e7dca 100644 --- a/packages/client-runtime/src/connection/routes.test.ts +++ b/packages/client-runtime/src/connection/routes.test.ts @@ -116,6 +116,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, diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index f7bee2245693f..4af9ab5da10d0 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -250,7 +250,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); diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index 4c6484581572e..df192fea23618 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -255,7 +255,7 @@ export function tailnetRefusalVerdict( headline: `${host} blocked this device`, steps: [ `On another device with access:write and orchestration:read on ${host} (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.`, - `Or run \`t3 pair\` on ${host}, pair this device with the code, unblock it, then revoke that pairing in Settings > Environments so it signs in through Tailscale again.`, + `Or run \`t3 pair\` on ${host}, pair this device with the code, and unblock it. Then, on ${host}, revoke that pairing (\`t3 auth session list\`, then \`t3 auth session revoke \`) so it signs in through Tailscale again.`, ], }; case "rate_limited": From 30e5aab6e89cca8da568e09e0d210a13966349a8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:15:51 -0800 Subject: [PATCH 108/114] fix(connection): a saved tailnet address with no port stays on port 80 when parsed again A URL with `http://` and no port means port 80; only a bare host means T3 Code's 3773. So a saved `http://100.88.1.2/`, parsed again when a Tailscale connection is renamed or moved, keeps port 80 instead of moving to 3773. Co-Authored-By: Claude Opus 5.5 --- packages/client-runtime/src/connection/tailnet.test.ts | 2 ++ packages/client-runtime/src/connection/tailnet.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/connection/tailnet.test.ts b/packages/client-runtime/src/connection/tailnet.test.ts index 58b83400d28e1..7ec096b0fa572 100644 --- a/packages/client-runtime/src/connection/tailnet.test.ts +++ b/packages/client-runtime/src/connection/tailnet.test.ts @@ -38,6 +38,8 @@ describe("tailnetSignInBaseUrl", () => { ["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); diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index df192fea23618..1c0656ef77675 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -47,9 +47,11 @@ export function tailnetSignInBaseUrl(input: string): string | null { if (url.pathname !== "/" || url.search !== "" || url.hash !== "") return null; const hostname = normalizeHostname(url.hostname); if (!isSharedAddressSpaceHost(hostname) && !isTailnetName(hostname)) return null; - // URL drops the default port, so a typed `:80` would read as no port at all. + // 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 : typedPort ? "80" : String(TAILNET_SIGN_IN_PORT); + const port = + url.port !== "" ? url.port : hasScheme || typedPort ? "80" : String(TAILNET_SIGN_IN_PORT); return `http://${hostname}:${port}/`; } From b7d1c85026ce5d4d959146aef537b10a38867283 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:19:38 -0800 Subject: [PATCH 109/114] docs(connection): the unblock steps name Sharing & access where Settings keeps those controls Co-Authored-By: Claude Opus 5.5 --- packages/client-runtime/src/connection/diagnosis.test.ts | 4 ++-- packages/client-runtime/src/connection/tailnet.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index 9271f868b97ff..06adf58c2688e 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -223,8 +223,8 @@ describe("presentConnectionDiagnosis", () => { expect(refused("blocked")).toMatchObject({ headline: "Mac mini blocked this device", steps: [ - "On another device with access:write and orchestration:read on Mac mini (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.", - "Or run `t3 pair` on Mac mini, pair this device with the code, and unblock it. Then, on Mac mini, revoke that pairing (`t3 auth session list`, then `t3 auth session revoke `) so it signs in through Tailscale again.", + "On another device with access:write and orchestration:read on Mac mini (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments (Sharing & access on that machine itself) and unblock this device.", + "Or run `t3 pair` on Mac mini, pair this device with the code, and unblock it. Then, on Mac mini, revoke that pairing (`t3 auth session list`, then `t3 auth session revoke `, or Settings > Sharing & access there) so it signs in through Tailscale again.", ], moreInfoUrl: expect.stringMatching(/#tailscale-troubleshooting$/), }); diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index 1c0656ef77675..c9513e1c3189a 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -256,8 +256,8 @@ export function tailnetRefusalVerdict( return { headline: `${host} blocked this device`, steps: [ - `On another device with access:write and orchestration:read on ${host} (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments and unblock this device.`, - `Or run \`t3 pair\` on ${host}, pair this device with the code, and unblock it. Then, on ${host}, revoke that pairing (\`t3 auth session list\`, then \`t3 auth session revoke \`) so it signs in through Tailscale again.`, + `On another device with access:write and orchestration:read on ${host} (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments (Sharing & access on that machine itself) and unblock this device.`, + `Or run \`t3 pair\` on ${host}, pair this device with the code, and unblock it. Then, on ${host}, revoke that pairing (\`t3 auth session list\`, then \`t3 auth session revoke \`, or Settings > Sharing & access there) so it signs in through Tailscale again.`, ], }; case "rate_limited": From cfe2cd4696c9778f6e59d11576b9a49867a97b2e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:26:30 -0800 Subject: [PATCH 110/114] docs(connection): recovery pairs with only the scopes it needs, and the CLI revoke says to restart The blocked steps split who can manage access into its own sentence, and the guide names Sharing & access on the machine itself. Co-Authored-By: Claude Opus 5.5 --- docs/user/remote-access.md | 13 ++++++++----- .../client-runtime/src/connection/diagnosis.test.ts | 5 +++-- packages/client-runtime/src/connection/tailnet.ts | 5 +++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 33faa39efa7c5..991f5e65a121f 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -266,11 +266,14 @@ 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 (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 **Block** it under **Tailscale -devices on **. Its sessions there end now, and **Unblock** undoes it. A -browser preview or device screen it already has open keeps working, clicks and +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. + +A blocked device's sessions on that machine end now, and **Unblock** undoes it. +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. diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index 06adf58c2688e..d0b05227bcb90 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -223,8 +223,9 @@ describe("presentConnectionDiagnosis", () => { expect(refused("blocked")).toMatchObject({ headline: "Mac mini blocked this device", steps: [ - "On another device with access:write and orchestration:read on Mac mini (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments (Sharing & access on that machine itself) and unblock this device.", - "Or run `t3 pair` on Mac mini, pair this device with the code, and unblock it. Then, on Mac mini, revoke that pairing (`t3 auth session list`, then `t3 auth session revoke `, or Settings > Sharing & access there) so it signs in through Tailscale again.", + "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.", + "Or run `t3 pair --scope orchestration:read --scope access:write` on Mac mini, pair this device with the code, and unblock it. Then 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$/), }); diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index c9513e1c3189a..c66e09860e8ac 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -256,8 +256,9 @@ export function tailnetRefusalVerdict( return { headline: `${host} blocked this device`, steps: [ - `On another device with access:write and orchestration:read on ${host} (one paired with a default t3 pair code, one granted admin, or yours while nothing is granted), open Settings > Environments (Sharing & access on that machine itself) and unblock this device.`, - `Or run \`t3 pair\` on ${host}, pair this device with the code, and unblock it. Then, on ${host}, revoke that pairing (\`t3 auth session list\`, then \`t3 auth session revoke \`, or Settings > Sharing & access there) so it signs in through Tailscale again.`, + `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.`, + `Or run \`t3 pair --scope orchestration:read --scope access:write\` on ${host}, pair this device with the code, and unblock it. Then 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": From 440e1c4c76d8d3af66a4158c0422c53cf5605b59 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:33:37 -0800 Subject: [PATCH 111/114] docs(connection): block says only Tailscale sessions end, and recovery pairs with the printed link A pairing-code session outlives a block, so the dialogs and guide say so. The owner rule is noted as off while grants are required. Co-Authored-By: Claude Opus 5.5 --- .../features/connection/TailnetDevicesSection.tsx | 2 +- .../settings/TailnetDevicesSettings.tsx | 2 +- docs/user/remote-access.md | 15 +++++++++------ .../src/connection/diagnosis.test.ts | 4 ++-- packages/client-runtime/src/connection/tailnet.ts | 4 ++-- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/connection/TailnetDevicesSection.tsx b/apps/mobile/src/features/connection/TailnetDevicesSection.tsx index a0729c5acacce..0b7ea3b0a4de2 100644 --- a/apps/mobile/src/features/connection/TailnetDevicesSection.tsx +++ b/apps/mobile/src/features/connection/TailnetDevicesSection.tsx @@ -71,7 +71,7 @@ export function TailnetDevicesSection(props: { } Dialog.confirm({ title: `Block ${name}?`, - message: `${device.current ? `This is the device you are using, so it loses access to ${props.environmentLabel} until another device unblocks it. ` : ""}Its sessions on ${props.environmentLabel} end now, and it cannot sign in through Tailscale until you unblock it. A device that rejoins Tailscale gets a new ID, so block it again or remove it from your tailnet.`, + message: `${device.current ? `This is the device you are using, so it loses access to ${props.environmentLabel} until another device unblocks it. ` : ""}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. 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), diff --git a/apps/web/src/components/settings/TailnetDevicesSettings.tsx b/apps/web/src/components/settings/TailnetDevicesSettings.tsx index b2229e8c9a34a..4325d6aa52de2 100644 --- a/apps/web/src/components/settings/TailnetDevicesSettings.tsx +++ b/apps/web/src/components/settings/TailnetDevicesSettings.tsx @@ -47,7 +47,7 @@ export function TailnetDevicesSettings({ const name = device.label ?? device.stableNodeId; if (device.blockedAt === null) { const confirmed = await requestConfirmDialog( - `Block ${name}?\n${device.current ? `This is the device you are using, so it loses access ${environmentLabel === undefined ? "here" : `to ${environmentLabel}`} until another device unblocks it. ` : ""}Its sessions ${environmentLabel === undefined ? "here" : `on ${environmentLabel}`} end now, and it cannot sign in through Tailscale until you unblock it. A device that rejoins Tailscale gets a new ID, so block it again or remove it from your tailnet.`, + `Block ${name}?\n${device.current ? `This is the device you are using, so it loses access ${environmentLabel === undefined ? "here" : `to ${environmentLabel}`} until another device unblocks it. ` : ""}Its Tailscale sessions ${environmentLabel === undefined ? "here" : `on ${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. A device that rejoins Tailscale gets a new ID, so block it again or remove it from your tailnet.`, { variant: "destructive" }, ); if (confirmed !== true) return; diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 991f5e65a121f..1f532361ea843 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -270,12 +270,15 @@ 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. - -A blocked device's sessions on that machine end now, and **Unblock** undoes it. -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. +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. diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index d0b05227bcb90..068d85926d3ed 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -224,8 +224,8 @@ describe("presentConnectionDiagnosis", () => { 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.", - "Or run `t3 pair --scope orchestration:read --scope access:write` on Mac mini, pair this device with the code, and unblock it. Then 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.", + "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.", + "Or run `t3 pair --scope orchestration:read --scope access:write` on Mac mini, pair this device with the link it prints, and unblock it. Then 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$/), }); diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index c66e09860e8ac..d7ef75f20be0f 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -257,8 +257,8 @@ export function tailnetRefusalVerdict( 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.`, - `Or run \`t3 pair --scope orchestration:read --scope access:write\` on ${host}, pair this device with the code, and unblock it. Then 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.`, + `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.`, + `Or run \`t3 pair --scope orchestration:read --scope access:write\` on ${host}, pair this device with the link it prints, and unblock it. Then 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": From 017160b03baa9224d250e92232ff8ed4e18fa4a2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:40:54 -0800 Subject: [PATCH 112/114] docs(connection): recovery works when the pairing link names a LAN address, and Block says where to revoke a pairing The self-block warning says the device loses Tailscale access until it is unblocked, as it can unblock itself through a pairing. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/connection/TailnetDevicesSection.tsx | 2 +- apps/web/src/components/settings/TailnetDevicesSettings.tsx | 2 +- packages/client-runtime/src/connection/diagnosis.test.ts | 3 ++- packages/client-runtime/src/connection/tailnet.ts | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/connection/TailnetDevicesSection.tsx b/apps/mobile/src/features/connection/TailnetDevicesSection.tsx index 0b7ea3b0a4de2..323a3c0c88c48 100644 --- a/apps/mobile/src/features/connection/TailnetDevicesSection.tsx +++ b/apps/mobile/src/features/connection/TailnetDevicesSection.tsx @@ -71,7 +71,7 @@ export function TailnetDevicesSection(props: { } Dialog.confirm({ title: `Block ${name}?`, - message: `${device.current ? `This is the device you are using, so it loses access to ${props.environmentLabel} until another device unblocks it. ` : ""}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. A device that rejoins Tailscale gets a new ID, so block it again or remove it from your tailnet.`, + 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), diff --git a/apps/web/src/components/settings/TailnetDevicesSettings.tsx b/apps/web/src/components/settings/TailnetDevicesSettings.tsx index 4325d6aa52de2..7551bf74caf0a 100644 --- a/apps/web/src/components/settings/TailnetDevicesSettings.tsx +++ b/apps/web/src/components/settings/TailnetDevicesSettings.tsx @@ -47,7 +47,7 @@ export function TailnetDevicesSettings({ const name = device.label ?? device.stableNodeId; if (device.blockedAt === null) { const confirmed = await requestConfirmDialog( - `Block ${name}?\n${device.current ? `This is the device you are using, so it loses access ${environmentLabel === undefined ? "here" : `to ${environmentLabel}`} until another device unblocks it. ` : ""}Its Tailscale sessions ${environmentLabel === undefined ? "here" : `on ${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. A device that rejoins Tailscale gets a new ID, so block it again or remove it from your tailnet.`, + `Block ${name}?\n${device.current ? `This is the device you are using, so it loses Tailscale access ${environmentLabel === undefined ? "here" : `to ${environmentLabel}`} until it is unblocked. ` : ""}Its Tailscale sessions ${environmentLabel === undefined ? "here" : `on ${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${environmentLabel === undefined ? "" : ` on ${environmentLabel}`}. A device that rejoins Tailscale gets a new ID, so block it again or remove it from your tailnet.`, { variant: "destructive" }, ); if (confirmed !== true) return; diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index 068d85926d3ed..16aa5668bb939 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -225,7 +225,8 @@ describe("presentConnectionDiagnosis", () => { 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.", - "Or run `t3 pair --scope orchestration:read --scope access:write` on Mac mini, pair this device with the link it prints, and unblock it. Then 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.", + "Or run `t3 pair --scope orchestration:read --scope access:write` on Mac mini and pair this device with the link it prints. If the link names a LAN address, pair with its code at http://: instead.", + "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$/), }); diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index d7ef75f20be0f..e1a7b37cf995a 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -258,7 +258,8 @@ export function tailnetRefusalVerdict( 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.`, - `Or run \`t3 pair --scope orchestration:read --scope access:write\` on ${host}, pair this device with the link it prints, and unblock it. Then 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.`, + `Or run \`t3 pair --scope orchestration:read --scope access:write\` on ${host} and pair this device with the link it prints. If the link names a LAN address, pair with its code at http://: instead.`, + `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": From bf9af4b16adfd4842d70a9b6041a97618bcf86b2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:47:02 -0800 Subject: [PATCH 113/114] docs(connection): unblock steps say how to retry and find the Tailscale address, and t3 pair names its code A blocked client waits for the app to come to the front before it retries. `t3 pair` printed "Token:" while every form asks for a pairing code. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/pair.ts | 2 +- packages/client-runtime/src/connection/diagnosis.test.ts | 4 ++-- packages/client-runtime/src/connection/tailnet.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index d9eb7e59ef698..841b4878fb2bd 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -196,7 +196,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}`]), "", diff --git a/packages/client-runtime/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index 16aa5668bb939..a5366aef6d637 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -223,9 +223,9 @@ describe("presentConnectionDiagnosis", () => { 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.", + "Unblock this device in Settings > Sharing & access on Mac mini, or in Settings > Environments on another device that can manage access there. Then bring T3 Code on this device to the front, or reopen it, to try again.", "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.", - "Or run `t3 pair --scope orchestration:read --scope access:write` on Mac mini and pair this device with the link it prints. If the link names a LAN address, pair with its code at http://: instead.", + "Or run `t3 pair --scope orchestration:read --scope access:write` on Mac mini and pair this device with the link it prints. If the link names a LAN address, pair with its code at http://: instead (`tailscale ip -4` on Mac mini prints 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$/), diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index e1a7b37cf995a..8135c8ea7d0e4 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -256,9 +256,9 @@ export function tailnetRefusalVerdict( 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.`, + `Unblock this device in Settings > Sharing & access on ${host}, or in Settings > Environments on another device that can manage access there. Then bring T3 Code on this device to the front, or reopen it, to try again.`, `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.`, - `Or run \`t3 pair --scope orchestration:read --scope access:write\` on ${host} and pair this device with the link it prints. If the link names a LAN address, pair with its code at http://: instead.`, + `Or run \`t3 pair --scope orchestration:read --scope access:write\` on ${host} and pair this device with the link it prints. If the link names a LAN address, pair with its code at http://: instead (\`tailscale ip -4\` on ${host} prints 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.`, ], }; From ae97b61ae2b4c15eee8ebcc198bc8df7524dceae Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 1 Nov 2026 02:55:27 -0800 Subject: [PATCH 114/114] docs(connection): recovery adds a route to a saved machine, retry names its button, and pairing output says code Add environment refuses a new address for a saved machine, so recovery uses Add route. A blocked retry needs Retry now or a fresh form submit. `t3 serve` and `t3 pair --help` call the credential a pairing code like every form. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/pair.ts | 4 ++-- apps/server/src/startupAccess.ts | 2 +- docs/user/remote-access.md | 4 ++-- packages/client-runtime/src/connection/diagnosis.test.ts | 5 +++-- packages/client-runtime/src/connection/tailnet.ts | 5 +++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 841b4878fb2bd..afc5a764904d6 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -449,7 +449,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, ); @@ -481,7 +481,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* () { diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 8586e49b53f8d..701eceb1fa2e8 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -274,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)}`, "", diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 1f532361ea843..600ed9da19df0 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -106,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. @@ -541,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/src/connection/diagnosis.test.ts b/packages/client-runtime/src/connection/diagnosis.test.ts index a5366aef6d637..036737cbe45fb 100644 --- a/packages/client-runtime/src/connection/diagnosis.test.ts +++ b/packages/client-runtime/src/connection/diagnosis.test.ts @@ -223,9 +223,10 @@ describe("presentConnectionDiagnosis", () => { 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. Then bring T3 Code on this device to the front, or reopen it, to try again.", + "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.", - "Or run `t3 pair --scope orchestration:read --scope access:write` on Mac mini and pair this device with the link it prints. If the link names a LAN address, pair with its code at http://: instead (`tailscale ip -4` on Mac mini prints the address).", + "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$/), diff --git a/packages/client-runtime/src/connection/tailnet.ts b/packages/client-runtime/src/connection/tailnet.ts index 8135c8ea7d0e4..1dcbfb6798229 100644 --- a/packages/client-runtime/src/connection/tailnet.ts +++ b/packages/client-runtime/src/connection/tailnet.ts @@ -256,9 +256,10 @@ export function tailnetRefusalVerdict( 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. Then bring T3 Code on this device to the front, or reopen it, to try again.`, + `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.`, - `Or run \`t3 pair --scope orchestration:read --scope access:write\` on ${host} and pair this device with the link it prints. If the link names a LAN address, pair with its code at http://: instead (\`tailscale ip -4\` on ${host} prints the address).`, + `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.`, ], };