diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 611d4cf44f75..66f9b0f9ac90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,10 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + # Schema and per-platform pin check only; no download. Release builds + # fetch the binaries this manifest describes. + - name: Verify Tailcat manifest + run: node scripts/fetch-tailcat.ts --verify --manifest-only # Files/dependencies are repo-wide; export checks cover clean workspaces only. - name: Check unused code run: vp run knip:check diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index c743b961560b..0c88dc46cca6 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -180,6 +180,35 @@ jobs: toolchain: stable targets: ${{ inputs.rust_target }} + - name: Cache Tailcat runtime + id: tailcat_cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: native/tailcat/dist/${{ inputs.resource_key }} + key: tailcat-${{ inputs.resource_key }}-${{ hashFiles('native/tailcat/manifest.json') }} + + # Upstream publishes no macOS Tailcat archive, so the macOS jobs compile + # the pinned tag with the Go toolchain the manifest names. + - name: Resolve Tailcat Go version + if: inputs.platform == 'mac' && steps.tailcat_cache.outputs.cache-hit != 'true' + id: tailcat_go + shell: bash + run: echo "version=$(node -p "require('./native/tailcat/manifest.json').source.goVersion")" >> "$GITHUB_OUTPUT" + + - name: Setup Go + if: inputs.platform == 'mac' && steps.tailcat_cache.outputs.cache-hit != 'true' + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version: ${{ steps.tailcat_go.outputs.version }} + cache: false + + # Verifies a cached runtime against the manifest and only downloads or + # builds when nothing valid is staged. The desktop app and the CLI + # archive both ship this copy. + - name: Fetch Tailcat runtime + shell: bash + run: node scripts/fetch-tailcat.ts --platform "${{ inputs.resource_key }}"${{ inputs.platform == 'mac' && ' --build-from-source' || '' }} + - name: Download relay client tracing config if: inputs.relay_client_tracing uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 diff --git a/.gitignore b/.gitignore index 8482c5a290e1..ced4123c5523 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ apps/mobile/.generated/ artifacts/app-store/screenshots/ .github/pr-assets/ native/**/target/ +native/tailcat/dist/ +apps/desktop/prod-resources/tailcat/ node_modules/ .alchemy/ *.log diff --git a/apps/desktop/package.json b/apps/desktop/package.json index daedb89a1283..86e227174110 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -21,6 +21,7 @@ "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/ssh": "workspace:*", + "@t3tools/tailcat": "workspace:*", "@t3tools/tailscale": "workspace:*", "dbus-next": "0.10.2", "effect": "catalog:", diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index b3bec8830ec6..ae920af04445 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -16,6 +16,7 @@ import serverPackageJson from "../../../server/package.json" with { type: "json" import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { resolveDesktopTailcatBinaryPath } from "../tailcat/DesktopTailcatRuntime.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; @@ -536,6 +537,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv function* ( input: SharedBootstrapInput & { readonly resourceMonitorPath: Option.Option; + readonly tailcatBinaryPath: Option.Option; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -561,6 +563,10 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv onNone: () => ({}), onSome: (resourceMonitorPath) => ({ resourceMonitorPath }), }), + ...Option.match(input.tailcatBinaryPath, { + onNone: () => ({}), + onSome: (tailcatBinaryPath) => ({ tailcatBinaryPath }), + }), ...buildObservabilityFragment(input.observabilitySettings), }; @@ -889,7 +895,17 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); - return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe( + // The bundled Tailcat binary is shared with the backend so the server's + // remote access and the desktop's forwards run the same pinned build. + const tailcatBinaryPath = yield* resolveDesktopTailcatBinaryPath().pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return yield* resolvePrimaryStartConfig({ + ...shared, + resourceMonitorPath, + tailcatBinaryPath, + }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), ); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c97c602552f4..5b06924b0e16 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -68,6 +68,7 @@ import { setSnapShotShortcutSuppressed, } from "./methods/snapShot.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import * as TailcatIpc from "./methods/tailcatEnvironment.ts"; import * as AppActivationIpc from "./methods/appActivation.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; @@ -115,6 +116,10 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(issueSshWebSocketTicket); yield* ipc.handle(resolveSshPasswordPrompt); + for (const tailcatMethod of TailcatIpc.methods) { + yield* ipc.handle(tailcatMethod); + } + yield* ipc.handle(getServerExposureState); yield* ipc.handle(setServerExposureMode); yield* ipc.handle(setTailscaleServeEnabled); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index e8a688c189a3..d62b7fb296c7 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -68,6 +68,12 @@ export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled" export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +export const ENSURE_TAILCAT_ENVIRONMENT_CHANNEL = "desktop:ensure-tailcat-environment"; +export const RESTART_TAILCAT_ENVIRONMENT_CHANNEL = "desktop:restart-tailcat-environment"; +export const DISCONNECT_TAILCAT_ENVIRONMENT_CHANNEL = "desktop:disconnect-tailcat-environment"; +export const GET_TAILCAT_CONNECTION_DIAGNOSTICS_CHANNEL = + "desktop:get-tailcat-connection-diagnostics"; +export const PROBE_TAILCAT_CONNECTION_PATH_CHANNEL = "desktop:probe-tailcat-connection-path"; export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview"; diff --git a/apps/desktop/src/ipc/methods/tailcatEnvironment.ts b/apps/desktop/src/ipc/methods/tailcatEnvironment.ts new file mode 100644 index 000000000000..a12ef1106e12 --- /dev/null +++ b/apps/desktop/src/ipc/methods/tailcatEnvironment.ts @@ -0,0 +1,81 @@ +import { + DesktopTailcatConnectionIdInputSchema, + DesktopTailcatEnvironmentBootstrapSchema, + DesktopTailcatEnvironmentEnsureInputSchema, + TailcatConnectionDiagnostics, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as DesktopTailcatEnvironment from "../../tailcat/DesktopTailcatEnvironment.ts"; + +/** + * Renderer-facing Tailcat transport methods. The renderer never touches the + * private key or the child process; it receives a loopback endpoint and + * diagnostics, and asks for lifecycle changes by connection id. + */ + +const ensureTailcatEnvironment = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.ENSURE_TAILCAT_ENVIRONMENT_CHANNEL, + payload: DesktopTailcatEnvironmentEnsureInputSchema, + result: DesktopTailcatEnvironmentBootstrapSchema, + handler: Effect.fn("desktop.ipc.tailcatEnvironment.ensureEnvironment")(function* (input) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return yield* tailcat.ensureEnvironment(input); + }), +}); + +const restartTailcatEnvironment = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESTART_TAILCAT_ENVIRONMENT_CHANNEL, + payload: DesktopTailcatConnectionIdInputSchema, + result: DesktopTailcatEnvironmentBootstrapSchema, + handler: Effect.fn("desktop.ipc.tailcatEnvironment.restartEnvironment")(function* ({ + connectionId, + }) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return yield* tailcat.restartEnvironment(connectionId); + }), +}); + +const disconnectTailcatEnvironment = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DISCONNECT_TAILCAT_ENVIRONMENT_CHANNEL, + payload: DesktopTailcatConnectionIdInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.tailcatEnvironment.disconnectEnvironment")(function* ({ + connectionId, + }) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + yield* tailcat.disconnectEnvironment(connectionId); + }), +}); + +const getTailcatConnectionDiagnostics = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_TAILCAT_CONNECTION_DIAGNOSTICS_CHANNEL, + payload: DesktopTailcatConnectionIdInputSchema, + result: Schema.NullOr(TailcatConnectionDiagnostics), + handler: Effect.fn("desktop.ipc.tailcatEnvironment.diagnostics")(function* ({ connectionId }) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return Option.getOrNull(yield* tailcat.diagnostics(connectionId)); + }), +}); + +const probeTailcatConnectionPath = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PROBE_TAILCAT_CONNECTION_PATH_CHANNEL, + payload: DesktopTailcatConnectionIdInputSchema, + result: Schema.NullOr(TailcatConnectionDiagnostics), + handler: Effect.fn("desktop.ipc.tailcatEnvironment.probePath")(function* ({ connectionId }) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return Option.getOrNull(yield* tailcat.probePath(connectionId)); + }), +}); + +export const methods = [ + ensureTailcatEnvironment, + restartTailcatEnvironment, + disconnectTailcatEnvironment, + getTailcatConnectionDiagnostics, + probeTailcatConnectionPath, +] as const; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0d626a51955d..3c2f2c66072c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -55,6 +55,9 @@ import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; +import * as DesktopTailcatEnvironment from "./tailcat/DesktopTailcatEnvironment.ts"; +import * as DesktopTailcatIdentity from "./tailcat/DesktopTailcatIdentity.ts"; +import * as DesktopTailcatRuntime from "./tailcat/DesktopTailcatRuntime.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; @@ -139,6 +142,14 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe( Layer.provideMerge(DesktopSshPasswordPrompts.layer()), ); +// Tailcat forwards for saved Tailcat environments, plus this device's client +// identity (encrypted with safeStorage). Rides on the foundation for paths. +const desktopTailcatLayer = DesktopTailcatEnvironment.layer.pipe( + Layer.provideMerge(DesktopTailcatIdentity.layer), + Layer.provideMerge(DesktopTailcatRuntime.layer), + Layer.provide(desktopFoundationLayer), +); + const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( Layer.provideMerge(DesktopNetworkInterfaces.layer), Layer.provideMerge(desktopFoundationLayer), @@ -197,6 +208,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, desktopSshLayer, + desktopTailcatLayer, ).pipe( Layer.provideMerge(desktopSnapShotLayer), Layer.provideMerge(DesktopUpdates.layer), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 53d8cca7b088..b8647fd77164 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -161,6 +161,16 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, resolveSshPasswordPrompt: (requestId, password) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), + ensureTailcatEnvironment: (input) => + ipcRenderer.invoke(IpcChannels.ENSURE_TAILCAT_ENVIRONMENT_CHANNEL, input), + restartTailcatEnvironment: (connectionId) => + ipcRenderer.invoke(IpcChannels.RESTART_TAILCAT_ENVIRONMENT_CHANNEL, { connectionId }), + disconnectTailcatEnvironment: (connectionId) => + ipcRenderer.invoke(IpcChannels.DISCONNECT_TAILCAT_ENVIRONMENT_CHANNEL, { connectionId }), + getTailcatConnectionDiagnostics: (connectionId) => + ipcRenderer.invoke(IpcChannels.GET_TAILCAT_CONNECTION_DIAGNOSTICS_CHANNEL, { connectionId }), + probeTailcatConnectionPath: (connectionId) => + ipcRenderer.invoke(IpcChannels.PROBE_TAILCAT_CONNECTION_PATH_CHANNEL, { connectionId }), getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), diff --git a/apps/desktop/src/tailcat/DesktopTailcatEnvironment.test.ts b/apps/desktop/src/tailcat/DesktopTailcatEnvironment.test.ts new file mode 100644 index 000000000000..a1e3c5ad3825 --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatEnvironment.test.ts @@ -0,0 +1,504 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { + DesktopTailcatEnvironmentEnsureInput, + TailcatPathProbe, + TailcatRuntimeInfo, +} from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; +import { tailcatBackoffDelayMs } from "@t3tools/tailcat/backoff"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +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 Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as DesktopTailcatEnvironment from "./DesktopTailcatEnvironment.ts"; +import * as DesktopTailcatIdentity from "./DesktopTailcatIdentity.ts"; + +const CONNECTION_ID = "connection-1"; +// Captured from a real `tailcat serve` run; the fake runtime never decodes it. +const ADDRESS = + "tco2FwWCB-p3FjjOrzlCPp0w8aT3p9xDZ1nNaXWX_dASxDCFT_MmFrWCDRnh2-iykbZ7W4Fl0g3nBpwTnR3iXVCKKCk4pps47ndGFpGQEu"; +const OTHER_ADDRESS = "tcAnotherServer_0123456789abcdefABCDEF"; +const REMOTE_PORT = 3773; +const FIRST_PORT = 41000; +const NODE_KEY = `nodekey:${"7f".repeat(32)}`; +const KEY_PATH = "/tmp/fake.key"; +// The TestClock starts at the epoch, so every recorded timestamp is fixed. +const EPOCH_ISO = "1970-01-01T00:00:00.000Z"; +const RECENT_OUTPUT = ["forward: tunnel established"]; +const FIRST_BACKOFF_MAX_MS = tailcatBackoffDelayMs(1, 1); +const SECOND_BACKOFF_MIN_MS = tailcatBackoffDelayMs(2, 0); +const SECOND_BACKOFF_MAX_MS = tailcatBackoffDelayMs(2, 1); + +const ENSURE_INPUT = { + connectionId: CONNECTION_ID, + address: ADDRESS, + remotePort: REMOTE_PORT, +} satisfies DesktopTailcatEnvironmentEnsureInput; + +const RUNTIME_INFO: TailcatRuntimeInfo = { + executablePath: "/opt/t3/resources/tailcat/linux-x64/tailcat", + source: "bundled", + version: "0.3.0", + pinnedVersion: "0.3.0", +}; + +const PATH_PROBE: TailcatPathProbe = { + kind: "direct", + via: "203.0.113.5:41641", + latencyMs: 12.5, + measuredAt: EPOCH_ISO, +}; + +const DESCRIPTOR = { + environmentId: "env-remote", + label: "Remote Devbox", + platform: { os: "linux", arch: "x64" }, + serverVersion: "1.2.3", + capabilities: {}, +}; + +const httpBaseUrlFor = (localPort: number) => `http://127.0.0.1:${localPort}/`; +const probeUrlFor = (localPort: number) => `${httpBaseUrlFor(localPort)}.well-known/t3/environment`; + +function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +interface FakeForward { + readonly pid: number; + readonly input: { + readonly keyPath: string | null; + readonly address: string; + readonly remotePort: number; + readonly localPort: number; + }; + /** Settle to simulate the forwarder process exiting on its own. */ + readonly exit: Deferred.Deferred>; + readonly state: { + running: boolean; + /** Set when the owning scope closes, which is how the runtime stops a forwarder. */ + stopped: boolean; + }; + readonly handle: TailcatRuntime.TailcatForwardHandle; +} + +interface Harness { + readonly layer: Layer.Layer; + /** Every `forward` call in order, whether or not it became ready. */ + readonly forwards: ReadonlyArray; + readonly probeRequests: ReadonlyArray; + readonly pings: ReadonlyArray<{ readonly keyPath: string | null; readonly address: string }>; + /** Whether the fake T3 server behind the tunnel answers the readiness probe. */ + readonly setRemoteHealthy: (healthy: boolean) => void; + /** Settles once the n-th (1-based) forward has been spawned. */ + readonly spawned: (count: number) => Effect.Effect; +} + +function makeHarness(options?: { + readonly resolve?: Effect.Effect; +}): Harness { + const forwards: Array = []; + const probeRequests: Array = []; + const pings: Array<{ readonly keyPath: string | null; readonly address: string }> = []; + const spawnSignals = new Map>(); + let remoteHealthy = true; + let nextPort = FIRST_PORT; + + const spawnSignal = (count: number) => { + const existing = spawnSignals.get(count); + if (existing !== undefined) { + return existing; + } + const created = Deferred.makeUnsafe(); + spawnSignals.set(count, created); + return created; + }; + + const runtimeLayer = Layer.mock(TailcatRuntime.TailcatRuntime)({ + resolve: options?.resolve ?? Effect.succeed(RUNTIME_INFO), + forward: (input) => + Effect.gen(function* () { + const exit = yield* Deferred.make>(); + const state = { running: true, stopped: false }; + const handle: TailcatRuntime.TailcatForwardHandle = { + pid: 5000 + forwards.length + 1, + address: input.address, + remotePort: input.remotePort, + localPort: input.localPort, + httpBaseUrl: httpBaseUrlFor(input.localPort), + wsBaseUrl: `ws://127.0.0.1:${input.localPort}/`, + exit: Deferred.await(exit), + isRunning: Effect.sync(() => state.running), + recentOutput: Effect.succeed(RECENT_OUTPUT), + stop: Effect.sync(() => { + state.running = false; + }), + }; + forwards.push({ + pid: handle.pid, + input: { + keyPath: input.keyPath, + address: input.address, + remotePort: input.remotePort, + localPort: input.localPort, + }, + exit, + state, + handle, + }); + yield* Deferred.done(spawnSignal(forwards.length), Exit.void); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + state.running = false; + state.stopped = true; + }), + ); + if (input.readiness !== undefined) { + // Like the runtime, a failed probe kills the forwarder before the error surfaces. + yield* input.readiness({ httpBaseUrl: handle.httpBaseUrl }).pipe( + Effect.onError(() => + Effect.sync(() => { + state.running = false; + }), + ), + ); + } + return handle; + }), + ping: (input) => + Effect.sync(() => { + pings.push({ keyPath: input.keyPath, address: input.address }); + return PATH_PROBE; + }), + }); + + const identityLayer = Layer.mock(DesktopTailcatIdentity.DesktopTailcatIdentity)({ + nodeKey: Effect.succeed(NODE_KEY), + encrypted: Effect.succeed(true), + withKeyFile: (use) => use(KEY_PATH), + }); + + const netLayer = Layer.mock(NetService.NetService)({ + reserveLoopbackPort: () => + Effect.sync(() => { + const port = nextPort; + nextPort += 1; + return port; + }), + }); + + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + probeRequests.push(request.url); + return remoteHealthy + ? jsonResponse(request, DESCRIPTOR) + : jsonResponse(request, { error: "server offline" }, 503); + }), + ), + ); + + return { + layer: DesktopTailcatEnvironment.layer.pipe( + Layer.provide(Layer.mergeAll(runtimeLayer, identityLayer, netLayer, httpClientLayer)), + ), + forwards, + probeRequests, + pings, + setRemoteHealthy: (healthy) => { + remoteHealthy = healthy; + }, + spawned: (count) => Deferred.await(spawnSignal(count)), + }; +} + +describe("DesktopTailcatEnvironment", () => { + it.effect("ensureEnvironment forwards a reserved loopback port and reports the bootstrap", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + + const bootstrap = yield* environment.ensureEnvironment(ENSURE_INPUT); + + assert.deepEqual(bootstrap, { + connectionId: CONNECTION_ID, + address: ADDRESS, + remotePort: REMOTE_PORT, + localPort: FIRST_PORT, + httpBaseUrl: httpBaseUrlFor(FIRST_PORT), + wsBaseUrl: `ws://127.0.0.1:${FIRST_PORT}/`, + clientNodeKey: NODE_KEY, + }); + assert.equal(harness.forwards.length, 1); + assert.deepEqual(harness.forwards[0]?.input, { + keyPath: KEY_PATH, + address: ADDRESS, + remotePort: REMOTE_PORT, + localPort: FIRST_PORT, + }); + assert.deepEqual(harness.probeRequests, [probeUrlFor(FIRST_PORT)]); + + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.deepEqual(diagnostics.value, { + connectionId: CONNECTION_ID, + address: ADDRESS, + remotePort: REMOTE_PORT, + status: "ready", + localEndpoint: httpBaseUrlFor(FIRST_PORT), + pid: 5001, + runtime: RUNTIME_INFO, + clientNodeKey: NODE_KEY, + path: null, + startedAt: EPOCH_ISO, + restartCount: 0, + lastError: null, + recentOutput: RECENT_OUTPUT, + }); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("reuses a healthy forward instead of spawning again", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + + const first = yield* environment.ensureEnvironment(ENSURE_INPUT); + const second = yield* environment.ensureEnvironment(ENSURE_INPUT); + + assert.equal(harness.forwards.length, 1); + assert.isFalse(harness.forwards[0]?.state.stopped); + assert.equal(second.localPort, first.localPort); + assert.deepEqual(second, first); + // The second call only re-probes the tunnel that is already up. + assert.deepEqual(harness.probeRequests, [probeUrlFor(FIRST_PORT), probeUrlFor(FIRST_PORT)]); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("moves a connection to a new address by replacing its forward", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + + const first = yield* environment.ensureEnvironment(ENSURE_INPUT); + const moved = yield* environment.ensureEnvironment({ + ...ENSURE_INPUT, + address: OTHER_ADDRESS, + }); + + assert.equal(harness.forwards.length, 2); + assert.isTrue(harness.forwards[0]?.state.stopped); + assert.isFalse(harness.forwards[1]?.state.stopped); + assert.deepEqual(harness.forwards[1]?.input, { + keyPath: KEY_PATH, + address: OTHER_ADDRESS, + remotePort: REMOTE_PORT, + localPort: FIRST_PORT + 1, + }); + assert.equal(moved.address, OTHER_ADDRESS); + assert.equal(moved.localPort, FIRST_PORT + 1); + assert.notEqual(moved.localPort, first.localPort); + assert.equal(moved.httpBaseUrl, httpBaseUrlFor(FIRST_PORT + 1)); + + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.equal(diagnostics.value.address, OTHER_ADDRESS); + assert.equal(diagnostics.value.status, "ready"); + assert.equal(diagnostics.value.pid, 5002); + assert.equal(diagnostics.value.restartCount, 0); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("fails ensureEnvironment when the remote never answers through the tunnel", () => { + const harness = makeHarness(); + harness.setRemoteHealthy(false); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + + const error = yield* environment.ensureEnvironment(ENSURE_INPUT).pipe(Effect.flip); + + assert.instanceOf(error, DesktopTailcatEnvironment.DesktopTailcatEnvironmentError); + assert.equal(error.code, "remote-unavailable"); + assert.isTrue(error.message.startsWith("[tailcat:remote-unavailable] ")); + assert.include(error.message, "may be offline"); + assert.include(error.message, "offline"); + // The failed attempt's forwarder went down with its scope. + assert.equal(harness.forwards.length, 1); + assert.isTrue(harness.forwards[0]?.state.stopped); + + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.equal(diagnostics.value.status, "failed"); + assert.equal(diagnostics.value.pid, null); + assert.equal(diagnostics.value.localEndpoint, null); + assert.equal(diagnostics.value.startedAt, null); + assert.deepEqual(diagnostics.value.lastError, { + code: "remote-unavailable", + message: error.detail, + at: EPOCH_ISO, + }); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("restarts a forward that exits on its own", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + const first = yield* environment.ensureEnvironment(ENSURE_INPUT); + const [initial] = harness.forwards; + assert(initial !== undefined); + + yield* Deferred.succeed(initial.exit, Option.some(1)); + // Let the exit monitor run, then cover the longest first backoff step. + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(FIRST_BACKOFF_MAX_MS)); + yield* harness.spawned(2); + // The next ensure waits behind the connection lock until the restart has settled. + const after = yield* environment.ensureEnvironment(ENSURE_INPUT); + + assert.equal(harness.forwards.length, 2); + assert.equal(after.localPort, first.localPort); + assert.deepEqual(harness.forwards[1]?.input, initial.input); + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.equal(diagnostics.value.status, "ready"); + assert.equal(diagnostics.value.restartCount, 1); + assert.equal(diagnostics.value.pid, 5002); + assert.equal(diagnostics.value.lastError, null); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("backs off before restarting a forward that already failed once", () => { + const harness = makeHarness(); + harness.setRemoteHealthy(false); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + // The first attempt fails and counts as the connection's first consecutive failure. + yield* environment.ensureEnvironment(ENSURE_INPUT).pipe(Effect.flip); + harness.setRemoteHealthy(true); + const bootstrap = yield* environment.ensureEnvironment(ENSURE_INPUT); + assert.equal(bootstrap.localPort, FIRST_PORT); + assert.equal(harness.forwards.length, 2); + const running = harness.forwards[1]; + assert(running !== undefined); + + yield* Deferred.succeed(running.exit, Option.some(137)); + // Let the exit monitor record the failure and schedule the restart. + yield* Effect.yieldNow; + + const failed = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(failed)); + assert.equal(failed.value.status, "failed"); + assert.equal(failed.value.pid, null); + assert.equal(failed.value.restartCount, 0); + assert.deepEqual(failed.value.lastError, { + code: "process-exited", + message: "The Tailcat forwarder exited with code 137.", + at: EPOCH_ISO, + }); + + // This is the second consecutive failure, so nothing restarts before the + // shortest jittered second step has passed. + yield* TestClock.adjust(Duration.millis(SECOND_BACKOFF_MIN_MS - 1)); + assert.equal(harness.forwards.length, 2); + // The longest jittered second step is enough for any random sample. + yield* TestClock.adjust(Duration.millis(SECOND_BACKOFF_MAX_MS - SECOND_BACKOFF_MIN_MS + 1)); + assert.equal(harness.forwards.length, 3); + yield* harness.spawned(3); + // The next ensure waits behind the connection lock until the restart has settled. + const after = yield* environment.ensureEnvironment(ENSURE_INPUT); + + assert.equal(after.localPort, FIRST_PORT); + assert.equal(harness.forwards.length, 3); + const restarted = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(restarted)); + assert.equal(restarted.value.status, "ready"); + assert.equal(restarted.value.restartCount, 1); + assert.equal(restarted.value.pid, 5003); + assert.equal(restarted.value.lastError, null); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("disconnectEnvironment stops the forward and forgets the connection", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + yield* environment.ensureEnvironment(ENSURE_INPUT); + const [initial] = harness.forwards; + assert(initial !== undefined); + + yield* environment.disconnectEnvironment(CONNECTION_ID); + + assert.isTrue(initial.state.stopped); + assert.isFalse(yield* initial.handle.isRunning); + assert.isTrue(Option.isNone(yield* environment.diagnostics(CONNECTION_ID))); + + // The stopped forwarder's exit arrives afterwards and must not restart anything. + yield* Deferred.succeed(initial.exit, Option.some(0)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(FIRST_BACKOFF_MAX_MS)); + assert.equal(harness.forwards.length, 1); + assert.isTrue(Option.isNone(yield* environment.diagnostics(CONNECTION_ID))); + + // Disconnecting an unknown connection is a no-op. + yield* environment.disconnectEnvironment(CONNECTION_ID); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("restartEnvironment replaces the forward and counts the restart", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + const first = yield* environment.ensureEnvironment(ENSURE_INPUT); + + const restarted = yield* environment.restartEnvironment(CONNECTION_ID); + + assert.equal(harness.forwards.length, 2); + assert.isTrue(harness.forwards[0]?.state.stopped); + assert.isFalse(harness.forwards[1]?.state.stopped); + assert.deepEqual(restarted, first); + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.equal(diagnostics.value.status, "ready"); + assert.equal(diagnostics.value.restartCount, 1); + assert.equal(diagnostics.value.pid, 5002); + + const missing = yield* environment.restartEnvironment("unknown-connection").pipe(Effect.flip); + assert.equal(missing.code, "unknown"); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("probePath records the measured path in diagnostics", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + assert.isTrue(Option.isNone(yield* environment.probePath(CONNECTION_ID))); + assert.equal(harness.pings.length, 0); + + yield* environment.ensureEnvironment(ENSURE_INPUT); + const probed = yield* environment.probePath(CONNECTION_ID); + + assert(Option.isSome(probed)); + assert.deepEqual(probed.value.path, PATH_PROBE); + assert.deepEqual(harness.pings, [{ keyPath: KEY_PATH, address: ADDRESS }]); + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.deepEqual(diagnostics.value.path, PATH_PROBE); + }).pipe(Effect.provide(harness.layer)); + }); +}); diff --git a/apps/desktop/src/tailcat/DesktopTailcatEnvironment.ts b/apps/desktop/src/tailcat/DesktopTailcatEnvironment.ts new file mode 100644 index 000000000000..7b81ce673bec --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatEnvironment.ts @@ -0,0 +1,523 @@ +import type { + DesktopTailcatEnvironmentBootstrap, + DesktopTailcatEnvironmentEnsureInput, + TailcatAddress, + TailcatConnectionDiagnostics, + TailcatFailure, + TailcatForwardStatus, + TailcatPathProbe, + TailcatRuntimeInfo, +} from "@t3tools/contracts"; +import { TailcatFailureCode } from "@t3tools/contracts"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import * as NetService from "@t3tools/shared/Net"; +import { tailcatBackoffDelayMs, TAILCAT_BACKOFF_RESET_AFTER_MS } from "@t3tools/tailcat/backoff"; +import { tailcatFailureCode, type TailcatRuntimeError } from "@t3tools/tailcat/errors"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +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 Option from "effect/Option"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import * as DesktopTailcatIdentity from "./DesktopTailcatIdentity.ts"; + +/** + * Desktop-side Tailcat transport: one `tailcat forward` per saved Tailcat + * environment, bound to a reserved loopback port, supervised for the lifetime + * of the app. The renderer only ever sees `http://127.0.0.1:`; T3 auth, + * pairing, and RPC run over that unchanged. + * + * Failure policy: a forward that exits on its own is restarted with jittered + * exponential backoff; a forward that starts but never passes the readiness + * probe (typical when the server is offline or its Tailcat access is off) fails the + * `ensure` call so the connection supervisor in the client can decide, and the + * probe result is kept for diagnostics. + */ + +const TAILCAT_FORWARD_READINESS_TIMEOUT = Duration.seconds(20); +const TAILCAT_FORWARD_MAX_RESTARTS = 8; + +export class DesktopTailcatEnvironmentError extends Schema.TaggedError()( + "DesktopTailcatEnvironmentError", + { + code: TailcatFailureCode, + detail: Schema.String, + }, +) { + /** + * The message crosses the IPC boundary as plain text, so it carries a + * machine-readable prefix the renderer can map back to a failure code. + */ + override get message(): string { + return `[tailcat:${this.code}] ${this.detail}`; + } +} + +export class DesktopTailcatEnvironment extends Context.Service< + DesktopTailcatEnvironment, + { + readonly ensureEnvironment: ( + input: DesktopTailcatEnvironmentEnsureInput, + ) => Effect.Effect; + readonly restartEnvironment: ( + connectionId: string, + ) => Effect.Effect; + readonly disconnectEnvironment: (connectionId: string) => Effect.Effect; + readonly diagnostics: ( + connectionId: string, + ) => Effect.Effect>; + readonly probePath: ( + connectionId: string, + ) => Effect.Effect, DesktopTailcatEnvironmentError>; + } +>()("@t3tools/desktop/tailcat/DesktopTailcatEnvironment") {} + +interface RunningForward { + readonly scope: Scope.Closeable; + readonly handle: TailcatRuntime.TailcatForwardHandle; + readonly startedAt: string; + readonly monitor: Fiber.Fiber; +} + +interface ForwardEntry { + readonly address: TailcatAddress; + readonly remotePort: number; + readonly localPort: number; + readonly status: TailcatForwardStatus; + readonly running: RunningForward | null; + readonly restartCount: number; + readonly consecutiveFailures: number; + readonly lastError: TailcatFailure | null; + readonly path: TailcatPathProbe | null; + /** Bumped per spawned forward so a stale exit monitor never acts on a newer one. */ + readonly generation: number; +} + +const describe = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); +const withoutKey = (map: ReadonlyMap, key: K): ReadonlyMap => { + const next = new Map(map); + next.delete(key); + return next; +}; +const isDesktopTailcatEnvironmentError = Schema.is(DesktopTailcatEnvironmentError); + +const failureCodeOf = ( + error: TailcatRuntimeError | DesktopTailcatIdentity.DesktopTailcatIdentityError, +): TailcatFailureCode => + error._tag === "DesktopTailcatIdentityError" ? "identity-failed" : tailcatFailureCode(error); + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const runtime = yield* TailcatRuntime.TailcatRuntime; + const identity = yield* DesktopTailcatIdentity.DesktopTailcatIdentity; + const net = yield* NetService.NetService; + const httpClient = yield* HttpClient.HttpClient; + const serviceScope = yield* Scope.Scope; + const entries = yield* Ref.make>(new Map()); + const locks = yield* Ref.make>(new Map()); + + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + // Created and published in one modify, so concurrent first callers share a lock. + const lockFor = (connectionId: string) => + Ref.modify(locks, (map) => { + const current = map.get(connectionId); + if (current !== undefined) { + return [current, map] as const; + } + const created = Semaphore.makeUnsafe(1); + return [created, new Map(map).set(connectionId, created)] as const; + }); + + const withLock = (connectionId: string, effect: Effect.Effect) => + lockFor(connectionId).pipe(Effect.flatMap((lock) => lock.withPermits(1)(effect))); + + const getEntry = (connectionId: string) => + Ref.get(entries).pipe(Effect.map((map) => Option.fromUndefinedOr(map.get(connectionId)))); + + const setEntry = (connectionId: string, entry: ForwardEntry) => + Ref.update(entries, (map) => new Map(map).set(connectionId, entry)); + + const patchEntry = (connectionId: string, patch: (entry: ForwardEntry) => ForwardEntry) => + Ref.update(entries, (map) => { + const current = map.get(connectionId); + return current === undefined ? map : new Map(map).set(connectionId, patch(current)); + }); + + const failure = (code: TailcatFailureCode, message: string): Effect.Effect => + nowIso.pipe(Effect.map((at) => ({ code, message, at }))); + + const runtimeInfo: Effect.Effect = runtime.resolve.pipe( + Effect.map((info): TailcatRuntimeInfo | null => info), + Effect.orElseSucceed(() => null), + ); + + const readiness = (endpoint: { readonly httpBaseUrl: string }) => + fetchRemoteEnvironmentDescriptor({ httpBaseUrl: endpoint.httpBaseUrl, timeoutMs: 4_000 }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.asVoid, + Effect.mapError( + (cause) => + new DesktopTailcatEnvironmentError({ + code: "remote-unavailable", + detail: `The T3 server did not answer through the tunnel: ${describe(cause)}`, + }), + ), + ); + + const stopRunning = (running: RunningForward) => + Fiber.interrupt(running.monitor).pipe( + Effect.andThen(Scope.close(running.scope, Exit.void).pipe(Effect.ignore)), + ); + + /** Starts (or restarts) the forward for an entry; the entry must be locked. */ + const startForward = ( + connectionId: string, + entry: ForwardEntry, + ): Effect.Effect => + Effect.gen(function* () { + const scope = yield* Scope.make("sequential"); + yield* setEntry(connectionId, { ...entry, status: "starting", running: null }); + const started = yield* identity + .withKeyFile((keyPath) => + runtime.forward({ + keyPath, + address: entry.address, + remotePort: entry.remotePort, + localPort: entry.localPort, + readiness, + readinessTimeout: TAILCAT_FORWARD_READINESS_TIMEOUT, + }), + ) + .pipe( + Scope.provide(scope), + Effect.mapError((error) => + isDesktopTailcatEnvironmentError(error) + ? new DesktopTailcatEnvironmentError({ + code: error.code, + detail: `${error.detail} The environment may be offline, have Tailcat access turned off, or have a new Tailcat identity (redeem a fresh connection code).`, + }) + : new DesktopTailcatEnvironmentError({ + code: failureCodeOf(error), + detail: error.message, + }), + ), + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + Effect.tapError((error) => + failure(error.code, error.detail).pipe( + Effect.flatMap((recorded) => + patchEntry(connectionId, (current) => ({ + ...current, + status: "failed", + running: null, + consecutiveFailures: current.consecutiveFailures + 1, + lastError: recorded, + })), + ), + ), + ), + ); + const startedAt = yield* nowIso; + const generation = entry.generation + 1; + // The monitor only starts watching once the ready entry is published, so + // an immediate exit cannot be recorded and then overwritten by "ready". + const published = yield* Deferred.make(); + const monitor = yield* Deferred.await(published).pipe( + Effect.andThen(started.exit), + Effect.flatMap((exitCode) => onForwardExit(connectionId, generation, exitCode)), + Effect.forkIn(serviceScope), + ); + const next: ForwardEntry = { + ...entry, + status: "ready", + running: { scope, handle: started, startedAt, monitor }, + lastError: null, + generation, + }; + yield* setEntry(connectionId, next); + yield* Deferred.succeed(published, undefined); + yield* Effect.logInfo("Tailcat forward ready.", { + localPort: entry.localPort, + remotePort: entry.remotePort, + pid: started.pid, + }); + return next; + }); + + /** + * Unexpected exit: record it and restart with backoff unless a newer forward replaced it. The + * generation guard makes a monitor from an older forward a no-op once the + * connection was re-ensured or restarted. + */ + const onForwardExit = ( + connectionId: string, + generation: number, + exitCode: Option.Option, + ) => + Effect.gen(function* () { + const current = yield* getEntry(connectionId); + if (Option.isNone(current) || current.value.generation !== generation) { + return; + } + const recorded = yield* failure( + "process-exited", + `The Tailcat forwarder exited${Option.isSome(exitCode) ? ` with code ${exitCode.value}` : ""}.`, + ); + // This exit is the connection's next consecutive failure (1 = first). + const failures = current.value.consecutiveFailures + 1; + yield* patchEntry(connectionId, (entry) => ({ + ...entry, + status: "failed", + running: null, + consecutiveFailures: failures, + lastError: recorded, + })); + yield* Effect.logWarning("Tailcat forward exited unexpectedly.", { + connectionId, + exitCode: Option.getOrNull(exitCode), + failures, + }); + if (failures > TAILCAT_FORWARD_MAX_RESTARTS) { + yield* Effect.logWarning("Tailcat forward gave up restarting; waiting for the client.", { + connectionId, + failures, + }); + return; + } + const random = yield* Random.next; + yield* Effect.sleep(Duration.millis(tailcatBackoffDelayMs(failures, random))); + yield* withLock( + connectionId, + Effect.gen(function* () { + const latest = yield* getEntry(connectionId); + if ( + Option.isNone(latest) || + latest.value.running !== null || + latest.value.generation !== generation + ) { + return; + } + yield* startForward(connectionId, { + ...latest.value, + restartCount: latest.value.restartCount + 1, + }).pipe(Effect.ignore); + }), + ); + }); + + const bootstrapOf = (connectionId: string, entry: ForwardEntry, running: RunningForward) => + identity.nodeKey.pipe( + Effect.mapError( + (error) => + new DesktopTailcatEnvironmentError({ + code: "identity-failed", + detail: error.message, + }), + ), + Effect.map((clientNodeKey): DesktopTailcatEnvironmentBootstrap => ({ + connectionId, + address: entry.address, + remotePort: entry.remotePort, + localPort: entry.localPort, + httpBaseUrl: running.handle.httpBaseUrl, + wsBaseUrl: running.handle.wsBaseUrl, + clientNodeKey, + })), + ); + + const ensureEnvironment: DesktopTailcatEnvironment["Service"]["ensureEnvironment"] = (input) => + withLock( + input.connectionId, + Effect.gen(function* () { + const existing = yield* getEntry(input.connectionId); + const running = Option.isSome(existing) ? existing.value.running : null; + if (Option.isSome(existing) && running !== null) { + const entry = existing.value; + const sameTarget = + entry.address === input.address && entry.remotePort === input.remotePort; + const alive = yield* running.handle.isRunning; + if (sameTarget && alive) { + // A healthy forward stays; a stale readiness only costs one probe. + const healthy = yield* readiness({ httpBaseUrl: running.handle.httpBaseUrl }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (healthy) { + // Fresh use resets the failure budget for the supervisor. + yield* patchEntry(input.connectionId, (current) => ({ + ...current, + consecutiveFailures: 0, + })); + return yield* bootstrapOf(input.connectionId, entry, running); + } + } + yield* stopRunning(running); + } + const localPort = + Option.isSome(existing) && existing.value.address === input.address + ? existing.value.localPort + : yield* net.reserveLoopbackPort().pipe( + Effect.mapError( + (error) => + new DesktopTailcatEnvironmentError({ + code: "port-in-use", + detail: `Could not reserve a loopback port: ${error.message}`, + }), + ), + ); + const resetFailures = + Option.isSome(existing) && + existing.value.lastError !== null && + DateTime.toEpochMillis(DateTime.makeUnsafe(existing.value.lastError.at)) + + TAILCAT_BACKOFF_RESET_AFTER_MS < + (yield* DateTime.now.pipe(Effect.map(DateTime.toEpochMillis))); + const base: ForwardEntry = { + address: input.address, + remotePort: input.remotePort, + localPort, + status: "starting", + running: null, + restartCount: Option.isSome(existing) ? existing.value.restartCount : 0, + consecutiveFailures: + Option.isSome(existing) && !resetFailures ? existing.value.consecutiveFailures : 0, + lastError: Option.isSome(existing) ? existing.value.lastError : null, + path: Option.isSome(existing) ? existing.value.path : null, + generation: Option.isSome(existing) ? existing.value.generation : 0, + }; + const started = yield* startForward(input.connectionId, base); + return yield* bootstrapOf(input.connectionId, started, started.running!); + }), + ); + + const restartEnvironment: DesktopTailcatEnvironment["Service"]["restartEnvironment"] = ( + connectionId, + ) => + withLock( + connectionId, + Effect.gen(function* () { + const existing = yield* getEntry(connectionId); + if (Option.isNone(existing)) { + return yield* new DesktopTailcatEnvironmentError({ + code: "unknown", + detail: "This Tailcat environment has no active tunnel to restart.", + }); + } + if (existing.value.running !== null) { + yield* stopRunning(existing.value.running); + } + const started = yield* startForward(connectionId, { + ...existing.value, + restartCount: existing.value.restartCount + 1, + consecutiveFailures: 0, + }); + return yield* bootstrapOf(connectionId, started, started.running!); + }), + ); + + const disconnectEnvironment: DesktopTailcatEnvironment["Service"]["disconnectEnvironment"] = ( + connectionId, + ) => + withLock( + connectionId, + Effect.gen(function* () { + const existing = yield* getEntry(connectionId); + if (Option.isNone(existing)) { + return; + } + if (existing.value.running !== null) { + yield* stopRunning(existing.value.running); + } + yield* Ref.update(entries, (map) => withoutKey(map, connectionId)); + yield* Effect.logInfo("Tailcat forward stopped.", { connectionId }); + }), + ); + + const diagnosticsOf = (connectionId: string, entry: ForwardEntry) => + Effect.gen(function* () { + const recentOutput = entry.running === null ? [] : yield* entry.running.handle.recentOutput; + const clientNodeKey = yield* identity.nodeKey.pipe(Effect.option); + return { + connectionId, + address: entry.address, + remotePort: entry.remotePort, + status: entry.status, + localEndpoint: entry.running === null ? null : entry.running.handle.httpBaseUrl, + pid: entry.running === null ? null : entry.running.handle.pid, + runtime: yield* runtimeInfo, + clientNodeKey: Option.getOrNull(clientNodeKey), + path: entry.path, + startedAt: entry.running === null ? null : entry.running.startedAt, + restartCount: entry.restartCount, + lastError: entry.lastError, + recentOutput, + } satisfies TailcatConnectionDiagnostics; + }); + + const diagnostics: DesktopTailcatEnvironment["Service"]["diagnostics"] = (connectionId) => + getEntry(connectionId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (entry) => diagnosticsOf(connectionId, entry).pipe(Effect.map(Option.some)), + }), + ), + ); + + const probePath: DesktopTailcatEnvironment["Service"]["probePath"] = (connectionId) => + Effect.gen(function* () { + const existing = yield* getEntry(connectionId); + if (Option.isNone(existing)) { + return Option.none(); + } + const probe = yield* identity + .withKeyFile((keyPath) => runtime.ping({ keyPath, address: existing.value.address })) + .pipe( + Effect.mapError( + (error) => + new DesktopTailcatEnvironmentError({ + code: failureCodeOf(error), + detail: error.message, + }), + ), + ); + yield* patchEntry(connectionId, (entry) => ({ ...entry, path: probe })); + return yield* diagnostics(connectionId); + }); + + // App shutdown takes every forwarder down with it. + yield* Effect.addFinalizer(() => + Ref.get(entries).pipe( + Effect.flatMap((map) => + Effect.forEach( + map.values(), + (entry) => + entry.running === null + ? Effect.void + : Scope.close(entry.running.scope, Exit.void).pipe(Effect.ignore), + { discard: true }, + ), + ), + ), + ); + + return DesktopTailcatEnvironment.of({ + ensureEnvironment, + restartEnvironment, + disconnectEnvironment, + diagnostics, + probePath, + }); +}); + +export const layer = Layer.effect(DesktopTailcatEnvironment, make); diff --git a/apps/desktop/src/tailcat/DesktopTailcatIdentity.test.ts b/apps/desktop/src/tailcat/DesktopTailcatIdentity.test.ts new file mode 100644 index 000000000000..c5d3f527fcc4 --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatIdentity.test.ts @@ -0,0 +1,262 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +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 Schema from "effect/Schema"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as DesktopTailcatIdentity from "./DesktopTailcatIdentity.ts"; + +const NODE_KEY = `nodekey:${"3c".repeat(32)}`; +const KEY_FILE_TEXT = `privkey:${"5a".repeat(32)}\n`; +const ENCRYPTED_PREFIX = "enc:"; +// The TestClock starts at the epoch, so the stored record's timestamp is fixed. +const EPOCH_ISO = "1970-01-01T00:00:00.000Z"; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const IdentityRecordJson = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(1), + nodeKey: Schema.String, + keyFile: Schema.String, + createdAt: Schema.String, + }), +); +const decodeIdentityRecord = Schema.decodeUnknownEffect(IdentityRecordJson); + +function makeSafeStorageLayer(encryptionAvailable: boolean) { + return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { + isEncryptionAvailable: Effect.succeed(encryptionAvailable), + encryptString: (value) => Effect.succeed(textEncoder.encode(`${ENCRYPTED_PREFIX}${value}`)), + decryptString: (value) => { + const decoded = textDecoder.decode(value); + return decoded.startsWith(ENCRYPTED_PREFIX) + ? Effect.succeed(decoded.slice(ENCRYPTED_PREFIX.length)) + : Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + cause: new Error("not encrypted by this test"), + }), + ); + }, + selectedStorageBackend: Effect.succeed(Option.none()), + } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); +} + +/** Only `stateDir` and `path` matter to the identity; the rest is a plausible desktop. */ +function makeEnvironmentLayer(baseDir: string) { + return DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "linux", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "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/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); +} + +/** A tailcat that writes a fixed private key wherever it is asked to. */ +function makeRuntimeLayer(generatedKeyPaths: Array) { + return Layer.unwrap( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return Layer.mock(TailcatRuntime.TailcatRuntime)({ + generateClientIdentity: ({ keyPath }) => + Effect.gen(function* () { + generatedKeyPaths.push(keyPath); + yield* fileSystem + .writeFileString(keyPath, KEY_FILE_TEXT, { mode: 0o600 }) + .pipe(Effect.orDie); + return { nodeKey: NODE_KEY }; + }), + }); + }), + ).pipe(Layer.provide(NodeServices.layer)); +} + +/** One fresh identity service instance over the desktop state below `baseDir`. */ +function makeIdentityLayer( + baseDir: string, + options: { + readonly encryptionAvailable: boolean; + readonly generatedKeyPaths: Array; + }, +) { + return DesktopTailcatIdentity.layer.pipe( + Layer.provide( + Layer.mergeAll( + makeEnvironmentLayer(baseDir), + makeSafeStorageLayer(options.encryptionAvailable), + makeRuntimeLayer(options.generatedKeyPaths), + NodeServices.layer, + ), + ), + ); +} + +const readIdentity = Effect.gen(function* () { + const identity = yield* DesktopTailcatIdentity.DesktopTailcatIdentity; + return { nodeKey: yield* identity.nodeKey, encrypted: yield* identity.encrypted }; +}); + +const withTempStateDirectory = ( + use: (paths: { + readonly baseDir: string; + readonly identityDir: string; + readonly tempDir: string; + readonly encryptedPath: string; + readonly plaintextPath: string; + }) => Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-tailcat-identity-" }); + // The desktop keeps packaged state under `/userdata`. + const identityDir = path.join( + baseDir, + "userdata", + DesktopTailcatIdentity.DESKTOP_TAILCAT_IDENTITY_DIRECTORY, + ); + return yield* use({ + baseDir, + identityDir, + tempDir: path.join(identityDir, "tmp"), + encryptedPath: path.join(identityDir, "client-identity.enc"), + plaintextPath: path.join(identityDir, "client-identity.private.json"), + }); + }).pipe(Effect.provide(NodeServices.layer)); + +describe("DesktopTailcatIdentity", () => { + it.effect("generates the identity once and stores it encrypted", () => + withTempStateDirectory((paths) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const generatedKeyPaths: Array = []; + const options = { encryptionAvailable: true, generatedKeyPaths }; + + const first = yield* readIdentity.pipe( + Effect.provide(makeIdentityLayer(paths.baseDir, options)), + ); + + assert.equal(first.nodeKey, NODE_KEY); + assert.isTrue(first.encrypted); + const [generatedKeyPath] = generatedKeyPaths; + assert(generatedKeyPath !== undefined); + assert.equal(generatedKeyPaths.length, 1); + assert.equal(path.dirname(generatedKeyPath), paths.tempDir); + assert.isFalse(yield* fileSystem.exists(generatedKeyPath)); + assert.isTrue(yield* fileSystem.exists(paths.encryptedPath)); + assert.isFalse(yield* fileSystem.exists(paths.plaintextPath)); + assert.deepEqual(yield* fileSystem.readDirectory(paths.tempDir), []); + + const stored = textDecoder.decode(yield* fileSystem.readFile(paths.encryptedPath)); + assert.isTrue(stored.startsWith(ENCRYPTED_PREFIX)); + const record = yield* decodeIdentityRecord(stored.slice(ENCRYPTED_PREFIX.length)); + assert.deepEqual(record, { + version: 1, + nodeKey: NODE_KEY, + keyFile: KEY_FILE_TEXT, + createdAt: EPOCH_ISO, + }); + + // A key file left behind by a crashed process is swept when the next instance starts. + yield* fileSystem.writeFileString(path.join(paths.tempDir, "stale.key"), "privkey:stale"); + const second = yield* readIdentity.pipe( + Effect.provide(makeIdentityLayer(paths.baseDir, options)), + ); + + assert.deepEqual(second, first); + assert.equal(generatedKeyPaths.length, 1); + assert.deepEqual(yield* fileSystem.readDirectory(paths.tempDir), []); + }), + ), + ); + + it.effect("materializes a private key file only for the duration of use", () => + withTempStateDirectory((paths) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const identity = yield* DesktopTailcatIdentity.DesktopTailcatIdentity; + + const observed = yield* identity.withKeyFile((keyPath) => + Effect.gen(function* () { + const info = yield* fileSystem.stat(keyPath); + return { + keyPath, + contents: yield* fileSystem.readFileString(keyPath), + mode: info.mode & 0o777, + }; + }), + ); + + assert.equal(path.dirname(observed.keyPath), paths.tempDir); + assert.equal(observed.contents, KEY_FILE_TEXT); + assert.equal(observed.mode, 0o600); + assert.isFalse(yield* fileSystem.exists(observed.keyPath)); + + const failure = yield* identity + .withKeyFile((keyPath) => Effect.fail({ _tag: "UseFailed" as const, keyPath })) + .pipe(Effect.flip); + + assert(failure._tag === "UseFailed"); + assert.notEqual(failure.keyPath, observed.keyPath); + assert.isFalse(yield* fileSystem.exists(failure.keyPath)); + assert.deepEqual(yield* fileSystem.readDirectory(paths.tempDir), []); + }).pipe( + Effect.provide( + makeIdentityLayer(paths.baseDir, { encryptionAvailable: true, generatedKeyPaths: [] }), + ), + ), + ), + ); + + it.effect("falls back to a private plaintext file when OS encryption is unavailable", () => + withTempStateDirectory((paths) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const generatedKeyPaths: Array = []; + const options = { encryptionAvailable: false, generatedKeyPaths }; + + const first = yield* readIdentity.pipe( + Effect.provide(makeIdentityLayer(paths.baseDir, options)), + ); + + assert.equal(first.nodeKey, NODE_KEY); + assert.isFalse(first.encrypted); + assert.isFalse(yield* fileSystem.exists(paths.encryptedPath)); + assert.isTrue(yield* fileSystem.exists(paths.plaintextPath)); + const info = yield* fileSystem.stat(paths.plaintextPath); + assert.equal(info.mode & 0o777, 0o600); + const record = yield* decodeIdentityRecord( + yield* fileSystem.readFileString(paths.plaintextPath), + ); + assert.equal(record.nodeKey, NODE_KEY); + assert.equal(record.keyFile, KEY_FILE_TEXT); + + const second = yield* readIdentity.pipe( + Effect.provide(makeIdentityLayer(paths.baseDir, options)), + ); + + assert.deepEqual(second, first); + assert.equal(generatedKeyPaths.length, 1); + }), + ), + ); +}); diff --git a/apps/desktop/src/tailcat/DesktopTailcatIdentity.ts b/apps/desktop/src/tailcat/DesktopTailcatIdentity.ts new file mode 100644 index 000000000000..40bb58e0effe --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatIdentity.ts @@ -0,0 +1,273 @@ +import { type TailcatNodeKey, tailcatNodeKeyFingerprint } from "@t3tools/contracts"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +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 Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; + +/** + * The desktop's Tailcat client identity: the private key T3 servers trust + * after a connection code is redeemed. It is stored encrypted with Electron's + * safeStorage (OS keychain / DPAPI / libsecret) and only materialized as a + * 0600 temp file for the moments a `tailcat` process needs to read it. + * + * When the OS offers no encryption backend, the key falls back to a 0600 + * plaintext file inside the desktop state directory, which is still private to + * the user account; the fallback is logged so support can see it. + */ + +export const DESKTOP_TAILCAT_IDENTITY_DIRECTORY = "tailcat"; +const ENCRYPTED_IDENTITY_FILE = "client-identity.enc"; +const PLAINTEXT_IDENTITY_FILE = "client-identity.private.json"; +const TEMP_DIRECTORY = "tmp"; + +const IdentityRecord = Schema.Struct({ + version: Schema.Literal(1), + nodeKey: Schema.String, + keyFile: Schema.String, + createdAt: Schema.String, +}); +type IdentityRecord = typeof IdentityRecord.Type; +const IdentityRecordJson = Schema.fromJsonString(IdentityRecord); +const decodeIdentityRecord = Schema.decodeUnknownEffect(IdentityRecordJson); +const encodeIdentityRecord = Schema.encodeEffect(IdentityRecordJson); + +export class DesktopTailcatIdentityError extends Schema.TaggedError()( + "DesktopTailcatIdentityError", + { + operation: Schema.Literals(["load", "generate", "store", "materialize"]), + detail: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Tailcat identity ${this.operation} failed: ${this.detail}`; + } +} + +export class DesktopTailcatIdentity extends Context.Service< + DesktopTailcatIdentity, + { + /** Public node key of this device, generating the identity on first use. */ + readonly nodeKey: Effect.Effect; + /** Whether the private key is protected by the OS encryption backend. */ + readonly encrypted: Effect.Effect; + /** + * Runs `use` with a temporary 0600 key file that is deleted afterwards, + * whatever the outcome. + */ + readonly withKeyFile: ( + use: (keyPath: string) => Effect.Effect, + ) => Effect.Effect; + } +>()("@t3tools/desktop/tailcat/DesktopTailcatIdentity") {} + +const describe = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + const runtime = yield* TailcatRuntime.TailcatRuntime; + const crypto = yield* Crypto.Crypto; + const lock = yield* Semaphore.make(1); + + const directory = path.join(environment.stateDir, DESKTOP_TAILCAT_IDENTITY_DIRECTORY); + const tempDirectory = path.join(directory, TEMP_DIRECTORY); + const encryptedPath = path.join(directory, ENCRYPTED_IDENTITY_FILE); + const plaintextPath = path.join(directory, PLAINTEXT_IDENTITY_FILE); + const cached = yield* Ref.make>( + Option.none(), + ); + + const ensureDirectories = Effect.gen(function* () { + yield* fileSystem.makeDirectory(tempDirectory, { recursive: true }).pipe(Effect.ignore); + yield* fileSystem.chmod(directory, 0o700).pipe(Effect.ignore); + yield* fileSystem.chmod(tempDirectory, 0o700).pipe(Effect.ignore); + }); + + // Temp key files from a previous crash must not outlive the process that + // needed them. + const sweepTempFiles = fileSystem.readDirectory(tempDirectory).pipe( + Effect.flatMap((entries) => + Effect.forEach( + entries, + (entry) => fileSystem.remove(path.join(tempDirectory, entry)).pipe(Effect.ignore), + { discard: true }, + ), + ), + Effect.ignore, + ); + yield* ensureDirectories; + yield* sweepTempFiles; + + const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe( + Effect.orElseSucceed(() => false), + ); + + const readStored = Effect.gen(function* () { + const encryptedExists = yield* fileSystem + .exists(encryptedPath) + .pipe(Effect.orElseSucceed(() => false)); + if (encryptedExists) { + const bytes = yield* fileSystem.readFile(encryptedPath); + const json = yield* safeStorage.decryptString(bytes); + const record = yield* decodeIdentityRecord(json); + return Option.some({ record, encrypted: true }); + } + const plaintextExists = yield* fileSystem + .exists(plaintextPath) + .pipe(Effect.orElseSucceed(() => false)); + if (plaintextExists) { + const json = yield* fileSystem.readFileString(plaintextPath); + const record = yield* decodeIdentityRecord(json); + return Option.some({ record, encrypted: false }); + } + return Option.none<{ record: IdentityRecord; encrypted: boolean }>(); + }).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "load", + detail: describe(cause), + cause, + }), + ), + ); + + const tempPath = crypto.randomUUIDv4.pipe( + Effect.map((uuid) => path.join(tempDirectory, `${uuid.replace(/-/g, "")}.key`)), + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "materialize", + detail: "Secure randomness is unavailable.", + cause, + }), + ), + ); + + const writePrivate = (filePath: string, contents: string) => + fileSystem + .writeFileString(filePath, contents, { mode: 0o600 }) + .pipe(Effect.andThen(fileSystem.chmod(filePath, 0o600).pipe(Effect.ignore))); + + const store = (record: IdentityRecord) => + Effect.gen(function* () { + const json = yield* encodeIdentityRecord(record); + if (yield* encryptionAvailable) { + const bytes = yield* safeStorage.encryptString(json); + yield* fileSystem.writeFile(encryptedPath, bytes, { mode: 0o600 }); + yield* fileSystem.chmod(encryptedPath, 0o600).pipe(Effect.ignore); + yield* fileSystem.remove(plaintextPath).pipe(Effect.ignore); + return true; + } + yield* Effect.logWarning( + "OS encryption is unavailable; the Tailcat client identity is stored as a private file.", + { path: plaintextPath }, + ); + yield* writePrivate(plaintextPath, json); + return false; + }).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "store", + detail: describe(cause), + cause, + }), + ), + ); + + const generate = Effect.gen(function* () { + const keyPath = yield* tempPath; + const generated = yield* runtime.generateClientIdentity({ keyPath }).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "generate", + detail: cause.message, + cause, + }), + ), + ); + const keyFile = yield* fileSystem.readFileString(keyPath).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "generate", + detail: describe(cause), + cause, + }), + ), + Effect.ensuring(fileSystem.remove(keyPath).pipe(Effect.ignore)), + ); + const record: IdentityRecord = { + version: 1, + nodeKey: generated.nodeKey, + keyFile, + createdAt: yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)), + }; + const encrypted = yield* store(record); + yield* Effect.logInfo("Created the Tailcat client identity.", { + nodeKeyFingerprint: tailcatNodeKeyFingerprint(generated.nodeKey), + encrypted, + }); + return { record, encrypted }; + }); + + const load = lock.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(cached); + if (Option.isSome(current)) { + return current.value; + } + const stored = yield* readStored; + const identity = Option.isSome(stored) ? stored.value : yield* generate; + yield* Ref.set(cached, Option.some(identity)); + return identity; + }), + ); + + const withKeyFile: DesktopTailcatIdentity["Service"]["withKeyFile"] = (use) => + Effect.gen(function* () { + const identity = yield* load; + const keyPath = yield* tempPath; + yield* writePrivate(keyPath, identity.record.keyFile).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "materialize", + detail: describe(cause), + cause, + }), + ), + ); + return yield* use(keyPath).pipe( + Effect.ensuring(fileSystem.remove(keyPath).pipe(Effect.ignore)), + ); + }); + + return DesktopTailcatIdentity.of({ + nodeKey: load.pipe(Effect.map((identity) => identity.record.nodeKey as TailcatNodeKey)), + encrypted: load.pipe( + Effect.map((identity) => identity.encrypted), + Effect.orElseSucceed(() => false), + ), + withKeyFile, + }); +}); + +export const layer = Layer.effect(DesktopTailcatIdentity, make); diff --git a/apps/desktop/src/tailcat/DesktopTailcatRuntime.ts b/apps/desktop/src/tailcat/DesktopTailcatRuntime.ts new file mode 100644 index 000000000000..be147547ed13 --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatRuntime.ts @@ -0,0 +1,68 @@ +import { tailcatExecutableName, tailcatPlatformKey } from "@t3tools/tailcat/manifest"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +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 DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +/** + * Where the desktop app looks for the Tailcat executable, in preference order: + * the developer override, the packaged `resources/tailcat//` + * directory, the dev `prod-resources` staging directory, and the monorepo's + * `native/tailcat/dist` output. A `tailcat` on PATH is the last resort and is + * still version-checked against the pinned manifest. + */ +function desktopTailcatBundledCandidates( + environment: DesktopEnvironment.DesktopEnvironment["Service"], +): ReadonlyArray { + const architecture = environment.processArch as NodeJS.Architecture; + const platformKey = tailcatPlatformKey(environment.platform, architecture); + if (platformKey === undefined) { + return []; + } + const packaged = TailcatRuntime.bundledTailcatCandidates({ + platform: environment.platform, + architecture, + joinPath: (...segments) => environment.path.join(...segments), + moduleDirectory: environment.resourcesPath, + repoRootCandidates: environment.isDevelopment ? [environment.rootDir] : [], + }); + const staged = environment.resolveResourcePathCandidates( + environment.path.join("tailcat", platformKey, tailcatExecutableName(environment.platform)), + ); + return Array.from(new Set([...packaged, ...staged])); +} + +/** First bundled candidate that exists on disk, for the backend bootstrap. */ +export const resolveDesktopTailcatBinaryPath = Effect.fn("desktop.tailcat.resolveBinaryPath")( + function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const override = yield* TailcatRuntime.tailcatOverridePathFromEnvironment; + if (override !== undefined) { + return Option.some(override); + } + for (const candidate of desktopTailcatBundledCandidates(environment)) { + if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + return Option.some(candidate); + } + } + return Option.none(); + }, +); + +export const layer = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const overridePath = yield* TailcatRuntime.tailcatOverridePathFromEnvironment; + return TailcatRuntime.layer({ + resolution: { + overridePath, + bundledCandidates: desktopTailcatBundledCandidates(environment), + allowSystem: true, + }, + }); + }), +); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index f7fa30c8f230..92cac0bfeab2 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -6,6 +6,7 @@ import { PrimaryEnvironmentAuth, RelayDeviceIdentity, SshEnvironmentGateway, + TailcatEnvironmentGateway, } from "@t3tools/client-runtime/platform"; import { ConnectionBlockedError, @@ -194,6 +195,28 @@ const capabilitiesLayer = Layer.effectContext( disconnect: () => Effect.void, }), ), + // A phone has no process to run the tailcat forwarder in, so Tailcat + // environments are set up (and saved) from the desktop app. + Context.add( + TailcatEnvironmentGateway, + TailcatEnvironmentGateway.of({ + provision: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "Tailcat environments are managed from the desktop app.", + }), + ), + prepare: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "Tailcat environments are managed from the desktop app.", + }), + ), + disconnect: () => Effect.void, + }), + ), ); }), ); diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 193927684794..f03694b70af5 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -5,8 +5,11 @@ import { extractPairingUrlFromQrPayload, PairingQrPayloadEmptyError, parsePairingUrl, + unsupportedPairingInputMessage, } from "./pairing"; +const TAILCAT_CODE = "t3c://tailcat/eyJ2IjoxfQ"; + describe("buildPairingUrl", () => { it("uses HTTP for a schemeless IP address", () => { expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe( @@ -62,3 +65,24 @@ describe("parsePairingUrl", () => { }); }); }); + +describe("unsupportedPairingInputMessage", () => { + it("guides Tailcat codes to the desktop app", () => { + expect(unsupportedPairingInputMessage(` ${TAILCAT_CODE} `)).toBe( + "This is a Tailcat connection code. Paste it in the desktop app under Add environment → Tailcat.", + ); + expect(unsupportedPairingInputMessage("t3c://mystery/abc")).toBe( + "This is a T3 connection code, not a pairing URL. Use it in the desktop app.", + ); + }); + + it("leaves pairing urls and hosts alone", () => { + expect(unsupportedPairingInputMessage("https://remote.example.com/#token=abc")).toBeNull(); + expect(unsupportedPairingInputMessage("192.168.1.100:3773")).toBeNull(); + expect(unsupportedPairingInputMessage("")).toBeNull(); + }); + + it("keeps a pasted connection code intact instead of mangling it into a host", () => { + expect(parsePairingUrl(TAILCAT_CODE)).toEqual({ host: TAILCAT_CODE, code: "" }); + }); +}); diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index ee9e4b9ec8f3..9dab45bdff9f 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -1,4 +1,5 @@ import { readHostedPairingRequest } from "@t3tools/shared/remote"; +import { isT3ConnectionCode, peekT3ConnectionCodeKind } from "@t3tools/shared/t3ConnectionCode"; import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; @@ -27,6 +28,20 @@ export class PairingQrPayloadEmptyError extends Schema.TaggedError { const nextPairingUrl = pairingUrl ?? connectionPairingUrl; setPendingConnectionError(null); + // Tailcat codes are redeemed by the desktop app; say so instead + // of letting the pairing resolver report an invalid URL. + const guidance = unsupportedPairingInputMessage(nextPairingUrl); + if (guidance !== null) { + setPendingConnectionError(guidance); + return AsyncResult.failure< + EnvironmentId, + ConnectionAttemptError | ConnectionPersistenceError + >(Cause.fail(new ConnectionBlockedError({ reason: "configuration", detail: guidance }))); + } const result = await controller.connectPairingUrl(nextPairingUrl); if (AsyncResult.isFailure(result)) { const error = Cause.squash(result.cause); diff --git a/apps/server/package.json b/apps/server/package.json index afa2ca18fdfc..aae1af605fb0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -42,6 +42,7 @@ "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/ssh": "workspace:*", + "@t3tools/tailcat": "workspace:*", "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", "@types/node": "catalog:", diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 481ffc2fad64..d0f1de3fcb7f 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -63,6 +63,14 @@ export interface IssuedBearerSession { readonly expiresAt: DateTime.Utc; } +/** A completed bootstrap exchange with the facts callers need beyond the wire result. */ +export interface BootstrapCredentialExchange { + readonly result: AuthAccessTokenResult; + readonly sessionId: AuthSessionId; + /** Subject of the redeemed grant, which names what kind of pairing it was. */ + readonly grantSubject: string; +} + export interface AuthenticatedSession { readonly sessionId: AuthSessionId; readonly subject: string; @@ -445,6 +453,18 @@ export class EnvironmentAuth extends Context.Service< AuthAccessTokenResult, ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError >; + /** Same exchange, also reporting which grant was consumed and the session it made. */ + readonly exchangeBootstrapCredential: ( + credential: string, + requestedScopes: ReadonlyArray | undefined, + requestMetadata: AuthClientMetadata, + input?: { + readonly proofKeyThumbprint?: string; + }, + ) => Effect.Effect< + BootstrapCredentialExchange, + ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError + >; readonly createPairingLink: (input?: { readonly ttl?: Duration.Duration; readonly label?: string; @@ -801,46 +821,52 @@ export const make = Effect.gen(function* () { ); }; - const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = - (credential, requestedScopes, requestMetadata, input) => - resolveBootstrapGrant(credential, input).pipe( - Effect.flatMap((grant) => - Effect.gen(function* () { - const grantedScopes = requestedScopes ?? grant.scopes; - if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { - return yield* new ServerAuthScopeNotGrantedError({}); - } - return yield* sessions - .issue({ - method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", - subject: grant.subject, - scopes: grantedScopes, - ...(input?.proofKeyThumbprint - ? { - proofKeyThumbprint: input.proofKeyThumbprint, - ttl: Duration.hours(1), - } - : {}), - // Desktop restarts forget the previous bearer token. Replace - // its session, including stale entries left by older versions. - replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap", - client: { - ...requestMetadata, - ...(grant.label ? { label: grant.label } : {}), - }, - }) - .pipe( - Effect.mapError( - (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), - ), - ); - }), - ), - Effect.flatMap((session) => - DateTime.now.pipe( - Effect.map( - (now) => - ({ + const exchangeBootstrapCredential: EnvironmentAuth["Service"]["exchangeBootstrapCredential"] = ( + credential, + requestedScopes, + requestMetadata, + input, + ) => + resolveBootstrapGrant(credential, input).pipe( + Effect.flatMap((grant) => + Effect.gen(function* () { + const grantedScopes = requestedScopes ?? grant.scopes; + if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { + return yield* new ServerAuthScopeNotGrantedError({}); + } + const session = yield* sessions + .issue({ + method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", + subject: grant.subject, + scopes: grantedScopes, + ...(input?.proofKeyThumbprint + ? { + proofKeyThumbprint: input.proofKeyThumbprint, + ttl: Duration.hours(1), + } + : {}), + // Desktop restarts forget the previous bearer token. Replace + // its session, including stale entries left by older versions. + replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap", + client: { + ...requestMetadata, + ...(grant.label ? { label: grant.label } : {}), + }, + }) + .pipe( + Effect.mapError( + (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), + ), + ); + return { grant, session }; + }), + ), + Effect.flatMap(({ grant, session }) => + DateTime.now.pipe( + Effect.map( + (now) => + ({ + result: { access_token: session.token, issued_token_type: AuthAccessTokenType, token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer", @@ -851,10 +877,20 @@ export const make = Effect.gen(function* () { ), ), scope: encodeOAuthScope(session.scopes), - }) satisfies AuthAccessTokenResult, - ), + } satisfies AuthAccessTokenResult, + sessionId: session.sessionId, + grantSubject: grant.subject, + }) satisfies BootstrapCredentialExchange, ), ), + ), + Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredential"), + ); + + const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = + (credential, requestedScopes, requestMetadata, input) => + exchangeBootstrapCredential(credential, requestedScopes, requestMetadata, input).pipe( + Effect.map((exchange) => exchange.result), Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), ); @@ -1100,6 +1136,7 @@ export const make = Effect.gen(function* () { getSessionState, createBrowserSession, exchangeBootstrapCredentialForAccessToken, + exchangeBootstrapCredential, createPairingLink, issuePairingCredential, issueStartupPairingCredential, diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index ec27c0a4e147..9bdee4079a9b 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -21,6 +21,8 @@ import * as ServerConfig from "../config.ts"; import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; export interface BootstrapGrant { + /** The pairing link id, when the grant came from a persisted link. */ + readonly id?: string; readonly method: ServerAuthBootstrapMethod; readonly scopes: ReadonlyArray; readonly subject: string; @@ -521,6 +523,7 @@ export const make = Effect.gen(function* () { if (Option.isSome(consumed)) { yield* emitRemoved(consumed.value.id); return { + id: consumed.value.id, method: consumed.value.method, scopes: consumed.value.scopes, subject: consumed.value.subject, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 8ab1520a6f34..8233d2d825cb 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -1,6 +1,7 @@ import { type DeviceListInput, AuthAccessReadScope, + AuthAccessWriteScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, @@ -169,6 +170,14 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, + // Tailcat remote access is administrative: it changes who can reach this + // server at the transport layer. + [WS_METHODS.tailcatSubscribeRemoteAccess]: AuthAccessReadScope, + [WS_METHODS.tailcatSetRemoteAccessEnabled]: AuthAccessWriteScope, + [WS_METHODS.tailcatCreateConnectionCode]: AuthAccessWriteScope, + [WS_METHODS.tailcatRevokeTrustedPeer]: AuthAccessWriteScope, + [WS_METHODS.tailcatRenameTrustedPeer]: AuthAccessWriteScope, + [WS_METHODS.tailcatRegenerateIdentity]: AuthAccessWriteScope, [WS_METHODS.subscribeBackgroundPolicy]: AuthOrchestrationReadScope, } as const satisfies Readonly>; diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts index 793f86349650..bb06d0380230 100644 --- a/apps/server/src/auth/http.test.ts +++ b/apps/server/src/auth/http.test.ts @@ -16,6 +16,7 @@ import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as TailcatRemoteAccess from "../tailcat/TailcatRemoteAccess.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./http.ts"; @@ -44,6 +45,8 @@ const environmentAuthLayer = EnvironmentAuth.layer.pipe( ); const routesLayer = HttpApiBuilder.layer(AuthTestApi).pipe( Layer.provide(authHttpApiLayer), + // The token route can record Tailcat trust; nothing here pairs over Tailcat. + Layer.provide(Layer.mock(TailcatRemoteAccess.TailcatRemoteAccess)({})), Layer.provide(environmentAuthenticatedAuthLayer), Layer.provideMerge(environmentAuthLayer), Layer.provide(configLayer), diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 0f927580367d..c169d3e27272 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -36,6 +36,9 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; +import { isTailcatNodeKey } from "@t3tools/tailcat/address"; +import { TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT } from "@t3tools/contracts"; +import * as TailcatRemoteAccess from "../tailcat/TailcatRemoteAccess.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; @@ -233,6 +236,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const tailcatRemoteAccess = yield* TailcatRemoteAccess.TailcatRemoteAccess; return handlers .handle( @@ -352,7 +356,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( ) : undefined; yield* appendCredentialResponseHeaders; - return yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + const exchange = yield* serverAuth.exchangeBootstrapCredential( args.payload.subject_token, requestedScopes, deriveAuthClientMetadata({ @@ -367,6 +371,34 @@ export const authHttpApiLayer = HttpApiBuilder.group( }), proofKeyThumbprint ? { proofKeyThumbprint } : undefined, ); + // Only a grant minted as a Tailcat connection code records the + // client's node key as a paired Tailcat device, so any other + // pairing link cannot claim one. + const tailcatNodeKey = args.payload.client_tailcat_node_key?.trim(); + if ( + tailcatNodeKey !== undefined && + exchange.grantSubject === TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT && + isTailcatNodeKey(tailcatNodeKey) + ) { + yield* tailcatRemoteAccess + .recordTrustedPeer({ + nodeKey: tailcatNodeKey, + label: args.payload.client_label, + sessionId: exchange.sessionId, + }) + .pipe( + Effect.catch((error) => + serverAuth + .revokeSession(exchange.sessionId) + .pipe( + Effect.andThen( + failEnvironmentInternal("access_token_issuance_failed", error), + ), + ), + ), + ); + } + return exchange.result; }, traceRelayRequest, Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index aecf82eeac67..c62f68e8da0a 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -123,6 +123,8 @@ const makeCliTestServerConfig = (baseDir: string) => logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, } satisfies ServerConfig.ServerConfig["Service"]; }); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index e30870aac873..30d983efdf7e 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -15,6 +15,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { isEntrypoint } from "./entrypoint.ts"; import { projectCommand } from "./cli/project.ts"; +import { remoteCommand } from "./cli/remote.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; import { uninstallCommand } from "./cli/uninstall.ts"; @@ -62,6 +63,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serveCommand, appCommand, pairCommand, + remoteCommand, authCommand, projectCommand, serviceCommand, diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index f37c65eac9ed..c914ecb4b069 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -24,6 +24,7 @@ import { type CliAuthLocationFlags, DurationFromString, resolveCliAuthConfig, + jsonFlag, } from "./config.ts"; const runWithEnvironmentAuth = ( @@ -56,11 +57,6 @@ const ttlFlag = Flag.String("ttl").pipe( Flag.optional, ); -const jsonFlag = Flag.Boolean("json").pipe( - Flag.withDescription("Emit JSON instead of human-readable output."), - Flag.withDefault(false), -); - const labelFlag = Flag.String("label").pipe( Flag.withDescription("Optional human-readable label."), Flag.optional, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 42932b927008..84e267073dae 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -235,6 +235,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); assert.equal(resolved.stateDir, join(baseDir, "userdata")); }), @@ -305,6 +307,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: true, tailscaleServePort: 8443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); assert.equal(resolved.dbPath, join(baseDir, "userdata", "state.sqlite")); }), @@ -378,6 +382,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }), ); @@ -461,6 +467,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); assert.equal(join(baseDir, "userdata"), resolved.stateDir); assert.equal(resolved.desktopTelemetryFd, 4); @@ -590,6 +598,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }), ); @@ -662,6 +672,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }), ); @@ -828,6 +840,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 62461e286748..56938a995b82 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -37,6 +37,10 @@ const hostFlag = Flag.String("host").pipe( Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), Flag.optional, ); +export const jsonFlag = Flag.Boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); export const baseDirFlag = Flag.String("base-dir").pipe( Flag.withDescription( "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME).", @@ -81,6 +85,12 @@ const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe( Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, ); +const tailcatFlag = Flag.Boolean("tailcat").pipe( + Flag.withDescription( + "Enable Tailcat remote access: serve this backend through an encrypted Tailcat tunnel and print a connection code.", + ), + Flag.optional, +); // Trace file location, shared by the server and `t3 trace summary`. export const traceFileConfig = Config.String("T3CODE_TRACE_FILE").pipe( @@ -162,6 +172,10 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + tailcatEnabled: Config.Boolean("T3CODE_TAILCAT").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), }); const DevAuthTokenConfig = Config.Redacted("T3CODE_DEV_AUTH_TOKEN").pipe( @@ -197,6 +211,7 @@ export interface CliServerFlags { readonly logWebSocketEvents: Option.Option; readonly tailscaleServeEnabled: Option.Option; readonly tailscaleServePort: Option.Option; + readonly tailcatEnabled?: Option.Option; } export interface CliAuthLocationFlags { @@ -231,6 +246,7 @@ export const sharedServerCommandFlags = { logWebSocketEvents: logWebSocketEventsFlag, tailscaleServeEnabled: tailscaleServeFlag, tailscaleServePort: tailscaleServePortFlag, + tailcatEnabled: tailcatFlag, } as const; const resolveOptionPrecedence = ( @@ -274,6 +290,7 @@ export const resolveServerConfig = ( logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + tailcatEnabled: flags.tailcatEnabled ?? Option.none(), } satisfies CliServerFlags; const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = @@ -381,6 +398,13 @@ export const resolveServerConfig = ( ), () => 443, ); + const tailcatEnabled = Option.getOrUndefined( + resolveOptionPrecedence( + normalizedFlags.tailcatEnabled ?? Option.none(), + Option.fromUndefinedOr(env.tailcatEnabled), + ), + ); + const tailcatBinaryPath = bootstrap?.tailcatBinaryPath; const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir(); const host = Option.getOrElse( resolveOptionPrecedence( @@ -458,6 +482,8 @@ export const resolveServerConfig = ( logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + tailcatEnabled, + tailcatBinaryPath, }; return config; diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 3427941bedeb..28a611f5dea2 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -47,7 +47,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; -import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { projectLocationFlags, resolveCliAuthConfig, jsonFlag } from "./config.ts"; import { resolveCliCommand } from "./invocation.ts"; import { bootServiceLayer, @@ -55,11 +55,6 @@ import { recoverServiceOnboardingOffer, } from "./service.ts"; -const jsonFlag = Flag.Boolean("json").pipe( - Flag.withDescription("Emit JSON instead of human-readable output."), - Flag.withDefault(false), -); - const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError); const headlessFlag = Flag.Boolean("headless").pipe( diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 04ce0332c14c..939c637fd47e 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -232,14 +232,14 @@ const probeEnvironmentDescriptor = ( return { _tag: "descriptor", descriptor } as const; }).pipe(Effect.catch((outcome) => Effect.succeed(outcome))); -interface DiscoveredPairTarget { +export interface DiscoveredPairTarget { readonly baseDir: string; readonly variant: PairStateVariant; readonly state: PersistedServerRuntimeState; readonly descriptor: ExecutionEnvironmentDescriptor; } -const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( +export const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( explicitBaseDir: string | undefined, ) { const bases: Array = []; @@ -299,7 +299,7 @@ const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( * choice pinned to where the runtime state was actually found, independent of * ambient environment variables. */ -const makePairServerConfig = Effect.fn(function* (input: { +export const makePairServerConfig = Effect.fn(function* (input: { readonly target: DiscoveredPairTarget; readonly logLevel: ServerConfig.ServerConfig["Service"]["logLevel"]; }) { @@ -346,6 +346,8 @@ const makePairServerConfig = Effect.fn(function* (input: { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }); diff --git a/apps/server/src/cli/remote.test.ts b/apps/server/src/cli/remote.test.ts new file mode 100644 index 000000000000..f12595ad8ed9 --- /dev/null +++ b/apps/server/src/cli/remote.test.ts @@ -0,0 +1,335 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentId, + type TailcatConnectionCodeResult, + TailcatRemoteAccessError, + TailcatRemoteAccessState, + WS_METHODS, + WsTailcatCreateConnectionCodeRpc, + WsTailcatRevokeTrustedPeerRpc, + WsTailcatSetRemoteAccessEnabledRpc, + WsTailcatSubscribeRemoteAccessRpc, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { RpcGroup } from "effect/unstable/rpc"; + +import { + captureJson, + captureStdout, + expectShowHelpError, + flipCli, + makeTempBaseDir, + withLiveCliServer, +} from "../testUtils/liveCliServer.ts"; +import { NoRunningServerError } from "./pair.ts"; +import { TailcatUnavailableError } from "./remote.ts"; +import { runningServerWsUrl } from "./runningServer.ts"; + +const TAILCAT_ADDRESS = "tcAbCdEfGhIjKlMnOpQrStUv"; +const NODE_KEY = `nodekey:${"0123456789abcdef".repeat(4)}`; + +const readyState: TailcatRemoteAccessState = { + enabled: true, + status: "ready", + address: TAILCAT_ADDRESS, + remotePort: 3773, + pairingOpen: false, + trustedPeers: [ + { + id: "peer-phone", + nodeKey: NODE_KEY, + label: "Phone", + createdAt: "2026-06-20T00:00:00.000Z", + lastSeenAt: "2026-06-21T08:30:00.000Z", + sessionIds: [], + }, + ], + runtime: { + executablePath: "/opt/t3/tailcat", + source: "bundled", + version: "1.4.0", + pinnedVersion: "1.4.0", + }, + identityFingerprint: "SHA256:remote-test", + lastError: null, + updatedAt: "2026-06-21T08:30:00.000Z", +}; + +const disabledState: TailcatRemoteAccessState = { + ...readyState, + enabled: false, + status: "disabled", + address: null, + remotePort: null, +}; + +const unavailableState: TailcatRemoteAccessState = { + ...disabledState, + status: "unavailable", + runtime: null, + lastError: { + code: "binary-missing", + message: "The tailcat binary was not found.", + at: "2026-06-21T08:30:00.000Z", + }, +}; + +const connectionCode: TailcatConnectionCodeResult = { + code: "t3c://tailcat/remote-test-code", + payload: { + v: 1, + transport: "tailcat", + address: TAILCAT_ADDRESS, + port: 3773, + environmentId: EnvironmentId.make("remote-test-environment"), + name: "remote-test", + serverVersion: "0.0.1", + pairingToken: "one-time", + expiresAt: "2026-06-21T08:35:00.000Z", + }, + pairingLinkId: "pairing-link-1", + expiresAt: "2026-06-21T08:35:00.000Z", +}; + +/** Only the Tailcat RPCs the CLI drives; the client is built from the full group and dispatches by tag. */ +const RemoteCliRpcs = RpcGroup.make( + WsTailcatSubscribeRemoteAccessRpc, + WsTailcatSetRemoteAccessEnabledRpc, + WsTailcatCreateConnectionCodeRpc, + WsTailcatRevokeTrustedPeerRpc, +); + +/** + * Scripted remote access over a state ref. Enabling reports "starting" the way + * the real service does before its listener is up, so `enable` has to follow + * the subscription until the state settles. + */ +const makeTailcatHandlersLayer = (stateRef: Ref.Ref) => + RemoteCliRpcs.toLayer({ + [WS_METHODS.tailcatSubscribeRemoteAccess]: () => Stream.fromEffect(Ref.get(stateRef)), + [WS_METHODS.tailcatSetRemoteAccessEnabled]: ({ enabled }) => + enabled + ? Ref.updateAndGet(stateRef, (state): TailcatRemoteAccessState => ({ + ...state, + enabled: true, + status: "ready", + address: TAILCAT_ADDRESS, + remotePort: 3773, + })).pipe(Effect.map((state) => ({ ...state, status: "starting" as const }))) + : Ref.updateAndGet(stateRef, (state) => ({ + ...state, + enabled: false, + status: "disabled", + address: null, + remotePort: null, + })), + [WS_METHODS.tailcatCreateConnectionCode]: () => Effect.succeed(connectionCode), + [WS_METHODS.tailcatRevokeTrustedPeer]: ({ peerId }) => + Effect.gen(function* () { + const current = yield* Ref.get(stateRef); + if (!current.trustedPeers.some((peer) => peer.id === peerId)) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "That device is no longer in the trusted list.", + }); + } + return yield* Ref.updateAndGet(stateRef, (state) => ({ + ...state, + trustedPeers: state.trustedPeers.filter((peer) => peer.id !== peerId), + })); + }), + }); + +const withLiveTailcatServer = ( + baseDir: string, + stateRef: Ref.Ref, + run: () => Effect.Effect, +) => + withLiveCliServer({ + baseDir, + rpcs: RemoteCliRpcs, + handlers: makeTailcatHandlersLayer(stateRef), + run, + }); + +const decodeStateJson = Schema.decodeUnknownEffect(Schema.fromJsonString(TailcatRemoteAccessState)); +const isTailcatRemoteAccessError = Schema.is(TailcatRemoteAccessError); +const isTailcatUnavailableError = Schema.is(TailcatUnavailableError); +const isNoRunningServerError = Schema.is(NoRunningServerError); + +it("derives the RPC socket URL from the server origin", () => { + assert.equal(runningServerWsUrl("http://127.0.0.1:3773"), "ws://127.0.0.1:3773/ws"); + assert.equal(runningServerWsUrl("https://[fd7a:115c::1]:3773"), "wss://[fd7a:115c::1]:3773/ws"); +}); + +it.layer(NodeServices.layer)("t3 remote tailcat", (it) => { + it.effect("registers every tailcat subcommand", () => + Effect.gen(function* () { + const output = yield* captureStdout(["remote", "tailcat", "--help"]); + + for (const subcommand of ["status", "enable", "disable", "code", "peers", "revoke"]) { + assert.include(output, subcommand); + } + assert.include(output, "Manage Tailcat remote access on the running server."); + }), + ); + + it.effect("rejects a missing or blank peer id before contacting any server", () => + Effect.gen(function* () { + expectShowHelpError(yield* flipCli(["remote", "tailcat", "revoke"]), "MissingArgument"); + expectShowHelpError(yield* flipCli(["remote", "tailcat", "revoke", " "]), "InvalidValue"); + }), + ); + + it.effect("reports remote access state and trusted peers from the running server", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("status"); + const stateRef = yield* Ref.make(readyState); + + yield* withLiveTailcatServer(baseDir, stateRef, () => + Effect.gen(function* () { + const status = yield* captureStdout([ + "remote", + "tailcat", + "status", + "--base-dir", + baseDir, + ]); + assert.include(status, "Tailcat remote access"); + assert.include(status, "Enabled: yes"); + assert.include(status, "Status: ready"); + assert.include(status, `Address: ${TAILCAT_ADDRESS}`); + assert.include(status, "Connection code: none active"); + assert.include(status, "Runtime: bundled 1.4.0 (pinned 1.4.0) at /opt/t3/tailcat"); + assert.include(status, "Trusted peers: 1"); + assert.include(status, "Last error: none"); + + const json = yield* captureJson([ + "remote", + "tailcat", + "status", + "--base-dir", + baseDir, + "--json", + ]); + assert.deepEqual(yield* decodeStateJson(json), readyState); + + const peers = yield* captureStdout(["remote", "tailcat", "peers", "--base-dir", baseDir]); + assert.include(peers, "peer-phone (Phone)"); + assert.include(peers, "node key: 0123·4567·cdef"); + assert.include(peers, "created: 2026-06-20T00:00:00.000Z"); + assert.include(peers, "last seen: 2026-06-21T08:30:00.000Z"); + }), + ); + }), + ); + + it.effect("enables, mints a connection code, revokes a peer, and disables again", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("toggle"); + const stateRef = yield* Ref.make(disabledState); + + yield* withLiveTailcatServer(baseDir, stateRef, () => + Effect.gen(function* () { + const enabled = yield* captureStdout([ + "remote", + "tailcat", + "enable", + "--base-dir", + baseDir, + ]); + assert.include(enabled, "Status: ready"); + assert.include(enabled, `Address: ${TAILCAT_ADDRESS}`); + assert.include(enabled, "Next: run `t3 remote tailcat code`"); + assert.isTrue((yield* Ref.get(stateRef)).enabled); + + const code = yield* captureStdout([ + "remote", + "tailcat", + "code", + "--base-dir", + baseDir, + "--label", + "Laptop", + ]); + assert.include(code, "Connection code (expires 2026-06-21T08:35:00.000Z, single use):"); + assert.include(code, "t3c://tailcat/remote-test-code"); + assert.include(code, "Paste the code in the T3 Code desktop app"); + assert.include(code, "one-time pairing credential"); + + const revoked = yield* captureStdout([ + "remote", + "tailcat", + "revoke", + "peer-phone", + "--base-dir", + baseDir, + ]); + assert.include(revoked, "Revoked trusted peer peer-phone. 0 trusted peer(s) remain."); + + // The server's typed failure surfaces with its own wording. + const revokedAgain = yield* flipCli([ + "remote", + "tailcat", + "revoke", + "peer-phone", + "--base-dir", + baseDir, + ]); + if (!isTailcatRemoteAccessError(revokedAgain)) { + assert.fail(`Expected TailcatRemoteAccessError, got ${String(revokedAgain)}`); + } + assert.equal(revokedAgain.message, "That device is no longer in the trusted list."); + + const disabled = yield* captureStdout([ + "remote", + "tailcat", + "disable", + "--base-dir", + baseDir, + ]); + assert.include(disabled, "Tailcat remote access is disabled."); + assert.isFalse((yield* Ref.get(stateRef)).enabled); + }), + ); + }), + ); + + it.effect("fails with the binary override hint when the server reports Tailcat unavailable", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("unavailable"); + const stateRef = yield* Ref.make(unavailableState); + + yield* withLiveTailcatServer(baseDir, stateRef, () => + Effect.gen(function* () { + const error = yield* flipCli(["remote", "tailcat", "status", "--base-dir", baseDir]); + + if (!isTailcatUnavailableError(error)) { + assert.fail(`Expected TailcatUnavailableError, got ${String(error)}`); + } + assert.equal(error.code, "binary-missing"); + assert.include(error.message, "The tailcat binary was not found."); + assert.include(error.message, "T3CODE_TAILCAT_BINARY"); + }), + ); + }), + ); + + it.effect("directs to t3 serve when no server is running", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("none"); + + const error = yield* flipCli(["remote", "tailcat", "status", "--base-dir", baseDir]); + + if (!isNoRunningServerError(error)) { + assert.fail(`Expected NoRunningServerError, got ${String(error)}`); + } + assert.include(error.message, "No running T3 Code server found."); + assert.include(error.message, "npx t3 serve"); + }), + ); +}); diff --git a/apps/server/src/cli/remote.ts b/apps/server/src/cli/remote.ts new file mode 100644 index 000000000000..167e6a10fdef --- /dev/null +++ b/apps/server/src/cli/remote.ts @@ -0,0 +1,409 @@ +/** + * `t3 remote tailcat ` - manage Tailcat remote access on the + * running T3 Code server: status, enable/disable, connection codes, and the + * trusted device list, over the same RPC methods the UIs use (see + * runningServer.ts for discovery and credentials). + */ +import { + type TailcatConnectionCodeResult, + TailcatFailureCode, + TailcatRemoteAccessError, + type TailcatRemoteAccessState, + TailcatServeStatus, + type TailcatTrustedPeer, + TrimmedNonEmptyString, + WS_METHODS, + isTailcatRuntimeUnavailable, + tailcatNodeKeyFingerprint, +} from "@t3tools/contracts"; +import { TAILCAT_BINARY_OVERRIDE_ENV } from "@t3tools/tailcat/runtime"; +import * as Console from "effect/Console"; +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 { Argument, Command, Flag } from "effect/unstable/cli"; + +import { formatTailcatConnectionCodeLines } from "../tailcat/startupOutput.ts"; +import { baseDirFlag, jsonFlag } from "./config.ts"; +import { + callRunningServer, + codeTtlFlag, + codeTtlInput, + RunningServerRequestError, + withRunningServerRpcClient, + type WsRpcClient, +} from "./runningServer.ts"; + +// Enabling starts the tailcat process and waits for it to report an address; +// a cold start with a DERP handshake is a few seconds, so wait up to 30s. +const ENABLE_SETTLE_TIMEOUT = Duration.seconds(30); + +const isTailcatRemoteAccessError = Schema.is(TailcatRemoteAccessError); + +export class TailcatUnavailableError extends Schema.TaggedError()( + "TailcatUnavailableError", + { + code: TailcatFailureCode, + detail: Schema.String, + }, +) { + override get message(): string { + return [ + `Tailcat is unavailable on this server (${this.code}): ${this.detail}`, + `Install tailcat and point ${TAILCAT_BINARY_OVERRIDE_ENV} at the binary, or reinstall T3 Code to restore the bundled runtime.`, + ].join("\n"); + } +} + +export class TailcatNotReadyError extends Schema.TaggedError()( + "TailcatNotReadyError", + { + status: TailcatServeStatus, + detail: Schema.String, + }, +) { + override get message(): string { + return `Tailcat remote access did not become ready (${this.status}): ${this.detail}`; + } +} + +// A missing or incompatible binary is the one Tailcat failure the user fixes +// on their own machine, so it gets the override hint; everything else is +// already worded for them by the server. +const tailcatCliErrorFromServer = ( + error: TailcatRemoteAccessError, +): TailcatRemoteAccessError | TailcatUnavailableError => + isTailcatRuntimeUnavailable(error.code) + ? new TailcatUnavailableError({ code: error.code, detail: error.message }) + : error; + +const tailcatUnavailableFromState = ( + state: TailcatRemoteAccessState, +): Option.Option => { + if (state.status !== "unavailable") { + return Option.none(); + } + return Option.some( + new TailcatUnavailableError({ + code: state.lastError?.code ?? "unknown", + detail: state.lastError?.message ?? "The Tailcat runtime is not available.", + }), + ); +}; + +const call = (operation: string, request: Effect.Effect) => + callRunningServer(operation, request, isTailcatRemoteAccessError).pipe( + Effect.mapError((cause) => + isTailcatRemoteAccessError(cause) ? tailcatCliErrorFromServer(cause) : cause, + ), + ); + +// The subscription replays the current state before any change. +const currentState = (client: WsRpcClient) => + call( + "tailcat.subscribeRemoteAccess", + client[WS_METHODS.tailcatSubscribeRemoteAccess]({}).pipe(Stream.runHead), + ).pipe( + Effect.flatMap((state) => + Effect.fromOption( + state, + () => + new RunningServerRequestError({ + operation: "tailcat.subscribeRemoteAccess", + cause: "The state stream ended before reporting a state.", + }), + ), + ), + ); + +const runTailcatCommand = ( + flags: { readonly baseDir: Option.Option; readonly json?: boolean }, + run: (client: WsRpcClient) => Effect.Effect, +) => + withRunningServerRpcClient({ + baseDir: flags.baseDir, + label: "t3 remote tailcat", + quietLogs: flags.json === true, + run, + }); + +const formatRuntime = (state: TailcatRemoteAccessState): string => + state.runtime === null + ? "not detected" + : `${state.runtime.source} ${state.runtime.version} (pinned ${state.runtime.pinnedVersion}) at ${state.runtime.executablePath}`; + +const formatTailcatStatus = ( + state: TailcatRemoteAccessState, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(state, null, 2); + } + const lastError = + state.lastError === null + ? "none" + : `${state.lastError.message} (${state.lastError.code}, ${state.lastError.at})`; + return [ + "Tailcat remote access", + ` Enabled: ${state.enabled ? "yes" : "no"}`, + ` Status: ${state.status}`, + ` Address: ${state.address ?? "none"}`, + ` Remote port: ${state.remotePort === null ? "none" : String(state.remotePort)}`, + ` Connection code: ${state.pairingOpen ? "active (not yet redeemed)" : "none active"}`, + ` Runtime: ${formatRuntime(state)}`, + ` Identity: ${state.identityFingerprint ?? "none"}`, + ` Trusted peers: ${String(state.trustedPeers.length)}`, + ` Last error: ${lastError}`, + ].join("\n"); +}; + +const formatTrustedPeers = ( + peers: ReadonlyArray, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify( + peers.map((peer) => ({ + id: peer.id, + label: peer.label, + nodeKeyFingerprint: tailcatNodeKeyFingerprint(peer.nodeKey), + createdAt: peer.createdAt, + lastSeenAt: peer.lastSeenAt, + })), + null, + 2, + ); + } + if (peers.length === 0) { + return "No trusted peers."; + } + return peers + .map((peer) => + [ + `${peer.id} (${peer.label})`, + ` node key: ${tailcatNodeKeyFingerprint(peer.nodeKey)}`, + ` created: ${peer.createdAt}`, + ` last seen: ${peer.lastSeenAt ?? "never"}`, + ].join("\n"), + ) + .join("\n\n"); +}; + +// Same shape as the `t3 serve --tailcat` startup output, so the code reads +// the same wherever the user sees it. +const formatConnectionCode = ( + issued: TailcatConnectionCodeResult, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(issued, null, 2); + } + return formatTailcatConnectionCodeLines(issued).join("\n"); +}; + +// Right after enabling, the service still reports "disabled" until its +// reconcile debounce fires, so an enabled-but-disabled state is not settled. +const isSettledTailcatState = (state: TailcatRemoteAccessState): boolean => + state.status !== "starting" && + state.status !== "restarting" && + !(state.enabled && state.status === "disabled"); + +/** Follows the state until it settles, or returns the last state seen once the wait runs out. */ +const awaitSettledTailcatState = (client: WsRpcClient, current: TailcatRemoteAccessState) => + client[WS_METHODS.tailcatSubscribeRemoteAccess]({}).pipe( + Stream.takeUntil(isSettledTailcatState), + Stream.interruptWhen(Effect.sleep(ENABLE_SETTLE_TIMEOUT)), + Stream.runLast, + Effect.map(Option.getOrElse(() => current)), + Effect.mapError((cause) => + isTailcatRemoteAccessError(cause) + ? tailcatCliErrorFromServer(cause) + : new RunningServerRequestError({ operation: "tailcat.subscribeRemoteAccess", cause }), + ), + ); + +const tailcatStatusCommand = Command.make("status", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Show Tailcat remote access state on the running server."), + Command.withHandler((flags) => + runTailcatCommand(flags, (client) => + Effect.gen(function* () { + const state = yield* currentState(client); + yield* Console.log(formatTailcatStatus(state, { json: flags.json })); + const unavailable = tailcatUnavailableFromState(state); + if (Option.isSome(unavailable)) { + return yield* unavailable.value; + } + }), + ), + ), +); + +const tailcatEnableCommand = Command.make("enable", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription( + "Enable Tailcat remote access and wait until the listener is ready or has failed.", + ), + Command.withHandler((flags) => + runTailcatCommand(flags, (client) => + Effect.gen(function* () { + const enabled = yield* call( + "tailcat.setRemoteAccessEnabled", + client[WS_METHODS.tailcatSetRemoteAccessEnabled]({ enabled: true }), + ); + const settled = isSettledTailcatState(enabled) + ? enabled + : yield* awaitSettledTailcatState(client, enabled); + yield* Console.log(formatTailcatStatus(settled, { json: flags.json })); + + const unavailable = tailcatUnavailableFromState(settled); + if (Option.isSome(unavailable)) { + return yield* unavailable.value; + } + switch (settled.status) { + case "ready": + if (!flags.json) { + yield* Console.log( + "\nNext: run `t3 remote tailcat code` to pair a device through this address.", + ); + } + return; + case "error": + return yield* new TailcatNotReadyError({ + status: settled.status, + detail: settled.lastError?.message ?? "The server reported an error.", + }); + case "disabled": + return yield* new TailcatNotReadyError({ + status: settled.status, + detail: settled.enabled + ? "The listener has not started yet; check `t3 remote tailcat status` in a moment." + : "Remote access was disabled again before the listener came up.", + }); + case "starting": + case "restarting": + return yield* new TailcatNotReadyError({ + status: settled.status, + detail: `still ${settled.status} after ${Duration.format( + ENABLE_SETTLE_TIMEOUT, + )}; check \`t3 remote tailcat status\` in a moment.`, + }); + case "unavailable": + // Handled above; kept so the switch stays exhaustive. + return; + } + }), + ), + ), +); + +const tailcatDisableCommand = Command.make("disable", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Disable Tailcat remote access on the running server."), + Command.withHandler((flags) => + runTailcatCommand(flags, (client) => + Effect.gen(function* () { + const state = yield* call( + "tailcat.setRemoteAccessEnabled", + client[WS_METHODS.tailcatSetRemoteAccessEnabled]({ enabled: false }), + ); + yield* Console.log( + flags.json + ? formatTailcatStatus(state, { json: true }) + : "Tailcat remote access is disabled. Trusted devices keep their entries and reconnect once it is enabled again.", + ); + }), + ), + ), +); + +const tailcatCodeCommand = Command.make("code", { + baseDir: baseDirFlag, + label: Flag.String("label").pipe( + Flag.withDescription("Optional label for the device that will redeem the code."), + Flag.optional, + ), + ttl: codeTtlFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Create a one-time Tailcat connection code for another device."), + Command.withHandler((flags) => + runTailcatCommand(flags, (client) => + Effect.gen(function* () { + const issued = yield* call( + "tailcat.createConnectionCode", + client[WS_METHODS.tailcatCreateConnectionCode]({ + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), + ...codeTtlInput(flags.ttl), + }), + ); + yield* Console.log(formatConnectionCode(issued, { json: flags.json })); + }), + ), + ), +); + +const tailcatPeersCommand = Command.make("peers", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("List the devices trusted to reach this server over Tailcat."), + Command.withHandler((flags) => + runTailcatCommand(flags, (client) => + Effect.gen(function* () { + const state = yield* currentState(client); + yield* Console.log(formatTrustedPeers(state.trustedPeers, { json: flags.json })); + }), + ), + ), +); + +const tailcatRevokeCommand = Command.make("revoke", { + baseDir: baseDirFlag, + peerId: Argument.String("peer-id").pipe( + Argument.withDescription("Trusted peer id to revoke, as listed by `peers`."), + Argument.withSchema(TrimmedNonEmptyString), + ), +}).pipe( + Command.withDescription( + "Revoke a trusted device. Its Tailcat access and the sessions it paired with end together.", + ), + Command.withHandler((flags) => + runTailcatCommand(flags, (client) => + Effect.gen(function* () { + const state = yield* call( + "tailcat.revokeTrustedPeer", + client[WS_METHODS.tailcatRevokeTrustedPeer]({ peerId: flags.peerId }), + ); + yield* Console.log( + `Revoked trusted peer ${flags.peerId}. ${String(state.trustedPeers.length)} trusted peer(s) remain.`, + ); + }), + ), + ), +); + +const tailcatCommand = Command.make("tailcat").pipe( + Command.withDescription("Manage Tailcat remote access on the running server."), + Command.withSubcommands([ + tailcatStatusCommand, + tailcatEnableCommand, + tailcatDisableCommand, + tailcatCodeCommand, + tailcatPeersCommand, + tailcatRevokeCommand, + ]), +); + +export const remoteCommand = Command.make("remote").pipe( + Command.withDescription("Manage how remote devices reach the running T3 Code server."), + Command.withSubcommands([tailcatCommand]), +); diff --git a/apps/server/src/cli/runningServer.ts b/apps/server/src/cli/runningServer.ts new file mode 100644 index 000000000000..631195fe243e --- /dev/null +++ b/apps/server/src/cli/runningServer.ts @@ -0,0 +1,195 @@ +/** + * Plumbing for CLI commands that manage the running T3 Code server + * (`t3 remote`). + * + * Discovery and credentials mirror `t3 pair`: the running server is found + * through the runtime state it persists next to its database, and every + * invocation mints a short-lived administrative session in that database, + * revoked when the command finishes. Commands then drive the server over the + * same WebSocket RPC surface the UIs use, carrying the session as a bearer + * header on the upgrade request. + */ +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import { + AuthAdministrativeScopes, + EnvironmentAuthorizationError, + WsRpcGroup, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +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 References from "effect/References"; +import * as Schema from "effect/Schema"; +import { Flag, GlobalFlag } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; +import { RpcClient, RpcClientError, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import { DurationFromString } from "./config.ts"; +import { type DiscoveredPairTarget, discoverPairTarget, makePairServerConfig } from "./pair.ts"; + +/** + * Bound for one unary call against the running server: generous for a busy + * disk, short enough that a wedged server does not hang the terminal. + */ +const RUNNING_SERVER_REQUEST_TIMEOUT = Duration.seconds(10); +const RPC_OPEN_TIMEOUT = Duration.seconds(10); + +const isEnvironmentAuthorizationError = Schema.is(EnvironmentAuthorizationError); +const isRpcClientError = Schema.is(RpcClientError.RpcClientError); + +/** The running server plus the administrative session minted for one CLI invocation. */ +interface RunningServerSession { + readonly target: DiscoveredPairTarget; + /** Origin the server listens on; HTTP and the RPC WebSocket both live here. */ + readonly origin: string; + readonly token: string; +} + +/** + * Anything the running server answered with that is not a typed Tailcat + * failure: rejected credentials, an internal error, a transport failure, or + * no answer at all. The cause stays attached for logs. + */ +export class RunningServerRequestError extends Schema.TaggedError()( + "RunningServerRequestError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const cause = this.cause; + if (isEnvironmentAuthorizationError(cause)) { + return `The running server rejected ${this.operation}: ${cause.message}`; + } + if (Cause.isTimeoutError(cause)) { + return `The running server did not answer ${this.operation} within ${Duration.format(RUNNING_SERVER_REQUEST_TIMEOUT)}.`; + } + if (isRpcClientError(cause)) { + return `Lost the connection to the running server during ${this.operation}.`; + } + return `Failed to call the running server (${this.operation}).`; + } +} + +/** + * Discover the running server, mint an administrative session in its database + * and run `run` with it. The session is revoked on the way out, including on + * interruption, so a Ctrl-C leaves nothing behind. + */ +const withRunningServerSession = Effect.fn("runningServer.withSession")(function* (input: { + readonly baseDir: Option.Option; + readonly label: string; + /** Machine-readable output must stay parseable, so `--json` raises the log floor to Error. */ + readonly quietLogs: boolean; + readonly run: (session: RunningServerSession) => Effect.Effect; +}) { + const cliLogLevel = yield* GlobalFlag.LogLevel; + // Default to Warn so storage/migration chatter cannot bury the output; an + // explicit --log-level still wins unless the output has to be JSON. + const logLevel = input.quietLogs + ? ("Error" as const) + : Option.getOrElse(cliLogLevel, () => "Warn" as const); + const target = yield* discoverPairTarget(Option.getOrUndefined(input.baseDir)); + const config = yield* makePairServerConfig({ target, logLevel }); + + return yield* Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + return yield* Effect.acquireUseRelease( + environmentAuth.issueSession({ scopes: AuthAdministrativeScopes, label: input.label }), + (issued) => input.run({ target, origin: target.state.origin, token: issued.token }), + (issued) => + environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), + ); + }).pipe( + Effect.provide( + EnvironmentAuth.runtimeLayer.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, logLevel)), + ), + ), + ); +}); + +/** The server's `/ws` route on the origin it recorded; the dev proxy is not involved on loopback. */ +export const runningServerWsUrl = (origin: string): string => { + const url = new URL("/ws", origin); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +}; + +// Node's `ws` client rather than the global WebSocket: the administrative +// bearer token has to ride on the upgrade request, and only `ws` takes headers. +// Socket.makeWebSocket only ever passes its `protocols` option here. +const bearerWebSocketConstructorLayer = (token: string) => + Layer.succeed( + Socket.WebSocketConstructor, + (url, protocols) => + new NodeSocket.NodeWS.WebSocket(url, protocols as string | string[] | undefined, { + headers: { authorization: `Bearer ${token}` }, + }) as unknown as globalThis.WebSocket, + ); + +const rpcProtocolLayer = (session: RunningServerSession) => + RpcClient.layerProtocolSocket().pipe( + Layer.provide( + Socket.layerWebSocket(runningServerWsUrl(session.origin), { + openTimeout: RPC_OPEN_TIMEOUT, + }).pipe(Layer.provide(bearerWebSocketConstructorLayer(session.token))), + ), + Layer.provide(RpcSerialization.layerJson), + ); + +const makeRpcClient = RpcClient.make(WsRpcGroup); +export type WsRpcClient = Effect.Success; + +/** Runs `run` with an RPC client to the running server, authenticated for this invocation only. */ +export const withRunningServerRpcClient = (input: { + readonly baseDir: Option.Option; + readonly label: string; + readonly quietLogs: boolean; + readonly run: (client: WsRpcClient) => Effect.Effect; +}) => + withRunningServerSession({ + baseDir: input.baseDir, + label: input.label, + quietLogs: input.quietLogs, + run: (session) => + Effect.scoped( + makeRpcClient.pipe(Effect.flatMap(input.run), Effect.provide(rpcProtocolLayer(session))), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + +/** + * Bounds a request to the running server and wraps anything that is not a + * typed server error (authorization, transport, no answer) so the user sees + * one consistent failure shape. + */ +export const callRunningServer = ( + operation: string, + request: Effect.Effect, + isTyped: (cause: E) => cause is T, +): Effect.Effect => + request.pipe( + Effect.timeout(RUNNING_SERVER_REQUEST_TIMEOUT), + Effect.mapError((cause) => + isTyped(cause as E) ? (cause as T) : new RunningServerRequestError({ operation, cause }), + ), + ); + +/** `--ttl` for one-time codes (`t3 remote tailcat code`). */ +export const codeTtlFlag = Flag.String("ttl").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription( + "How long the code stays redeemable, for example `5m` or `1h`. Defaults to 5 minutes.", + ), + Flag.optional, +); + +export const codeTtlInput = (ttl: Option.Option) => + Option.isSome(ttl) ? { ttlSeconds: Math.max(1, Math.round(Duration.toSeconds(ttl.value))) } : {}; diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d8dad5ae4d24..8427643d91e1 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -101,6 +101,10 @@ export class ServerConfig extends Context.Service< readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + /** Enable Tailcat remote access at startup (`t3 serve --tailcat`). */ + readonly tailcatEnabled: boolean | undefined; + /** Tailcat executable handed over by the desktop app's bootstrap. */ + readonly tailcatBinaryPath: string | undefined; } >()("t3/config/ServerConfig") { /** @deprecated Import and use `layerTest` from this module. */ @@ -229,6 +233,8 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, port: 0, host: undefined, desktopBootstrapToken: undefined, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 6eb95c84b477..8784d8d14cbd 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -67,6 +67,8 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, port: 0, host: undefined, desktopBootstrapToken: undefined, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index d58c014d86e8..921d9c5ab837 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -242,6 +242,7 @@ export const make = Effect.gen(function* () { threadPullRequestLinking: true, environmentIcon: true, projectCloneTracking: true, + tailcatRemoteAccess: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 6836d28e25c1..b923050d318c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -10,6 +10,8 @@ import { AuthStandardClientScopes, AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, + TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + TailcatRemoteAccessError, CommandId, DEFAULT_SERVER_SETTINGS, type DpopFailureReason, @@ -26,6 +28,7 @@ import { type OrchestrationThreadActivity, type OrchestrationThreadShell, TerminalNotRunningError, + type TailcatRemoteAccessState, type OrchestrationCommand, type OrchestrationEvent, ORCHESTRATION_WS_METHODS, @@ -58,6 +61,7 @@ import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; +import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -72,6 +76,7 @@ import * as Ref from "effect/Ref"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -110,6 +115,7 @@ import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import * as DeviceService from "./device/DeviceService.ts"; import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts"; +import * as TailcatRemoteAccess from "./tailcat/TailcatRemoteAccess.ts"; import { isThreadDetailEvent, resolveAvailableEditorsForConfig, @@ -554,6 +560,7 @@ const buildAppUnderTest = (options?: { serverLifecycleEvents?: Partial; serverRuntimeStartup?: Partial; serverEnvironment?: Partial; + tailcatRemoteAccess?: Partial; repositoryIdentityResolver?: Partial< RepositoryIdentityResolver.RepositoryIdentityResolver["Service"] >; @@ -605,6 +612,8 @@ const buildAppUnderTest = (options?: { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, ...options?.config, }; const layerConfig = ServerConfig.layer(config); @@ -760,6 +769,13 @@ const buildAppUnderTest = (options?: { Layer.provide(Layer.succeed(HostProcessEnvironment, {})), ); + // Defaults support auth/bootstrap; subscription tests provide stateful services. + const tailcatRemoteAccessLayer = Layer.mock(TailcatRemoteAccess.TailcatRemoteAccess)({ + readyEndpoint: Effect.succeed(Option.none()), + recordTrustedPeer: () => Effect.void, + start: () => Effect.void, + ...options?.layers?.tailcatRemoteAccess, + }); const servedRoutesLayer = HttpRouter.serve( // Viewed-file marks for a host that keeps none of its own are rows, so the routes want a // database. Its own, in memory: nothing here shares a table with the auth store. @@ -1072,7 +1088,7 @@ const buildAppUnderTest = (options?: { ); const appLayer = servedRoutesLayer.pipe( - Layer.provide(resourceTelemetryLayer), + Layer.provide(Layer.mergeAll(resourceTelemetryLayer, tailcatRemoteAccessLayer)), Layer.provide(UsageService.layerTest), Layer.provide( Layer.mock(AnalyticsService.AnalyticsService)({ @@ -1236,8 +1252,8 @@ const buildAppUnderTest = (options?: { Layer.provide(layerConfig), ); - yield* Layer.build(appLayer); - return config; + const context = yield* Layer.build(appLayer); + return { ...config, auth: Context.get(context, EnvironmentAuth.EnvironmentAuth) }; }); const parseSessionCookieFromWsUrl = ( @@ -1361,6 +1377,7 @@ const exchangeAccessToken = ( options?: { readonly headers?: Record; readonly scope?: string; + readonly tailcatNodeKey?: string; readonly clientMetadata?: { readonly label?: string; readonly deviceType?: string; @@ -1389,6 +1406,7 @@ const exchangeAccessToken = ( ? { client_device_type: options.clientMetadata.deviceType } : {}), ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}), + ...(options?.tailcatNodeKey ? { client_tailcat_node_key: options.tailcatNodeKey } : {}), }).toString(), }); const body = yield* responseJsonEffect<{ @@ -2420,6 +2438,57 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("rejects Tailcat pairing and revokes its session when peer persistence fails", () => + Effect.gen(function* () { + const persistenceFails = yield* Ref.make(true); + const { auth } = yield* buildAppUnderTest({ + layers: { + tailcatRemoteAccess: { + recordTrustedPeer: () => + Ref.get(persistenceFails).pipe( + Effect.flatMap((fails) => + fails + ? new TailcatRemoteAccessError({ + code: "unknown", + message: "Could not persist peer trust", + }) + : Effect.void, + ), + ), + }, + }, + }); + const grant = yield* auth.createPairingLink({ + subject: TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + scopes: AuthStandardClientScopes, + }); + const options = { + scope: AuthStandardClientScopes.join(" "), + tailcatNodeKey: "nodekey:9ab555a4a588b75d2054adb683db82461bb6c707d43e8ba39439f8eb1e821503", + }; + + const failed = yield* exchangeAccessToken(grant.credential, options); + assert.equal(failed.response.status, 500); + assert.equal(failed.body.reason, "access_token_issuance_failed"); + assert.isUndefined(failed.body.access_token); + assert.deepEqual(yield* auth.listSessions(), []); + const reused = yield* exchangeAccessToken(grant.credential, options); + assert.equal(reused.response.status, 401); + + yield* Ref.set(persistenceFails, false); + const replacement = yield* auth.createPairingLink({ + subject: TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + scopes: AuthStandardClientScopes, + }); + const paired = yield* exchangeAccessToken(replacement.credential, options); + assert.equal(paired.response.status, 200); + assert.equal(typeof paired.body.access_token, "string"); + const sessions = yield* auth.listSessions(); + assert.equal(sessions.length, 1); + assert.equal(sessions[0]?.subject, TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("persists token exchange client display metadata for authorized-client listings", () => Effect.gen(function* () { yield* buildAppUnderTest({ @@ -6483,6 +6552,59 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("streams the Tailcat snapshot once before subsequent updates", () => + Effect.gen(function* () { + const updatedAt = "2026-01-01T00:00:00.000Z"; + const nextUpdatedAt = "2026-01-01T00:00:01.000Z"; + const tailcat = yield* SubscriptionRef.make({ + enabled: false, + status: "disabled", + address: null, + remotePort: null, + pairingOpen: false, + trustedPeers: [], + runtime: null, + identityFingerprint: null, + lastError: null, + updatedAt, + }); + const replayed = yield* Deferred.make(); + yield* buildAppUnderTest({ + layers: { + tailcatRemoteAccess: { + state: SubscriptionRef.get(tailcat), + changes: SubscriptionRef.changes(tailcat).pipe( + Stream.tap(() => Deferred.succeed(replayed, undefined)), + ), + }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const firstReceived = yield* Deferred.make(); + const received = yield* client[WS_METHODS.tailcatSubscribeRemoteAccess]({}).pipe( + Stream.map((state) => state.updatedAt), + Stream.orDie, + Stream.tap(() => Deferred.succeed(firstReceived, undefined)), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(firstReceived); + yield* Deferred.await(replayed); + yield* SubscriptionRef.update(tailcat, (state) => ({ + ...state, + updatedAt: nextUpdatedAt, + })); + assert.deepEqual(yield* Fiber.join(received), [updatedAt, nextUpdatedAt]); + }), + ), + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4f264ae1cb0d..9fde59cd3f00 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -17,6 +17,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Random from "effect/Random"; import * as Schedule from "effect/Schedule"; +import * as Console from "effect/Console"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; @@ -165,6 +166,9 @@ import { persistServerRuntimeState, } from "./serverRuntimeState.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; +import * as TailcatRemoteAccess from "./tailcat/TailcatRemoteAccess.ts"; +import * as TailcatRuntimeLive from "./tailcat/TailcatRuntimeLive.ts"; +import { formatTailcatHeadlessOutput } from "./tailcat/startupOutput.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; @@ -561,7 +565,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( ), ); -const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( +const RuntimeBaseDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), Layer.provideMerge(ResourceDiagnosticsLayerLive), @@ -574,6 +578,13 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provide(NetService.layer), ); +// Tailcat exposes this environment's loopback listener, on top of the runtime +// above (auth, pairing links, secrets). +const RuntimeDependenciesLive = TailcatRemoteAccess.layer.pipe( + Layer.provide(TailcatRuntimeLive.layer), + Layer.provideMerge(RuntimeBaseDependenciesLive), +); + const commandReadinessLayer = HttpRouter.middleware( (httpEffect) => Effect.flatMap(ServerRuntimeStartup.ServerRuntimeStartup, (startup) => @@ -619,6 +630,7 @@ const makeServerLayer = Layer.unwrap( const activationLayer = Layer.succeed(ServerActivation, awaitActivation); const runtimeStateParked = yield* Deferred.make(); const tailscaleParked = yield* Deferred.make(); + const tailcatParked = yield* Deferred.make(); const cloudLinkParked = yield* Deferred.make(); const routesReady = yield* Deferred.make(); const launcherLayer = ServiceLauncherClient.layer; @@ -719,6 +731,54 @@ const makeServerLayer = Layer.unwrap( ), ) : Layer.empty; + // Tailcat learns the bound loopback port once the listener is up, then + // starts serving if remote access is enabled (persisted or `--tailcat`). + const tailcatStartLayer = Layer.effectDiscard( + Effect.gen(function* () { + yield* Deferred.succeed(tailcatParked, undefined).pipe(Effect.orDie); + yield* awaitActivation; + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return; + } + const remoteAccess = yield* TailcatRemoteAccess.TailcatRemoteAccess; + yield* remoteAccess.start({ localPort: address.port }); + if (config.tailcatEnabled !== true || config.startupPresentation !== "headless") { + return; + } + // Headless `t3 serve --tailcat`: print a one-time connection code once + // the Tailcat listener is reachable, like the pairing URL for HTTP. + yield* Effect.forkScoped( + // `changes` replays the current state first. + remoteAccess.changes.pipe( + Stream.filter( + (state) => + state.status === "ready" || + state.status === "error" || + state.status === "unavailable", + ), + Stream.runHead, + Effect.flatMap((settled) => + settled._tag === "Some" && settled.value.status === "ready" + ? remoteAccess + .createConnectionCode({}) + .pipe( + Effect.flatMap((issued) => + Console.log(formatTailcatHeadlessOutput(settled.value, issued)), + ), + ) + : Effect.logWarning("Tailcat remote access did not become ready.", { + error: settled._tag === "Some" ? settled.value.lastError : null, + }), + ), + Effect.catch((error) => + Effect.logWarning("Could not print the Tailcat connection code.", { error }), + ), + ), + ); + }), + ); const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( Effect.gen(function* () { const releaseManagedTunnel = releaseManagedTunnelOnShutdown().pipe( @@ -941,6 +1001,7 @@ const makeServerLayer = Layer.unwrap( Deferred.await(runtimeStateParked), Deferred.await(cloudLinkParked), Deferred.await(routesReady), + Deferred.await(tailcatParked), ...(config.tailscaleServeEnabled ? [Deferred.await(tailscaleParked)] : []), ], { concurrency: "unbounded" }, @@ -956,6 +1017,7 @@ const makeServerLayer = Layer.unwrap( httpListeningLayer, runtimeStateLayer.pipe(Layer.provide(launcherLayer)), tailscaleServeLayer, + tailcatStartLayer, cloudDesiredLinkReconcileLayer, ); diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index 43843b249eea..4c88a175d7e9 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -67,6 +67,8 @@ const configLayer = (overrides: Partial) = logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, port: 0, host: undefined, desktopBootstrapToken: undefined, diff --git a/apps/server/src/tailcat/TailcatRemoteAccess.test.ts b/apps/server/src/tailcat/TailcatRemoteAccess.test.ts new file mode 100644 index 000000000000..6b61a53a49f3 --- /dev/null +++ b/apps/server/src/tailcat/TailcatRemoteAccess.test.ts @@ -0,0 +1,509 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + AuthSessionId, + EnvironmentId, + type ExecutionEnvironmentDescriptor, + TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS, + TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + type TailcatAddress, + type TailcatNodeKey, + type TailcatRemoteAccessState, + type TailcatRuntimeInfo, + TailcatTrustedPeer, +} from "@t3tools/contracts"; +import { decodeTailcatConnectionCode } from "@t3tools/shared/t3ConnectionCode"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +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 Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as PairingGrantStore from "../auth/PairingGrantStore.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as TailcatRemoteAccess from "./TailcatRemoteAccess.ts"; + +// Captured from a real `tailcat serve` run; decodes to server key 7ea7…ff32. +const SERVER_ADDRESS: TailcatAddress = + "tco2FwWCB-p3FjjOrzlCPp0w8aT3p9xDZ1nNaXWX_dASxDCFT_MmFrWCDRnh2-iykbZ7W4Fl0g3nBpwTnR3iXVCKKCk4pps47ndGFpGQEu"; +const SERVER_FINGERPRINT = "7ea7·7163·ff32"; +const PEER_NODE_KEY: TailcatNodeKey = + "nodekey:9ab555a4a588b75d2054adb683db82461bb6c707d43e8ba39439f8eb1e821503"; +const LOCAL_PORT = 3773; +/** Ceiling of the first-failure restart backoff (1s base plus 25% jitter). */ +const FIRST_RETRY_BACKOFF_MAX = Duration.millis(1_250); +const RUNTIME_INFO: TailcatRuntimeInfo = { + executablePath: "/opt/t3/bin/tailcat", + source: "bundled", + version: "0.4.2", + pinnedVersion: "0.4.2", +}; +const ENVIRONMENT_ID = EnvironmentId.make("environment-tailcat-test"); +const DESCRIPTOR: ExecutionEnvironmentDescriptor = { + environmentId: ENVIRONMENT_ID, + label: "Tailcat test environment", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, +}; + +interface FakeServe { + readonly options: { + readonly keyPath: string; + readonly localPort: number; + }; + /** Complete this to simulate the listener process dying. */ + readonly exit: Deferred.Deferred>; + /** False once the owning scope closed, i.e. the service stopped this listener. */ + readonly isRunning: Effect.Effect; +} + +/** Records what the service asked of the tailcat runtime; `Queue.take` is the receipt for a (re)started listener. */ +class FakeTailcat extends Context.Service< + FakeTailcat, + { + readonly serves: Queue.Queue; + readonly identityGenerations: Ref.Ref; + } +>()("t3/tailcat/TailcatRemoteAccess.test/FakeTailcat") { + static readonly layer = Layer.effect( + FakeTailcat, + Effect.gen(function* () { + return FakeTailcat.of({ + serves: yield* Queue.unbounded(), + identityGenerations: yield* Ref.make(0), + }); + }), + ); +} + +const fakeRuntimeLayer = Layer.unwrap( + Effect.gen(function* () { + const fake = yield* FakeTailcat; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let nextPid = 40_000; + return Layer.mock(TailcatRuntime.TailcatRuntime)({ + resolve: Effect.succeed(RUNTIME_INFO), + refresh: Effect.succeed(RUNTIME_INFO), + generateServerIdentity: ({ keyPath }) => + Effect.gen(function* () { + yield* fileSystem.makeDirectory(path.dirname(keyPath), { recursive: true }); + yield* fileSystem.writeFileString(keyPath, "fake tailcat identity"); + yield* Ref.update(fake.identityGenerations, (count) => count + 1); + return { address: SERVER_ADDRESS }; + }).pipe(Effect.orDie), + serve: (options) => + Effect.gen(function* () { + const exit = yield* Deferred.make>(); + const running = yield* Ref.make(true); + const stop = Ref.set(running, false).pipe( + Effect.andThen(Deferred.succeed(exit, Option.none())), + Effect.asVoid, + ); + yield* Effect.addFinalizer(() => stop); + const handle: TailcatRuntime.TailcatServeHandle = { + pid: nextPid++, + address: SERVER_ADDRESS, + localPort: options.localPort, + exit: Deferred.await(exit), + isRunning: Ref.get(running), + recentOutput: Effect.succeed([`listening on 127.0.0.1:${options.localPort}`]), + stop, + }; + yield* Queue.offer(fake.serves, { options, exit, isRunning: Ref.get(running) }); + return handle; + }), + }); + }), +).pipe(Layer.provideMerge(FakeTailcat.layer)); + +const authLayer = EnvironmentAuth.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStore.layer), + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ + getEnvironmentId: Effect.succeed(ENVIRONMENT_ID), + }), + ), +); + +const serverEnvironmentLayer = Layer.mock(ServerEnvironment.ServerEnvironment)({ + getEnvironmentId: Effect.succeed(ENVIRONMENT_ID), + getDescriptor: Effect.succeed(DESCRIPTOR), +}); + +const makeTestLayer = (beforeStateWrite = Effect.void) => + TailcatRemoteAccess.layer.pipe( + Layer.updateService(FileSystem.FileSystem, (fileSystem) => ({ + ...fileSystem, + rename: (from, to) => beforeStateWrite.pipe(Effect.andThen(fileSystem.rename(from, to))), + })), + Layer.provideMerge(fakeRuntimeLayer), + Layer.provideMerge(authLayer), + Layer.provide(serverEnvironmentLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-tailcat-remote-access-test-" }), + ), + ); + +/** Pauses one persisted-state replacement after its snapshot has been derived. */ +const makeWriteBarrier = Effect.gen(function* () { + const armed = yield* Ref.make(false); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const beforeWrite = Effect.gen(function* () { + if (yield* Ref.getAndSet(armed, false)) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + }); + return { armed, started, release, beforeWrite }; +}); + +const PersistedStateJson = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(1), + enabled: Schema.Boolean, + trustedPeers: Schema.Array(TailcatTrustedPeer), + }), +); +const decodePersistedState = Schema.decodeUnknownSync(PersistedStateJson); + +const readPersistedState = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const raw = yield* fileSystem.readFileString( + path.join(config.stateDir, TailcatRemoteAccess.TAILCAT_REMOTE_ACCESS_STATE_FILE), + ); + return decodePersistedState(raw); +}); + +/** + * `changes` only carries publishes made after subscribing, so the watcher is + * forked (and subscribed) synchronously before the caller triggers anything. + * Join it to get the first published state matching `predicate`. + */ +const watchState = (predicate: (state: TailcatRemoteAccessState) => boolean) => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + return yield* Effect.forkChild( + service.changes.pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)), + { startImmediately: true }, + ); + }); + +/** Binds the service to the local port, enables it, and waits for the first listener. */ +const startEnabled = Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const ready = yield* watchState((state) => state.status === "ready"); + yield* service.start({ localPort: LOCAL_PORT }); + const enabled = yield* service.setEnabled(true); + const serve = yield* Queue.take(fake.serves); + const state = yield* Fiber.join(ready); + return { enabled, serve, state }; +}); + +it.layer(NodeServices.layer)("TailcatRemoteAccess", (it) => { + it.effect.each([ + ["different devices", `nodekey:${"ab".repeat(32)}` as TailcatNodeKey], + ["the same device", PEER_NODE_KEY], + ] as const)("preserves concurrent pairings from %s", ([_label, secondNodeKey]) => + Effect.gen(function* () { + const barrier = yield* makeWriteBarrier; + yield* Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const firstSession = AuthSessionId.make("session-first"); + const secondSession = AuthSessionId.make("session-second"); + yield* Ref.set(barrier.armed, true); + const first = yield* service + .recordTrustedPeer({ nodeKey: PEER_NODE_KEY, label: undefined, sessionId: firstSession }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(barrier.started); + const second = yield* service + .recordTrustedPeer({ nodeKey: secondNodeKey, label: undefined, sessionId: secondSession }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(barrier.release, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + + const saved = yield* readPersistedState; + expect(saved.trustedPeers.map((peer) => peer.nodeKey)).toEqual([ + ...new Set([PEER_NODE_KEY, secondNodeKey]), + ]); + expect(saved.trustedPeers.flatMap((peer) => peer.sessionIds)).toEqual([ + firstSession, + secondSession, + ]); + expect((yield* service.state).trustedPeers).toEqual(saved.trustedPeers); + }).pipe(Effect.provide(makeTestLayer(barrier.beforeWrite))); + }), + ); + + it.effect("preserves disabling access while a peer is being recorded", () => + Effect.gen(function* () { + const barrier = yield* makeWriteBarrier; + yield* Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + yield* service.setEnabled(true); + yield* Ref.set(barrier.armed, true); + const pairing = yield* service + .recordTrustedPeer({ nodeKey: PEER_NODE_KEY, label: undefined }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(barrier.started); + const disabling = yield* service + .setEnabled(false) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(barrier.release, undefined); + yield* Fiber.join(pairing); + yield* Fiber.join(disabling); + + const saved = yield* readPersistedState; + expect(saved.enabled).toBe(false); + expect(saved.trustedPeers.map((peer) => peer.nodeKey)).toEqual([PEER_NODE_KEY]); + expect(yield* service.state).toMatchObject({ + enabled: false, + trustedPeers: saved.trustedPeers, + }); + }).pipe(Effect.provide(makeTestLayer(barrier.beforeWrite))); + }), + ); + + it.effect("stays disabled and spawns nothing while remote access is off", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + // `start` publishes nothing itself; the first publish is its reconcile pass. + const reconciled = yield* watchState(() => true); + + yield* service.start({ localPort: LOCAL_PORT }); + const state = yield* Fiber.join(reconciled); + + expect(state).toMatchObject({ + enabled: false, + status: "disabled", + address: null, + pairingOpen: false, + trustedPeers: [], + runtime: null, + identityFingerprint: null, + lastError: null, + }); + expect(yield* Queue.size(fake.serves)).toBe(0); + expect(yield* Ref.get(fake.identityGenerations)).toBe(0); + expect(yield* service.readyEndpoint).toEqual(Option.none()); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("enabling creates the identity once and serves the listener", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const { enabled, serve, state } = yield* startEnabled; + + // setEnabled answers immediately; the listener comes up on the next reconcile pass. + expect(enabled.enabled).toBe(true); + expect(enabled.status).toBe("disabled"); + expect(serve.options).toEqual({ + keyPath: path.join(config.secretsDir, TailcatRemoteAccess.TAILCAT_SERVER_IDENTITY_FILE), + localPort: LOCAL_PORT, + }); + expect(yield* Ref.get(fake.identityGenerations)).toBe(1); + expect(yield* fileSystem.exists(serve.options.keyPath)).toBe(true); + expect(state).toMatchObject({ + enabled: true, + status: "ready", + address: SERVER_ADDRESS, + remotePort: LOCAL_PORT, + pairingOpen: false, + trustedPeers: [], + runtime: RUNTIME_INFO, + identityFingerprint: SERVER_FINGERPRINT, + lastError: null, + }); + expect(yield* service.readyEndpoint).toEqual( + Option.some({ address: SERVER_ADDRESS, port: LOCAL_PORT }), + ); + expect((yield* readPersistedState).enabled).toBe(true); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect( + "a connection code carries a one-time pairing token without restarting the listener", + () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const pairingLinks = yield* PairingGrantStore.PairingGrantStore; + const { serve: listener } = yield* startEnabled; + + const opened = yield* watchState((state) => state.pairingOpen && state.status === "ready"); + const issuedAt = yield* Clock.currentTimeMillis; + const result = yield* service.createConnectionCode({}); + const payload = decodeTailcatConnectionCode(result.code); + + expect(result.code.startsWith("t3c://tailcat/")).toBe(true); + expect(payload).toEqual(result.payload); + expect(payload).toMatchObject({ + v: 1, + transport: "tailcat", + address: SERVER_ADDRESS, + port: LOCAL_PORT, + environmentId: ENVIRONMENT_ID, + name: DESCRIPTOR.label, + serverVersion: DESCRIPTOR.serverVersion, + expiresAt: result.expiresAt, + }); + expect(Date.parse(result.expiresAt) - issuedAt).toBe( + TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS * 1_000, + ); + const link = (yield* pairingLinks.listActive()).find( + (candidate) => candidate.id === result.pairingLinkId, + ); + expect(link?.subject).toBe(TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT); + + const state = yield* Fiber.join(opened); + + // The listener already admits any Tailcat node, so tunnels stay up. + expect(yield* listener.isRunning).toBe(true); + expect(yield* Queue.size(fake.serves)).toBe(0); + expect(state.address).toBe(SERVER_ADDRESS); + + // Pairing lists no longer carry credentials, so redeem the code's token to + // prove it is the credential of the link the service reported. + if (payload.pairingToken === undefined) throw new Error("Expected a pairing token"); + const grant = yield* pairingLinks.consume(payload.pairingToken); + expect(grant.id).toBe(result.pairingLinkId); + expect(grant.subject).toBe(TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("reports the pairing window closed once the connection code expires", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const { serve: listener } = yield* startEnabled; + const opened = yield* watchState((state) => state.pairingOpen); + yield* service.createConnectionCode({ ttlSeconds: 60 }); + yield* Fiber.join(opened); + + const closed = yield* watchState((state) => !state.pairingOpen && state.status === "ready"); + // Past the code's expiry, plus the service's grace second. + yield* TestClock.adjust(Duration.seconds(61)); + const state = yield* Fiber.join(closed); + + expect(state.pairingOpen).toBe(false); + expect(yield* listener.isRunning).toBe(true); + expect(yield* Queue.size(fake.serves)).toBe(0); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("paired devices are persisted and revocable without restarting the listener", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const { serve: listener } = yield* startEnabled; + const sessionId = AuthSessionId.make("session-julius-iphone"); + + yield* service.recordTrustedPeer({ + nodeKey: PEER_NODE_KEY, + label: " Julius iPhone ", + sessionId, + }); + const recorded = yield* service.state; + expect(recorded.trustedPeers).toHaveLength(1); + const peer = recorded.trustedPeers[0]!; + expect(peer).toMatchObject({ + nodeKey: PEER_NODE_KEY, + label: "Julius iPhone", + sessionIds: [sessionId], + }); + expect((yield* readPersistedState).trustedPeers).toEqual([peer]); + + const revoked = yield* service.revokeTrustedPeer(peer.id); + expect(revoked.trustedPeers).toEqual([]); + expect((yield* readPersistedState).trustedPeers).toEqual([]); + + // Revoking ends the device's T3 sessions; other devices keep their tunnels. + expect(yield* listener.isRunning).toBe(true); + expect(yield* Queue.size(fake.serves)).toBe(0); + + const missing = yield* Effect.flip(service.revokeTrustedPeer(peer.id)); + expect(missing.code).toBe("unknown"); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("an unexpected listener exit is reported and retried after the backoff", () => + Effect.gen(function* () { + const fake = yield* FakeTailcat; + const { serve: first } = yield* startEnabled; + + const failed = yield* watchState((state) => state.status === "error"); + yield* Deferred.succeed(first.exit, Option.some(1)); + const errorState = yield* Fiber.join(failed); + + expect(errorState.lastError).toMatchObject({ code: "process-exited" }); + expect(errorState.lastError?.message).toContain("exited (1)"); + // A transient failure keeps the stable address; only permanent ones drop it. + expect(errorState.address).toBe(SERVER_ADDRESS); + + const restarted = yield* watchState( + (state) => state.status === "ready" && state.lastError === null, + ); + yield* TestClock.adjust(FIRST_RETRY_BACKOFF_MAX); + yield* Queue.take(fake.serves); + const readyState = yield* Fiber.join(restarted); + + expect(readyState.address).toBe(SERVER_ADDRESS); + // The identity file survived the restart, so no new address was minted. + expect(yield* Ref.get(fake.identityGenerations)).toBe(1); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("disabling stops the listener and reports disabled", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const { serve } = yield* startEnabled; + + const disabled = yield* watchState((state) => state.status === "disabled"); + const returned = yield* service.setEnabled(false); + expect(returned.enabled).toBe(false); + + const state = yield* Fiber.join(disabled); + + expect(state).toMatchObject({ + enabled: false, + status: "disabled", + address: null, + identityFingerprint: null, + lastError: null, + }); + expect(yield* serve.isRunning).toBe(false); + expect(yield* service.readyEndpoint).toEqual(Option.none()); + expect((yield* readPersistedState).enabled).toBe(false); + expect(yield* Queue.size(fake.serves)).toBe(0); + }).pipe(Effect.provide(makeTestLayer())), + ); +}); diff --git a/apps/server/src/tailcat/TailcatRemoteAccess.ts b/apps/server/src/tailcat/TailcatRemoteAccess.ts new file mode 100644 index 000000000000..304c2079237f --- /dev/null +++ b/apps/server/src/tailcat/TailcatRemoteAccess.ts @@ -0,0 +1,723 @@ +import { + AuthStandardClientScopes, + type AuthSessionId, + TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS, + TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + type TailcatAddress, + type TailcatConnectionCodeResult, + type TailcatCreateConnectionCodeInput, + type TailcatFailure, + type TailcatFailureCode, + type TailcatNodeKey, + TailcatRemoteAccessError, + type TailcatRemoteAccessState, + type TailcatRuntimeInfo, + type TailcatServeStatus, + TailcatTrustedPeer, + tailcatNodeKeyFingerprint, + isTailcatRuntimeUnavailable, +} from "@t3tools/contracts"; +import { encodeTailcatConnectionCode } from "@t3tools/shared/t3ConnectionCode"; +import { decodeTailcatAddress } from "@t3tools/tailcat/address"; +import { tailcatBackoffDelayMs } from "@t3tools/tailcat/backoff"; +import { + type TailcatRuntimeError, + isTailcatRuntimeError, + tailcatFailureCode, +} from "@t3tools/tailcat/errors"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +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 Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +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 Queue from "effect/Queue"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +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 { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as PairingGrantStore from "../auth/PairingGrantStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; + +/** + * TailcatRemoteAccess makes this environment reachable over Tailcat. + * + * It owns one `tailcat serve` child that fronts the server's loopback listener + * and the server's Tailcat identity (a key file in the secrets directory, so the + * address is stable across restarts). Tailcat is only a transport: the listener + * admits any Tailcat node, and T3 pairing and sessions gate everything exactly + * as on any other network path. Tailcat reads an allowlist only at startup, so + * gating by node key would restart the listener, dropping every tunnel, on each + * pairing. + * + * Pairing over Tailcat is the ordinary T3 pairing flow. The token exchange that + * consumes a connection code reports the client's node key here, which records + * the device with its session so it can be listed, renamed, and revoked. + */ + +const isTailcatRemoteAccessError = Schema.is(TailcatRemoteAccessError); + +export const TAILCAT_REMOTE_ACCESS_STATE_FILE = "tailcat-remote-access.json"; +export const TAILCAT_SERVER_IDENTITY_FILE = "tailcat-server-identity.private.json"; +const EXPIRY_GRACE = Duration.seconds(1); + +const PersistedTailcatRemoteAccess = Schema.Struct({ + version: Schema.Literal(1), + enabled: Schema.Boolean, + trustedPeers: Schema.Array(TailcatTrustedPeer), +}); +type PersistedTailcatRemoteAccess = typeof PersistedTailcatRemoteAccess.Type; + +const PersistedTailcatRemoteAccessJson = Schema.fromJsonString(PersistedTailcatRemoteAccess); +const decodePersistedState = Schema.decodeUnknownEffect(PersistedTailcatRemoteAccessJson); +const encodePersistedState = Schema.encodeEffect(PersistedTailcatRemoteAccessJson); + +const EMPTY_PERSISTED_STATE: PersistedTailcatRemoteAccess = { + version: 1, + enabled: false, + trustedPeers: [], +}; + +export class TailcatRemoteAccess extends Context.Service< + TailcatRemoteAccess, + { + readonly state: Effect.Effect; + readonly changes: Stream.Stream; + /** The address and port peers should dial while Tailcat access is enabled and serving. */ + readonly readyEndpoint: Effect.Effect< + Option.Option<{ readonly address: TailcatAddress; readonly port: number }> + >; + /** Binds the service to the server's listening port and starts reconciling. */ + readonly start: (input: { readonly localPort: number }) => Effect.Effect; + readonly setEnabled: ( + enabled: boolean, + ) => Effect.Effect; + readonly createConnectionCode: ( + input: TailcatCreateConnectionCodeInput, + ) => Effect.Effect; + /** Called by the token exchange that consumed a Tailcat connection code. */ + readonly recordTrustedPeer: (input: { + readonly nodeKey: TailcatNodeKey; + readonly label: string | undefined; + /** The T3 session issued alongside the pairing, revoked with the peer. */ + readonly sessionId?: AuthSessionId; + }) => Effect.Effect; + readonly revokeTrustedPeer: ( + peerId: string, + ) => Effect.Effect; + readonly renameTrustedPeer: (input: { + readonly peerId: string; + readonly label: string; + }) => Effect.Effect; + readonly regenerateIdentity: Effect.Effect; + } +>()("t3/tailcat/TailcatRemoteAccess") {} + +interface RunningServe { + readonly scope: Scope.Closeable; + readonly handle: TailcatRuntime.TailcatServeHandle; +} + +interface RuntimeState { + readonly localPort: number | null; + readonly running: RunningServe | null; + readonly status: TailcatServeStatus; + readonly address: TailcatAddress | null; + readonly pairingOpen: boolean; + readonly failures: number; + readonly lastError: TailcatFailure | null; + readonly runtime: TailcatRuntimeInfo | null; +} + +const INITIAL_RUNTIME_STATE: RuntimeState = { + localPort: null, + running: null, + status: "disabled", + address: null, + pairingOpen: false, + failures: 0, + lastError: null, + runtime: null, +}; + +function failureOf( + error: TailcatRuntimeError | TailcatRemoteAccessError, + at: string, +): TailcatFailure { + if (isTailcatRuntimeError(error)) { + return { code: tailcatFailureCode(error), message: error.message, at }; + } + return { code: error.code, message: error.message, at }; +} + +const isPermanentFailure = (code: TailcatFailureCode): boolean => + isTailcatRuntimeUnavailable(code) || code === "identity-failed"; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const runtime = yield* TailcatRuntime.TailcatRuntime; + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const pairingLinks = yield* PairingGrantStore.PairingGrantStore; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const serviceScope = yield* Scope.Scope; + + const statePath = path.join(config.stateDir, TAILCAT_REMOTE_ACCESS_STATE_FILE); + const identityPath = path.join(config.secretsDir, TAILCAT_SERVER_IDENTITY_FILE); + + const now = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + const readPersisted = Effect.gen(function* () { + const raw = yield* fileSystem.readFileString(statePath).pipe(Effect.option); + if (Option.isNone(raw) || raw.value.trim().length === 0) { + return EMPTY_PERSISTED_STATE; + } + return yield* decodePersistedState(raw.value).pipe( + Effect.catch((cause) => + Effect.logWarning("Tailcat remote access state is unreadable; starting from defaults.", { + statePath, + cause, + }).pipe(Effect.as(EMPTY_PERSISTED_STATE)), + ), + ); + }); + + const persisted = yield* Ref.make(yield* readPersisted); + // Hold this across the read, derived update, and file write in every persisted-state mutator. + const persistedLock = yield* Semaphore.make(1); + const runtimeState = yield* Ref.make(INITIAL_RUNTIME_STATE); + const signals = yield* Queue.unbounded<"reconcile">(); + const expiryTimer = yield* Ref.make>>(Option.none()); + const retryTimer = yield* Ref.make>>(Option.none()); + + const persistError = (cause: unknown) => + new TailcatRemoteAccessError({ + code: "unknown", + message: `Could not save Tailcat remote access settings: ${String(cause)}`, + }); + + const writePersisted = (next: PersistedTailcatRemoteAccess) => + encodePersistedState(next).pipe( + Effect.flatMap((contents) => + writeFileStringAtomically({ filePath: statePath, contents: `${contents}\n` }), + ), + Effect.mapError(persistError), + Effect.andThen(Ref.set(persisted, next)), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const buildState = Effect.gen(function* () { + const saved = yield* Ref.get(persisted); + const current = yield* Ref.get(runtimeState); + const fingerprint = + current.address === null + ? null + : Result.match(decodeTailcatAddress(current.address), { + onFailure: () => null, + onSuccess: (decoded) => tailcatNodeKeyFingerprint(decoded.serverNodeKey), + }); + return { + enabled: saved.enabled, + status: current.status, + address: current.address, + remotePort: current.localPort, + pairingOpen: current.pairingOpen, + trustedPeers: saved.trustedPeers, + runtime: current.runtime, + identityFingerprint: fingerprint, + lastError: current.lastError, + updatedAt: yield* now, + } satisfies TailcatRemoteAccessState; + }); + + const published = yield* SubscriptionRef.make(yield* buildState); + const publish = buildState.pipe(Effect.tap((state) => SubscriptionRef.set(published, state))); + + const signalReconcile = Queue.offer(signals, "reconcile").pipe(Effect.asVoid); + + const listActiveConnectionCodes = pairingLinks.listActive().pipe( + Effect.map((links) => + links.filter((link) => link.subject === TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT), + ), + Effect.catch((cause) => + Effect.logWarning("Could not list Tailcat connection codes; treating none as active.", { + cause, + }).pipe(Effect.as([])), + ), + ); + + // Both timers are one-shot wake-ups for the reconcile loop; only the delay differs. + type TimerSlot = Ref.Ref>>; + const disarmTimer = (slot: TimerSlot) => + Ref.getAndSet(slot, Option.none()).pipe( + Effect.flatMap( + Option.match({ onNone: () => Effect.void, onSome: (fiber) => Fiber.interrupt(fiber) }), + ), + ); + const armTimer = (slot: TimerSlot, delayMs: number) => + Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.andThen(signalReconcile), + Effect.forkIn(serviceScope), + Effect.flatMap((fiber) => Ref.set(slot, Option.some(fiber))), + ); + + /** + * Whether an unconsumed, unexpired connection code exists, for the UI. It is + * derived, never stored; expiry does not emit a store event, so a timer + * re-evaluates at the earliest expiry. + */ + const refreshPairingWindow = Effect.gen(function* () { + const active = yield* listActiveConnectionCodes; + const open = active.length > 0; + yield* disarmTimer(expiryTimer); + if (open) { + const currentMs = yield* DateTime.now.pipe(Effect.map(DateTime.toEpochMillis)); + const earliestExpiry = Math.min( + ...active.map((link) => DateTime.toEpochMillis(link.expiresAt)), + ); + yield* armTimer( + expiryTimer, + Math.max(0, earliestExpiry - currentMs) + Duration.toMillis(EXPIRY_GRACE), + ); + } + yield* Ref.update(runtimeState, (current) => ({ ...current, pairingOpen: open })); + }); + + const ensureIdentity = Effect.gen(function* () { + const exists = yield* fileSystem.exists(identityPath).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return; + } + yield* Effect.logInfo("Creating the Tailcat server identity.", { identityPath }); + yield* runtime.generateServerIdentity({ keyPath: identityPath }); + }); + + const stopRunning = Effect.gen(function* () { + const current = yield* Ref.get(runtimeState); + if (current.running === null) { + return; + } + yield* Ref.update(runtimeState, (state) => ({ ...state, running: null })); + yield* Scope.close(current.running.scope, Exit.void).pipe(Effect.ignore); + yield* Effect.logInfo("Tailcat listener stopped.", { pid: current.running.handle.pid }); + }); + + const scheduleRetry = (failures: number) => + Effect.gen(function* () { + yield* disarmTimer(retryTimer); + yield* armTimer(retryTimer, tailcatBackoffDelayMs(failures, yield* Random.next)); + }); + + const recordFailure = (error: TailcatRuntimeError | TailcatRemoteAccessError) => + Effect.gen(function* () { + const at = yield* now; + const failure = failureOf(error, at); + const permanent = isPermanentFailure(failure.code); + const next = yield* Ref.updateAndGet(runtimeState, (state) => ({ + ...state, + status: permanent ? ("unavailable" as const) : ("error" as const), + address: permanent ? null : state.address, + failures: state.failures + 1, + lastError: failure, + })); + yield* Effect.logWarning("Tailcat listener failed.", { + code: failure.code, + message: failure.message, + failures: next.failures, + permanent, + }); + if (!permanent) { + yield* scheduleRetry(next.failures); + } + }); + + const startServe = (localPort: number) => + Effect.gen(function* () { + yield* Ref.update(runtimeState, (state) => ({ + ...state, + status: state.address === null ? ("starting" as const) : ("restarting" as const), + })); + yield* publish; + const info = yield* runtime.resolve; + yield* Ref.update(runtimeState, (state) => ({ ...state, runtime: info })); + yield* ensureIdentity.pipe( + Effect.mapError( + (error) => + new TailcatRemoteAccessError({ + code: "identity-failed", + message: `Could not prepare the Tailcat identity: ${error.message}`, + }), + ), + ); + const scope = yield* Scope.make("sequential"); + const handle = yield* runtime.serve({ keyPath: identityPath, localPort }).pipe( + Effect.provideService(Scope.Scope, scope), + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + ); + const running: RunningServe = { scope, handle }; + yield* Ref.update(runtimeState, (state) => ({ + ...state, + running, + status: "ready" as const, + address: handle.address, + failures: 0, + lastError: null, + })); + yield* Effect.logInfo("Tailcat listener ready.", { pid: handle.pid, localPort }); + // Watch for an unexpected exit. A stop we initiated replaces `running` + // first, so only the still-current handle schedules a restart. + yield* handle.exit.pipe( + Effect.flatMap((exitCode) => + Effect.gen(function* () { + const current = yield* Ref.get(runtimeState); + if (current.running?.handle !== handle) { + return; + } + const recentOutput = yield* handle.recentOutput; + yield* Ref.update(runtimeState, (state) => ({ ...state, running: null })); + yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); + yield* recordFailure( + new TailcatRemoteAccessError({ + code: "process-exited", + message: + recentOutput.at(-1) !== undefined + ? `The Tailcat listener exited (${Option.getOrNull(exitCode) ?? "signal"}): ${recentOutput.at(-1)}` + : `The Tailcat listener exited unexpectedly (${Option.getOrNull(exitCode) ?? "signal"}).`, + }), + ); + yield* publish; + }), + ), + Effect.forkIn(serviceScope), + ); + }); + + const reconcile = Effect.gen(function* () { + const saved = yield* Ref.get(persisted); + const current = yield* Ref.get(runtimeState); + if (current.localPort === null) { + return; + } + if (!saved.enabled) { + yield* stopRunning; + yield* Ref.update(runtimeState, (state) => ({ + ...state, + status: "disabled" as const, + address: null, + failures: 0, + lastError: null, + })); + return; + } + if (current.running !== null) { + return; + } + yield* startServe(current.localPort).pipe( + Effect.catch((error) => + isTailcatRuntimeError(error) || isTailcatRemoteAccessError(error) + ? recordFailure(error) + : Effect.die(error), + ), + ); + }); + + const reconcileLoop = Effect.gen(function* () { + for (;;) { + yield* Queue.take(signals); + // One pass covers every signal queued so far. + yield* Queue.clear(signals); + yield* refreshPairingWindow; + yield* reconcile; + yield* publish; + } + }); + yield* reconcileLoop.pipe(Effect.forkIn(serviceScope)); + + yield* pairingLinks.streamChanges.pipe( + Stream.filter( + (change) => + change.type === "pairingLinkRemoved" || + change.pairingLink.subject === TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + ), + Stream.runForEach(() => signalReconcile), + Effect.forkIn(serviceScope), + ); + + yield* Scope.addFinalizer( + serviceScope, + Effect.gen(function* () { + const current = yield* Ref.get(runtimeState); + if (current.running !== null) { + yield* Scope.close(current.running.scope, Exit.void).pipe(Effect.ignore); + } + }), + ); + + const requireEnabledAndReady = Effect.gen(function* () { + const saved = yield* Ref.get(persisted); + const current = yield* Ref.get(runtimeState); + if (!saved.enabled) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "Enable Tailcat access before creating a connection code.", + }); + } + if (current.address === null || current.localPort === null) { + return yield* new TailcatRemoteAccessError({ + code: current.lastError?.code ?? "startup-failed", + message: current.lastError?.message ?? "Tailcat is still starting. Try again in a moment.", + }); + } + return { address: current.address, localPort: current.localPort }; + }); + + const createConnectionCode: TailcatRemoteAccess["Service"]["createConnectionCode"] = Effect.fn( + "TailcatRemoteAccess.createConnectionCode", + )(function* (input) { + const ready = yield* requireEnabledAndReady; + const descriptor = yield* serverEnvironment.getDescriptor; + const ttlSeconds = input.ttlSeconds ?? TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS; + const issued = yield* environmentAuth + .createPairingLink({ + scopes: AuthStandardClientScopes, + subject: TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + label: input.label ?? "Tailcat connection code", + ttl: Duration.seconds(ttlSeconds), + }) + .pipe( + Effect.mapError( + (cause) => + new TailcatRemoteAccessError({ + code: "unknown", + message: `Could not issue a pairing credential: ${cause.message}`, + }), + ), + ); + const expiresAt = DateTime.formatIso(issued.expiresAt); + const payload = { + v: 1 as const, + transport: "tailcat" as const, + address: ready.address, + port: ready.localPort, + environmentId: descriptor.environmentId, + name: descriptor.label, + serverVersion: descriptor.serverVersion, + pairingToken: issued.credential, + expiresAt, + }; + yield* Effect.logInfo("Tailcat connection code issued.", { + pairingLinkId: issued.id, + expiresAt, + }); + return { + code: encodeTailcatConnectionCode(payload), + payload, + pairingLinkId: issued.id, + expiresAt, + } satisfies TailcatConnectionCodeResult; + }); + + const setEnabled: TailcatRemoteAccess["Service"]["setEnabled"] = Effect.fn( + "TailcatRemoteAccess.setEnabled", + )(function* (enabled) { + const saved = yield* Ref.get(persisted); + if (saved.enabled !== enabled) { + yield* writePersisted({ ...saved, enabled }); + yield* Effect.logInfo(enabled ? "Tailcat access enabled." : "Tailcat access disabled."); + } + if (enabled) { + // Clear a stale permanent failure so a retry actually happens after the + // user installed or repaired the runtime. + yield* Ref.update(runtimeState, (state) => ({ ...state, failures: 0 })); + yield* runtime.refresh.pipe(Effect.ignore); + } + yield* signalReconcile; + return yield* publish; + }, persistedLock.withPermits(1)); + + const recordTrustedPeer: TailcatRemoteAccess["Service"]["recordTrustedPeer"] = Effect.fn( + "TailcatRemoteAccess.recordTrustedPeer", + )(function* (input) { + const saved = yield* Ref.get(persisted); + const at = yield* now; + const existing = saved.trustedPeers.find((peer) => peer.nodeKey === input.nodeKey); + const label = input.label?.trim() || existing?.label || "Paired device"; + const sessionIds = input.sessionId === undefined ? [] : [input.sessionId]; + const updated = existing + ? { + ...existing, + label, + lastSeenAt: at, + sessionIds: [ + ...existing.sessionIds, + ...sessionIds.filter((sessionId) => !existing.sessionIds.includes(sessionId)), + ], + } + : { + id: yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new TailcatRemoteAccessError({ + code: "unknown", + message: `Could not allocate a peer id: ${String(cause)}`, + }), + ), + ), + nodeKey: input.nodeKey, + label, + createdAt: at, + lastSeenAt: at, + sessionIds, + }; + const peers = existing + ? saved.trustedPeers.map((peer) => (peer === existing ? updated : peer)) + : [...saved.trustedPeers, updated]; + yield* writePersisted({ ...saved, trustedPeers: peers }); + yield* Effect.logInfo(existing ? "Tailcat peer re-paired." : "Tailcat peer trusted.", { + fingerprint: tailcatNodeKeyFingerprint(input.nodeKey), + label, + }); + yield* publish; + }, persistedLock.withPermits(1)); + + const revokeTrustedPeer: TailcatRemoteAccess["Service"]["revokeTrustedPeer"] = Effect.fn( + "TailcatRemoteAccess.revokeTrustedPeer", + )(function* (peerId) { + const saved = yield* Ref.get(persisted); + const peer = saved.trustedPeers.find((candidate) => candidate.id === peerId); + if (peer === undefined) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "That device is no longer in the trusted list.", + }); + } + yield* writePersisted({ + ...saved, + trustedPeers: saved.trustedPeers.filter((candidate) => candidate.id !== peerId), + }); + yield* Effect.forEach( + peer.sessionIds, + (sessionId) => environmentAuth.revokeSession(sessionId).pipe(Effect.ignore), + { discard: true }, + ); + yield* Effect.logInfo("Tailcat peer revoked.", { + fingerprint: tailcatNodeKeyFingerprint(peer.nodeKey), + revokedSessions: peer.sessionIds.length, + }); + return yield* publish; + }, persistedLock.withPermits(1)); + + const renameTrustedPeer: TailcatRemoteAccess["Service"]["renameTrustedPeer"] = Effect.fn( + "TailcatRemoteAccess.renameTrustedPeer", + )(function* ({ peerId, label }) { + const saved = yield* Ref.get(persisted); + if (!saved.trustedPeers.some((peer) => peer.id === peerId)) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "That device is no longer in the trusted list.", + }); + } + const trimmed = label.trim(); + if (trimmed.length === 0) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "A device name cannot be empty.", + }); + } + yield* writePersisted({ + ...saved, + trustedPeers: saved.trustedPeers.map((peer) => + peer.id === peerId ? { ...peer, label: trimmed } : peer, + ), + }); + return yield* publish; + }, persistedLock.withPermits(1)); + + const regenerateIdentity: TailcatRemoteAccess["Service"]["regenerateIdentity"] = Effect.gen( + function* () { + yield* stopRunning; + yield* fileSystem.remove(identityPath, { force: true }).pipe( + Effect.mapError( + (cause) => + new TailcatRemoteAccessError({ + code: "identity-failed", + message: `Could not remove the previous Tailcat identity: ${String(cause)}`, + }), + ), + ); + yield* Ref.update(runtimeState, (state) => ({ + ...state, + address: null, + failures: 0, + lastError: null, + })); + yield* Effect.logInfo("Tailcat identity regenerated; connected devices must re-pair."); + yield* signalReconcile; + return yield* publish; + }, + ).pipe(Effect.withSpan("TailcatRemoteAccess.regenerateIdentity")); + + const start: TailcatRemoteAccess["Service"]["start"] = Effect.fn("TailcatRemoteAccess.start")( + function* ({ localPort }) { + const current = yield* Ref.get(runtimeState); + if (current.localPort !== null) { + return; + } + yield* Ref.update(runtimeState, (state) => ({ ...state, localPort })); + if (config.tailcatEnabled === true) { + const saved = yield* Ref.get(persisted); + if (!saved.enabled) { + yield* writePersisted({ ...saved, enabled: true }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist the Tailcat enable flag.", { error }), + ), + ); + } + } + yield* signalReconcile; + }, + persistedLock.withPermits(1), + ); + + return TailcatRemoteAccess.of({ + readyEndpoint: Effect.gen(function* () { + const current = yield* Ref.get(runtimeState); + const saved = yield* Ref.get(persisted); + // Only while the listener is up (or restarting after a crash): a failed or + // unavailable listener must not be advertised in codes. + const serving = current.status === "ready" || current.status === "restarting"; + return saved.enabled && serving && current.address !== null && current.localPort !== null + ? Option.some({ address: current.address, port: current.localPort }) + : Option.none(); + }), + state: SubscriptionRef.get(published), + changes: SubscriptionRef.changes(published), + start, + setEnabled, + createConnectionCode, + recordTrustedPeer, + revokeTrustedPeer, + renameTrustedPeer, + regenerateIdentity, + }); +}); + +export const layer = Layer.effect(TailcatRemoteAccess, make); diff --git a/apps/server/src/tailcat/TailcatRuntimeLive.ts b/apps/server/src/tailcat/TailcatRuntimeLive.ts new file mode 100644 index 000000000000..5f8d7f01cf9e --- /dev/null +++ b/apps/server/src/tailcat/TailcatRuntimeLive.ts @@ -0,0 +1,45 @@ +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; + +/** + * Resolves the Tailcat executable for this server process. Preference order: + * an explicit `T3CODE_TAILCAT_BINARY` override, the path the desktop app hands + * over in its bootstrap (the binary it ships), the copy a CLI archive carries + * beside its executable (`tailcat//`), the monorepo's fetched runtime, + * and finally a `tailcat` already on PATH. + */ +export const layer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + const overridePath = yield* TailcatRuntime.tailcatOverridePathFromEnvironment; + const moduleDirectory = import.meta.dirname; + const bundledCandidates = [ + ...(config.tailcatBinaryPath === undefined ? [] : [config.tailcatBinaryPath]), + ...TailcatRuntime.bundledTailcatCandidates({ + platform, + architecture, + joinPath: path.join, + moduleDirectory, + repoRootCandidates: [ + path.resolve(moduleDirectory, "../../../.."), + path.resolve(moduleDirectory, "../../.."), + ], + }), + ]; + return TailcatRuntime.layer({ + resolution: { + overridePath, + bundledCandidates, + allowSystem: true, + }, + }); + }), +); diff --git a/apps/server/src/tailcat/startupOutput.ts b/apps/server/src/tailcat/startupOutput.ts new file mode 100644 index 000000000000..8e45b27c5b92 --- /dev/null +++ b/apps/server/src/tailcat/startupOutput.ts @@ -0,0 +1,38 @@ +import type { TailcatConnectionCodeResult, TailcatRemoteAccessState } from "@t3tools/contracts"; + +/** + * Terminal output for `t3 serve --tailcat`: the connection code a client pastes + * into Add Environment. The code embeds a single-use pairing credential, so it + * is handled exactly like the pairing URL. + */ +/** The code and its handling instructions, shared by every terminal entry point. */ +export function formatTailcatConnectionCodeLines( + issued: TailcatConnectionCodeResult, +): ReadonlyArray { + return [ + `Connection code (expires ${issued.expiresAt}, single use):`, + issued.code, + "", + "Paste the code in the T3 Code desktop app under Add Environment → Tailcat.", + "This code embeds a one-time pairing credential. Share it only with the device you are pairing.", + ]; +} + +export function formatTailcatHeadlessOutput( + state: TailcatRemoteAccessState, + issued: TailcatConnectionCodeResult, +): string { + const path = + state.runtime === null + ? "unknown" + : `${state.runtime.source} ${state.runtime.version} (${state.runtime.executablePath})`; + return [ + "", + "Tailcat remote access is ready.", + `Tailcat address: ${state.address ?? "unknown"}`, + `Tailcat runtime: ${path}`, + ...formatTailcatConnectionCodeLines(issued), + "Trusted devices stay connected after the code expires; issue a new code per device.", + "", + ].join("\n"); +} diff --git a/apps/server/src/testUtils/liveCliServer.ts b/apps/server/src/testUtils/liveCliServer.ts new file mode 100644 index 000000000000..1fd68edf3492 --- /dev/null +++ b/apps/server/src/testUtils/liveCliServer.ts @@ -0,0 +1,204 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. +/** + * Harness for CLI commands that talk to a running server (`t3 remote`): a + * real HTTP server the CLI can discover through persisted runtime state, the + * real auth stack behind its `/ws` upgrade, and scripted RPC handlers in place + * of the full server. + */ +import * as NodeHttp from "node:http"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NetService from "@t3tools/shared/Net"; +import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import { assert } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as References from "effect/References"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/unstable/cli"; +import * as CliError from "effect/unstable/cli/CliError"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { type Rpc, type RpcGroup, RpcSerialization, RpcServer } from "effect/unstable/rpc"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { cli } from "../bin.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "../serverRuntimeState.ts"; + +const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); + +const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); + +const provideCliTestLayers = (effect: Effect.Effect) => + Effect.provide(effect, Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)); + +// The test console is shared and accumulates across CLI runs, so each capture +// keeps only the entries its own run appended. +const captureNewLogLines = (args: ReadonlyArray) => + provideCliTestLayers( + Effect.gen(function* () { + const before = (yield* TestConsole.logLines).length; + yield* runCli(args); + return (yield* TestConsole.logLines) + .slice(before) + .filter((line): line is string => typeof line === "string"); + }), + ); + +/** Everything one CLI run logged, joined; some commands log more than once. */ +export const captureStdout = (args: ReadonlyArray) => + Effect.map(captureNewLogLines(args), (lines) => lines.join("\n")); + +/** `--json` output has to be one clean entry: nothing logged before or after it. */ +export const captureJson = (args: ReadonlyArray) => + Effect.map(captureNewLogLines(args), (lines) => { + assert.equal(lines.length, 1, `Expected exactly one JSON entry, got ${String(lines)}`); + return lines[0] ?? ""; + }); + +export const flipCli = (args: ReadonlyArray) => + provideCliTestLayers(runCli(args).pipe(Effect.flip)); + +export const expectShowHelpError = (error: unknown, expectedTag: string) => { + if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${String(error)}`); + } + assert.equal(error.errors[0]?._tag, expectedTag); + return error.errors[0]; +}; + +export const makeTempBaseDir = (prefix: string) => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), `t3-cli-${prefix}-`)); + +const makeCliTestServerConfig = (baseDir: string) => + Effect.gen(function* () { + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + return { + logLevel: "Warn", + traceMinLevel: "Info", + traceTimingEnabled: false, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpLogsUrl: undefined, + otlpTracesExport: DEFAULT_SIGNAL_EXPORT, + otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, + otlpLogsExport: DEFAULT_SIGNAL_EXPORT, + otelEnvironment: OtelEnvironment.none, + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + baseDir, + ...derivedPaths, + staticDir: undefined, + devUrl: undefined, + devAllowedOrigins: [], + noBrowser: true, + startupPresentation: "headless", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, + } satisfies ServerConfig.ServerConfig["Service"]; + }); + +// Discovery probes the well-known descriptor before trusting runtime state. +const descriptorRouteLayer = HttpRouter.add( + "GET", + "/.well-known/t3/environment", + HttpServerResponse.jsonUnsafe({ + environmentId: "cli-test-environment", + label: "cli-test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.1", + capabilities: { repositoryIdentity: true }, + }), +); + +/** + * Serves `rpcs` on `/ws` behind the server's own upgrade authentication (the + * CLI sends its minted session as a bearer header), persists runtime state so + * the CLI discovers this server under `baseDir`, then runs `run`. + */ +export const withLiveCliServer = (input: { + readonly baseDir: string; + readonly rpcs: RpcGroup.RpcGroup; + readonly handlers: Layer.Layer>; + readonly run: () => Effect.Effect; +}) => + Effect.gen(function* () { + const config = yield* makeCliTestServerConfig(input.baseDir); + const wsRouteLayer = HttpRouter.add( + "GET", + "/ws", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const authenticated = yield* Effect.result( + serverAuth.authenticateWebSocketUpgrade(request), + ); + if (authenticated._tag === "Failure") { + return HttpServerResponse.empty({ status: 401 }); + } + return yield* RpcServer.toHttpEffectWebsocket(input.rpcs, { disableTracing: true }).pipe( + Effect.provide(input.handlers.pipe(Layer.provideMerge(RpcSerialization.layerJson))), + Effect.flatMap((httpEffect) => httpEffect), + ); + }), + ); + const appLayer = HttpRouter.serve(Layer.mergeAll(descriptorRouteLayer, wsRouteLayer), { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge( + EnvironmentAuth.layer.pipe( + Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerEnvironment.identityLayer), + Layer.provide(ServerSecretStore.layer), + ), + ), + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { host: "127.0.0.1", port: 0 }), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provide(ServerConfig.layer(config)), + // The server shares the test console with the CLI under test; keep its + // own startup chatter out of the captured output. + Layer.provide(Layer.succeed(References.MinimumLogLevel, "Error")), + ); + + return yield* Effect.scoped( + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return yield* Effect.die(new Error(`Expected TCP address, got ${String(address)}`)); + } + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: yield* makePersistedServerRuntimeState({ config, port: address.port }), + }); + return yield* input.run(); + }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))), + ); + }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 830bb35b86db..c64a2c41ca52 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -132,6 +132,7 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import * as TailcatRemoteAccess from "./tailcat/TailcatRemoteAccess.ts"; import { readWorkflowScript } from "./orchestration/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; @@ -578,6 +579,7 @@ const makeWsRpcLayer = ( const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const tailcatRemoteAccess = yield* TailcatRemoteAccess.TailcatRemoteAccess; const canReplayPersistedRange = Effect.fnUntraced(function* ( afterSequence: number, headSequence: number, @@ -3778,6 +3780,41 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "server" }, ), + // Tailcat remote access: the Tailcat listener this environment exposes. + [WS_METHODS.tailcatSubscribeRemoteAccess]: (_input) => + observeRpcStream(WS_METHODS.tailcatSubscribeRemoteAccess, tailcatRemoteAccess.changes, { + "rpc.aggregate": "tailcat", + }), + [WS_METHODS.tailcatSetRemoteAccessEnabled]: (input) => + observeRpcEffect( + WS_METHODS.tailcatSetRemoteAccessEnabled, + tailcatRemoteAccess.setEnabled(input.enabled), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatCreateConnectionCode]: (input) => + observeRpcEffect( + WS_METHODS.tailcatCreateConnectionCode, + tailcatRemoteAccess.createConnectionCode(input), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatRevokeTrustedPeer]: (input) => + observeRpcEffect( + WS_METHODS.tailcatRevokeTrustedPeer, + tailcatRemoteAccess.revokeTrustedPeer(input.peerId), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatRenameTrustedPeer]: (input) => + observeRpcEffect( + WS_METHODS.tailcatRenameTrustedPeer, + tailcatRemoteAccess.renameTrustedPeer(input), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatRegenerateIdentity]: (_input) => + observeRpcEffect( + WS_METHODS.tailcatRegenerateIdentity, + tailcatRemoteAccess.regenerateIdentity, + { "rpc.aggregate": "tailcat" }, + ), }); }), ); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index c00af9f88164..5c774adb9e77 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -3,8 +3,10 @@ import { EllipsisIcon, PlusIcon, QrCodeIcon, + RadioTowerIcon, TerminalIcon, } from "lucide-react"; +import { formatAbsoluteTimestamp } from "~/timestampFormat"; import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/unstable/reactivity"; import { @@ -77,6 +79,12 @@ import { import { FoldedSettingsSection } from "./FoldedSettingsSection"; import { LoadBalancingSettings } from "./LoadBalancingSettings"; import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; +import { TailcatConnectForm } from "./TailcatConnectForm"; +import { + TailcatEnvironmentDetailsDialog, + useTailcatEnvironmentSubtitle, +} from "./TailcatEnvironmentDetails"; +import { TailcatRemoteAccessRow } from "./TailcatRemoteAccessSection"; import { Input } from "../ui/input"; import { CommandShortcut } from "../ui/command"; import { @@ -156,6 +164,10 @@ import { refreshDesktopNetworkAccessState, } from "~/state/desktopNetworkAccess"; import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts"; +import { + isDesktopTailcatAvailable, + refreshDesktopTailcatDiagnostics, +} from "~/state/desktopTailcat"; import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState"; import { type EnvironmentPresentation, @@ -183,6 +195,9 @@ import { } from "../../keybindings"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; + +/** How a new saved environment is added: pairing link, desktop SSH, or a Tailcat connection code. */ +type SavedBackendMode = "remote" | "ssh" | "tailcat"; const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray = []; @@ -192,19 +207,6 @@ const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray = []; const BACKEND_VALUE_DEFAULT_WSL = "backend:default-wsl"; const BACKEND_VALUE_WSL_OFF = "backend:wsl-off"; -const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", -}); - -function formatAccessTimestamp(value: string): string { - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return value; - } - return accessTimestampFormatter.format(parsed); -} - const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ readonly scope: AuthEnvironmentScope; readonly title: string; @@ -732,7 +734,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ if (credential) copyPairingValue(credential, "code"); }, [copyPairingValue, credential]); - const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); + const expiresAbsolute = formatAbsoluteTimestamp(pairingLink.expiresAt); const primaryLabel = pairingLink.label ?? "Pairing link"; const selectedQrOption = selectQrEndpointOption( @@ -755,7 +757,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({

{primaryLabel}

@@ -984,7 +986,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ ? `Connected for ${formatElapsedDurationLabel(lastConnectedAt, nowMs)}` : "Connected" : lastConnectedAt - ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}` + ? `Last connected at ${formatAbsoluteTimestamp(lastConnectedAt)}` : "Not connected yet."; const deviceInfoBits = [ clientSession.client.deviceType !== "unknown" @@ -1533,8 +1535,16 @@ function SavedBackendListRow({ environment.serverConfig ?? (lastDescriptor === undefined ? null : { environment: lastDescriptor }), ); + const tailcatProfile = + environment.entry.target._tag === "TailcatConnectionTarget" && + Option.isSome(environment.entry.profile) && + environment.entry.profile.value._tag === "TailcatConnectionProfile" + ? environment.entry.profile.value + : null; + const tailcatSubtitle = useTailcatEnvironmentSubtitle(tailcatProfile?.connectionId ?? null); + const [tailcatDetailsOpen, setTailcatDetailsOpen] = useState(false); const subtitleText = [ - environmentTransportLabel(environment), + tailcatSubtitle ?? environmentTransportLabel(environment), resumingServerUpdate ? "Restarting" : status.text, enabled && versionMismatch ? serverVersion : null, ] @@ -1635,6 +1645,16 @@ function SavedBackendListRow({ environmentId={environmentId} serverConfig={environment.serverConfig} /> + {tailcatProfile ? ( + { + refreshDesktopTailcatDiagnostics(tailcatProfile.connectionId); + setTailcatDetailsOpen(true); + }} + > + Tailcat details… + + ) : null} {errorTraceId ? ( copyTraceId(errorTraceId)}>Copy trace ID ) : null} @@ -1644,6 +1664,17 @@ function SavedBackendListRow({ + {tailcatProfile ? ( + onRemove(environment)} + /> + ) : null} ); } @@ -1949,7 +1980,7 @@ export function ConnectionsSettings() { >(null); const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false); const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false); - const [savedBackendMode, setSavedBackendMode] = useState<"remote" | "ssh">("remote"); + const [savedBackendMode, setSavedBackendMode] = useState("remote"); const [savedBackendHost, setSavedBackendHost] = useState(""); const [savedBackendPairingCode, setSavedBackendPairingCode] = useState(""); const [savedBackendSshHost, setSavedBackendSshHost] = useState(""); @@ -1999,6 +2030,9 @@ export function ConnectionsSettings() { DesktopServerExposureState["mode"] | null >(null); const primaryServerConfig = primaryEnvironment?.serverConfig ?? null; + const supportsTailcatRemoteAccess = + primaryServerConfig?.environment.capabilities.tailcatRemoteAccess === true; + const isDesktopTailcatReady = isDesktopTailcatAvailable(); const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig); const primaryServerUpdateState = useAtomValue( serverEnvironment.updateStateAtom(primaryEnvironmentId), @@ -2594,12 +2628,15 @@ export function ConnectionsSettings() { }, []); const renderConnectionModeCard = (input: { - readonly mode: "remote" | "ssh"; + readonly mode: SavedBackendMode; readonly title: string; readonly description: string; readonly icon?: ReactNode; + /** Shown but inert, with the reason under the description (e.g. "Desktop app required"). */ + readonly unavailableReason?: string; }) => { const selected = savedBackendMode === input.mode; + const unavailable = input.unavailableReason !== undefined; return ( ); }; + const renderTailcatModeBody = () => ( + { + setSavedBackendError(null); + setAddBackendDialogOpen(false); + }} + /> + ); + const renderTailcatRemoteAccessRow = () => + supportsTailcatRemoteAccess && primaryEnvironmentId !== null ? ( + + ) : null; const renderRemoteFields = () => (
@@ -3353,12 +3409,14 @@ export function ConnectionsSettings() { {renderNetworkAccessRow()} {renderEndpointRows("endpoint-rail")} {renderTailscaleRow()} + {renderTailcatRemoteAccessRow()} {renderWslRow()} ) : canManageLocalBackend ? ( <> {renderDisabledNetworkAccessRow()} + {renderTailcatRemoteAccessRow()} ) : null} @@ -3713,7 +3771,12 @@ export function ConnectionsSettings() {
-
+
{renderConnectionModeCard({ mode: "remote", title: "Remote link", @@ -3729,9 +3792,23 @@ export function ConnectionsSettings() { icon: , }) : null} + {renderConnectionModeCard({ + mode: "tailcat", + title: "Tailcat", + description: + "Paste a connection code from the other machine. Tunnels with relay fallback, no VPN account.", + icon: , + ...(isDesktopTailcatReady + ? {} + : { unavailableReason: "Desktop app required" }), + })}
- {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()} + {savedBackendMode === "ssh" + ? renderSshFields() + : savedBackendMode === "tailcat" + ? renderTailcatModeBody() + : renderRemoteModeBody()}
diff --git a/apps/web/src/components/settings/EnvironmentRow.tsx b/apps/web/src/components/settings/EnvironmentRow.tsx index 5f3dae7e47c1..453dc2ef15ac 100644 --- a/apps/web/src/components/settings/EnvironmentRow.tsx +++ b/apps/web/src/components/settings/EnvironmentRow.tsx @@ -14,7 +14,8 @@ export function formatDesktopSshTarget(target: DesktopSshEnvironmentTarget): str /** * How this client reaches a machine, printed first in every environment row so - * T3 Connect, SSH, WSL, and plain remote links are told apart without a legend. + * T3 Connect, SSH, WSL, Tailcat, and plain remote links are told apart without a + * legend. */ export function environmentTransportLabel(environment: EnvironmentPresentation): string { const { entry } = environment; @@ -28,6 +29,7 @@ export function environmentTransportLabel(environment: EnvironmentPresentation): ) { return `SSH ${formatDesktopSshTarget(entry.profile.value.target)}`; } + if (entry.target._tag === "TailcatConnectionTarget") return "Tailcat"; return environment.displayUrl ?? "Remote link"; } diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 6722969075a1..df5c0c23f6b5 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -25,6 +25,7 @@ import { import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; +import * as Option from "effect/Option"; import * as Result from "effect/Result"; import { PlusIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; @@ -168,6 +169,10 @@ function providerEnvironmentDetail(environment: EnvironmentPresentation): string if (environment.entry.target._tag === "PrimaryConnectionTarget") return "Primary device"; if (environment.relayManaged) return "T3 Connect"; if (environment.entry.target._tag === "SshConnectionTarget") return "SSH"; + if (environment.entry.target._tag === "TailcatConnectionTarget") { + const profile = Option.getOrNull(environment.entry.profile); + return profile?._tag === "TailcatConnectionProfile" ? `Tailcat ${profile.address}` : "Tailcat"; + } if (isDesktopLocalConnectionTarget(environment.entry.target)) return "Local device"; return environment.displayUrl ?? "Remote device"; } diff --git a/apps/web/src/components/settings/TailcatConnectForm.tsx b/apps/web/src/components/settings/TailcatConnectForm.tsx new file mode 100644 index 000000000000..5d36708ab60c --- /dev/null +++ b/apps/web/src/components/settings/TailcatConnectForm.tsx @@ -0,0 +1,205 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { describeTailcatConnectionCode } from "@t3tools/shared/t3ConnectionCode"; +import { ClipboardPasteIcon, RadioTowerIcon } from "lucide-react"; +import { memo, useCallback, useMemo, useState } from "react"; + +import { readTextFromClipboard } from "../../hooks/useCopyToClipboard"; +import { formatExpiresInLabel } from "../../timestampFormat"; +import { connectTailcatEnvironment as connectTailcatEnvironmentAtom } from "~/connection/onboarding"; +import { isDesktopTailcatAvailable } from "~/state/desktopTailcat"; +import { tailcatProvisioningPhaseAtom } from "~/state/tailcatProvisioning"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { useRelativeTimeTick } from "./settingsLayout"; +import { formatTailcatConnectionError } from "./TailcatRemoteAccess.logic"; + +const TAILCAT_DESKTOP_REQUIRED_MESSAGE = + "Desktop app required. The T3 Code desktop app runs the Tailcat tunnel for this device."; + +type TailcatConnectFormProps = { + /** "add" saves a new environment; "repair" refreshes an existing one from a fresh code. */ + readonly mode: "add" | "repair"; + /** In repair mode, the code must name this environment. */ + readonly expectedEnvironmentId?: EnvironmentId; + readonly onConnected: (environmentId: EnvironmentId) => void; +}; + +/** + * Paste a `t3c://tailcat/...` connection code, see which machine it names, + * and connect. The tunnel and pairing run in the desktop app; progress comes + * from the gateway's provisioning state, so the labels track real steps. + */ +export const TailcatConnectForm = memo(function TailcatConnectForm({ + mode, + expectedEnvironmentId, + onConnected, +}: TailcatConnectFormProps) { + const [code, setCode] = useState(""); + const [isConnecting, setIsConnecting] = useState(false); + const [error, setError] = useState(null); + const nowMs = useRelativeTimeTick(1_000); + const provisioningPhase = useAtomValue(tailcatProvisioningPhaseAtom); + const connectTailcatEnvironment = useAtomCommand(connectTailcatEnvironmentAtom, { + reportFailure: false, + }); + const desktopAvailable = isDesktopTailcatAvailable(); + const canPasteFromClipboard = + typeof navigator !== "undefined" && navigator.clipboard?.readText !== undefined; + + const preview = useMemo(() => describeTailcatConnectionCode(code), [code]); + const expired = preview.kind === "valid" && preview.expiresAtMs <= nowMs; + const environmentMismatch = + preview.kind === "valid" && + expectedEnvironmentId !== undefined && + preview.payload.environmentId !== expectedEnvironmentId; + const canConnect = + desktopAvailable && + !isConnecting && + preview.kind === "valid" && + !expired && + !environmentMismatch; + + const handlePaste = useCallback(async () => { + try { + const text = await readTextFromClipboard("connection code"); + setCode(text.trim()); + setError(null); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not read the clipboard."); + } + }, []); + + const handleConnect = useCallback(async () => { + if (!canConnect) return; + setIsConnecting(true); + setError(null); + const result = await connectTailcatEnvironment({ code: code.trim() }); + setIsConnecting(false); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) return; + const message = formatTailcatConnectionError( + squashAtomCommandFailure(result), + "Could not connect over Tailcat.", + ); + setError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: mode === "repair" ? "Could not re-pair" : "Could not connect over Tailcat", + description: message, + }), + ); + return; + } + setCode(""); + toastManager.add({ + type: "success", + title: mode === "repair" ? "Environment re-paired" : "Environment connected", + description: + mode === "repair" + ? "The saved environment has a fresh Tailcat credential." + : "The environment is saved and reconnects through Tailcat on app startup.", + }); + onConnected(result.value); + }, [canConnect, code, connectTailcatEnvironment, mode, onConnected]); + + const connectLabel = isConnecting + ? provisioningPhase === "pairing" + ? "Pairing…" + : "Starting tunnel…" + : mode === "repair" + ? "Re-pair" + : "Connect"; + + return ( +
+
+
+ + {canPasteFromClipboard ? ( + + ) : null} +
+