diff --git a/apps/server/src/peer/TailnetDevices.test.ts b/apps/server/src/peer/TailnetDevices.test.ts index bd017b82cc9e2..7895aa0bff23c 100644 --- a/apps/server/src/peer/TailnetDevices.test.ts +++ b/apps/server/src/peer/TailnetDevices.test.ts @@ -2,6 +2,12 @@ import { NodeHttpServer } from "@effect/platform-node"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { + AuthAccessReadScope, + AuthDiagnosticsReadScope, + AuthFilesystemReadScope, + AuthOrchestrationReadScope, + AuthRelayReadScope, + AuthStandardClientScopes, EnvironmentHttpApi, EnvironmentId, type ExecutionEnvironmentDescriptor, @@ -190,6 +196,8 @@ const serveDeviceOn = ( ), ), settings: Context.get(context, ServerSettings.ServerSettingsService), + /** One-time pairing credentials this device issued, with their scopes. */ + pairingLinks: auth.listPairingLinks(), /** Grants delivered to this device, each carrying a token. */ grants, /** Sessions this device issued to other devices. */ @@ -509,6 +517,60 @@ describe("tailnet device access", () => { ); }); +describe("opening a device from the app", () => { + it.live("gets a one-time app credential from the device, within its access", () => + Effect.gen(function* () { + const tailnet = yield* makeTailnet; + const a = yield* serveDevice(laptop, tailnet); + const b = yield* serveDevice(studio, tailnet); + yield* tailnet.join(a.url); + yield* tailnet.join(b.url); + yield* a.devices.linkIfMine({ url: b.url }); + + const full = yield* a.devices.openDevice(studio.environmentId); + expect([full.environmentId, full.urls]).toEqual([studio.environmentId, [b.url]]); + yield* b.devices.setAccess(laptop.environmentId, "read-only"); + yield* a.devices.openDevice(studio.environmentId); + + expect((yield* b.pairingLinks).map((link) => link.permissions?.toSorted())).toEqual([ + [ + AuthAccessReadScope, + AuthDiagnosticsReadScope, + AuthFilesystemReadScope, + AuthOrchestrationReadScope, + AuthRelayReadScope, + ].toSorted(), + [...AuthStandardClientScopes].toSorted(), + ]); + }), + ); + + it.live("gets nothing from a device that revoked it, nor without its device session", () => + Effect.gen(function* () { + const tailnet = yield* makeTailnet; + const a = yield* serveDevice(laptop, tailnet); + const b = yield* serveDevice(studio, tailnet); + yield* tailnet.join(a.url); + yield* tailnet.join(b.url); + yield* a.devices.linkIfMine({ url: b.url }); + yield* b.devices.setAccess(laptop.environmentId, "revoked"); + + const refused = yield* a.devices.openDevice(studio.environmentId).pipe(Effect.flip); + // Anyone can claim to be a device; without its session the device mints nothing. + const forged = yield* Effect.gen(function* () { + const client = yield* PeerLinks.peerApiClient(yield* HttpClient.HttpClient, b.url); + return yield* client.tailnetDevices.clientCredential({ + payload: { from: laptop.environmentId }, + }); + }).pipe(Effect.flip, Effect.provide(FetchHttpClient.layer)); + + expect(refused._tag).toBe("TailnetDevicesError"); + expect(forged._tag).toBe("TailnetDevicesUnauthorizedError"); + expect(yield* b.pairingLinks).toEqual([]); + }), + ); +}); + describe("locating a tailnet target", () => { const tailnet: Tailnet = { self: { diff --git a/apps/server/src/peer/TailnetDevices.ts b/apps/server/src/peer/TailnetDevices.ts index 1a9c34ae0d93a..9bad6e7b44432 100644 --- a/apps/server/src/peer/TailnetDevices.ts +++ b/apps/server/src/peer/TailnetDevices.ts @@ -1,10 +1,20 @@ import { + AuthAccessReadScope, + AuthDiagnosticsReadScope, + AuthFilesystemReadScope, + type AuthGrantScope, + type AuthMcpClientAccess, + AuthOrchestrationReadScope, + AuthRelayReadScope, + AuthStandardClientScopes, type EnvironmentId, type ExecutionEnvironmentDescriptor, type PeerLink, TAILNET_DEVICE_CAPABILITY, type TailnetChallengeConfirm, type TailnetDevice, + type TailnetDeviceClientCredential, + type TailnetDeviceClientCredentialRequest, type TailnetDeviceAccess, type DevicePolicyEntry, type TailnetDeviceDescriptor, @@ -119,6 +129,21 @@ const unreachable = (message: string, cause?: unknown) => ...(cause === undefined ? {} : { cause }), }); +/** + * App sessions carry no agent mode ceiling, so anything short of full access + * opens the app read-only rather than with a shell and full control. + */ +const clientScopesFor = (access: AuthMcpClientAccess): ReadonlyArray => + access === "full-access" + ? AuthStandardClientScopes + : [ + AuthOrchestrationReadScope, + AuthFilesystemReadScope, + AuthDiagnosticsReadScope, + AuthAccessReadScope, + AuthRelayReadScope, + ]; + const storageError = (cause: unknown) => new TailnetDevicesError({ reason: "storage", @@ -157,6 +182,13 @@ export class TailnetDevices extends Context.Service< readonly environmentId?: EnvironmentId; readonly url?: string; }) => Effect.Effect>; + /** + * A one-time pairing credential for my app on another of my devices, asked + * for over this device's session there, so the app connects without a code. + */ + readonly openDevice: ( + environmentId: EnvironmentId, + ) => Effect.Effect; /** Runs the handshake again for a device's link, as when its token stops working. */ readonly refresh: (environmentId: EnvironmentId) => Effect.Effect; readonly handleRequest: (request: TailnetLinkRequest) => Effect.Effect; @@ -166,6 +198,10 @@ export class TailnetDevices extends Context.Service< request: HttpServerRequest.HttpServerRequest, payload: TailnetDevicesSyncPayload, ) => Effect.Effect; + readonly handleClientCredential: ( + request: HttpServerRequest.HttpServerRequest, + payload: TailnetDeviceClientCredentialRequest, + ) => Effect.Effect; /** Finds and links devices on the tailnet once, and refreshes and syncs existing links. */ readonly scan: Effect.Effect; /** Scans at startup, every ten minutes and when the tailnet's devices change. */ @@ -634,21 +670,95 @@ const make = Effect.gen(function* () { const handleSync: TailnetDevices["Service"]["handleSync"] = Effect.fn( "TailnetDevices.handleSync", )(function* (request, payload) { + yield* authenticateDevice(request, payload.from.environmentId); + yield* mergeSync(payload, payload.from.environmentId).pipe( + Effect.tapError((cause) => Effect.logWarning("Could not merge a device's sync", { cause })), + Effect.ignore, + ); + return yield* syncPayload; + }); + + /** The device session a linked device holds here, by the id it claims. */ + const authenticateDevice = Effect.fn("TailnetDevices.authenticateDevice")(function* ( + request: HttpServerRequest.HttpServerRequest, + claimed: EnvironmentId, + ) { const caller = yield* auth.authenticateMcpClient(request).pipe(Effect.option); - if ( - Option.isNone(caller) || - caller.value.peerEnvironmentId === undefined || - caller.value.peerEnvironmentId !== payload.from.environmentId - ) { + if (Option.isNone(caller) || caller.value.peerEnvironmentId !== claimed) { return yield* new TailnetDevicesUnauthorizedError({ - message: "Sync needs the session this machine issued to the calling device.", + message: "This needs the session this machine issued to the calling device.", }); } - yield* mergeSync(payload, caller.value.peerEnvironmentId).pipe( - Effect.tapError((cause) => Effect.logWarning("Could not merge a device's sync", { cause })), - Effect.ignore, + return caller.value; + }); + + const handleClientCredential: TailnetDevices["Service"]["handleClientCredential"] = Effect.fn( + "TailnetDevices.handleClientCredential", + )(function* (request, payload) { + const caller = yield* authenticateDevice(request, payload.from); + const self = yield* selfDescriptor; + const issued = yield* auth + .issuePairingCredential({ + scopes: clientScopesFor(caller.access), + label: `Opened from ${caller.label.replace(/^T3 Code ยท /u, "")}`, + }) + .pipe(Effect.orDie); + return { + environmentId: self.environmentId, + urls: self.urls, + credential: issued.credential, + expiresAt: issued.expiresAt, + } satisfies TailnetDeviceClientCredential; + }); + + const openDevice: TailnetDevices["Service"]["openDevice"] = Effect.fn( + "TailnetDevices.openDevice", + )(function* (environmentId) { + const self = yield* environment.getEnvironmentId; + if (environmentId === self) { + return yield* new TailnetDevicesError({ + reason: "self", + message: "This machine is already the one you are using.", + }); + } + if ((yield* policy.accessFor(environmentId)) === "revoked") { + return yield* new TailnetDevicesError({ + reason: "revoked", + message: "That device is revoked. Restore it to open it.", + }); + } + const ask = Effect.gen(function* () { + const resolved = yield* links.resolve(environmentId); + const authorized = HttpClient.mapRequest( + httpClient, + HttpClientRequest.bearerToken(resolved.token), + ); + return yield* PeerLinks.peerApiClient(authorized, resolved.url).pipe( + Effect.flatMap((client) => + client.tailnetDevices.clientCredential({ payload: { from: self } }), + ), + Effect.timeout(DESCRIBE_TIMEOUT), + ); + }); + const credential = yield* ask.pipe( + Effect.catchTags({ + TailnetDevicesUnauthorizedError: () => refresh(environmentId).pipe(Effect.andThen(ask)), + }), + Effect.mapError((cause) => + cause._tag === "PeerLinkError" + ? new TailnetDevicesError({ + reason: "unknown_device", + message: "This machine has no link to that device.", + cause, + }) + : unreachable("That device did not answer.", cause), + ), ); - return yield* syncPayload; + // Only the device it asked may answer for it. + if (credential.environmentId !== environmentId) { + return yield* unreachable("A different machine answered for that device."); + } + return credential; }); /** Exchanges device hints with a linked device; a refused token starts a new handshake. */ @@ -928,11 +1038,13 @@ const make = Effect.gen(function* () { list, setAccess, linkIfMine, + openDevice, refresh, handleRequest, handleConfirm, handleGrant, handleSync, + handleClientCredential, scan, start, }); diff --git a/apps/server/src/peer/tailnetDevicesHttp.ts b/apps/server/src/peer/tailnetDevicesHttp.ts index c160d5208a654..8934aa4e6ce45 100644 --- a/apps/server/src/peer/tailnetDevicesHttp.ts +++ b/apps/server/src/peer/tailnetDevicesHttp.ts @@ -13,6 +13,9 @@ export const layer = HttpApiBuilder.group( .handle("request", ({ payload }) => devices.handleRequest(payload)) .handle("confirm", ({ payload }) => devices.handleConfirm(payload)) .handle("grant", ({ payload }) => devices.handleGrant(payload)) - .handle("sync", ({ payload, request }) => devices.handleSync(request, payload)); + .handle("sync", ({ payload, request }) => devices.handleSync(request, payload)) + .handle("clientCredential", ({ payload, request }) => + devices.handleClientCredential(request, payload), + ); }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 383c6cba555a1..89af8b582bd68 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,5 +1,5 @@ import { OrchestrationCommandReceiptRepository } from "./persistence/OrchestrationCommandReceipts.ts"; -import { TailnetDevicesError, OrchestrationDispatchCommandError } from "@t3tools/contracts"; +import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; import * as McpGatewayBroker from "./mcp/McpGatewayBroker.ts"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -1374,12 +1374,6 @@ export const layerWsRpc = ( const serverSettings = yield* ServerSettings.ServerSettingsService; const peerLinks = yield* PeerLinks.PeerLinks; const tailnetDevices = yield* TailnetDevices.TailnetDevices; - const tailnetDevicesUnavailable = Effect.fail( - new TailnetDevicesError({ - reason: "unreachable", - message: "This server cannot link devices yet.", - }), - ); const peerLinkRequests = yield* PeerLinkRequests.PeerLinkRequests; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; @@ -2617,7 +2611,8 @@ export const layerWsRpc = ( [WS_METHODS.tailnetDevicesSetAccess]: (input) => tailnetDevices.setAccess(input.environmentId, input.access), [WS_METHODS.tailnetDevicesUpdateSettings]: (input) => tailnetDevices.updateSettings(input), - [WS_METHODS.tailnetDevicesOpenDevice]: () => tailnetDevicesUnavailable, + [WS_METHODS.tailnetDevicesOpenDevice]: (input) => + tailnetDevices.openDevice(input.environmentId), [WS_METHODS.serverUpdateSettings]: /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* ({ patch, providerInstanceMutation, replicateProfiles }) { diff --git a/docs/internals/remote.md b/docs/internals/remote.md index deeb38a562a64..22663931dadd5 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -132,6 +132,12 @@ through another. Each `/mcp` request narrows a device session's token to its current entry, so a change applies without re-linking; widening past what the token was minted with takes a new handshake. +An app opens another of my devices through the server it is connected to, which +asks that device over its device session for a one-time pairing credential. The +app redeems it directly at the first address that answers as that device. App +sessions carry no agent mode ceiling, so a device below full access opens +read-only. + **Work a link paired below full access starts stays within that link.** The receiving environment knows a session is a peer link because the linking side registers its OAuth client with the `t3code-peer-link` software id, and the diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 2131507e547f7..1f76d9eafa26b 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -5,6 +5,7 @@ import { ORCHESTRATION_PROTOCOL_VERSION, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -56,6 +57,8 @@ function layerPairingHttp( calls: Array<{ readonly url: string; readonly init: RequestInit }>, options?: { readonly failDescriptor?: boolean; + /** Hosts whose descriptor does not answer. */ + readonly downHosts?: ReadonlyArray; readonly failExchange?: boolean; readonly protocolVersion?: number; readonly selfUpdate?: boolean; @@ -70,7 +73,10 @@ function layerPairingHttp( calls.push({ url, init }); if (url.endsWith("/.well-known/t3/environment")) { - if (options?.failDescriptor === true) { + if ( + options?.failDescriptor === true || + options?.downHosts?.includes(new URL(url).host) === true + ) { return Promise.resolve( Response.json({ message: "descriptor unavailable" }, { status: 503 }), ); @@ -280,6 +286,25 @@ describe("connection onboarding", () => { }), ); + it.effect("spends a device's credential only at an address that answers as that device", () => { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + return Effect.gen(function* () { + const onboarding = yield* Onboarding.ConnectionOnboarding; + + const environmentId = yield* onboarding.registerDevice({ + environmentId: PAIRED_ENVIRONMENT_ID, + urls: ["https://down.example.test", "https://remote.example.test"], + credential: "pairing-token", + expiresAt: DateTime.makeUnsafe(0), + }); + + expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID); + expect( + calls.filter((call) => call.url.endsWith("/oauth/token")).map((call) => call.url), + ).toEqual(["https://remote.example.test/oauth/token"]); + }).pipe(Effect.provide(layerPairingOnboarding({ downHosts: ["down.example.test"] }, calls))); + }); + it.effect("turns a switched-off environment back on when it is paired again", () => Effect.gen(function* () { const registry = yield* EnvironmentRegistry.EnvironmentRegistry; diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 48e8b614c67c8..148ee47a221d2 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -1,4 +1,8 @@ -import type { DesktopSshEnvironmentTarget, EnvironmentId } from "@t3tools/contracts"; +import type { + DesktopSshEnvironmentTarget, + EnvironmentId, + TailnetDeviceClientCredential, +} from "@t3tools/contracts"; import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -62,6 +66,13 @@ export class ConnectionOnboarding extends Context.Service< EnvironmentId, ConnectionAttemptError | Persistence.ConnectionPersistenceError >; + /** Saves one of my devices from the one-time credential its server handed out. */ + readonly registerDevice: ( + device: TailnetDeviceClientCredential, + ) => Effect.Effect< + EnvironmentId, + ConnectionAttemptError | Persistence.ConnectionPersistenceError + >; readonly registerSsh: ( input: SshConnectionInput, ) => Effect.Effect< @@ -168,6 +179,23 @@ const registerPairingConnection = Effect.fn( return registration.target.environmentId; }); +const registerDeviceConnection = Effect.fn( + "clientRuntime.connection.onboarding.registerDeviceConnection", +)(function* (device: TailnetDeviceClientCredential) { + // The credential works once, so it goes to the first address that answers as the device. + const answering = yield* Effect.findFirst(device.urls, (url) => + fetchRemoteEnvironmentDescriptor({ httpBaseUrl: url }).pipe( + Effect.map((descriptor) => descriptor.environmentId === device.environmentId), + Effect.orElseSucceed(() => false), + ), + ); + return yield* registerPairingConnection({ + host: Option.getOrElse(answering, () => device.urls[0] ?? ""), + pairingCode: device.credential, + expectedEnvironmentId: device.environmentId, + }); +}); + const isBearerCredential = Schema.is(BearerConnectionCredential); const isBearerProfile = Schema.is(BearerConnectionProfile); @@ -315,6 +343,14 @@ export const make = Effect.gen(function* () { Effect.provideService(ClientCapabilities.ClientPresentation, presentation), Effect.provideService(HttpClient.HttpClient, httpClient), ), + registerDevice: Effect.fnUntraced( + function* (device) { + return yield* registerDeviceConnection(device); + }, + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, registry), + Effect.provideService(ClientCapabilities.ClientPresentation, presentation), + Effect.provideService(HttpClient.HttpClient, httpClient), + ), registerSsh: Effect.fnUntraced( function* (input) { return yield* registerSshConnection(input); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2ee976b73808f..cc5df0dedb384 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -57,6 +57,8 @@ import { } from "./orchestrationV2.ts"; import { TailnetChallengeConfirm, + TailnetDeviceClientCredential, + TailnetDeviceClientCredentialRequest, TailnetDevicesSyncPayload, TailnetDevicesUnauthorizedError, TailnetGrant, @@ -874,6 +876,13 @@ class EnvironmentTailnetDevicesHttpApi extends HttpApiGroup.make("tailnetDevices success: TailnetDevicesSyncPayload, error: TailnetDevicesUnauthorizedError, }), + ) + .add( + HttpApiEndpoint.post("clientCredential", "/api/tailnet-devices/client-credential", { + payload: TailnetDeviceClientCredentialRequest, + success: TailnetDeviceClientCredential, + error: TailnetDevicesUnauthorizedError, + }), ) {} export class EnvironmentHttpApi extends HttpApi.make("environment") diff --git a/packages/contracts/src/tailnetDevices.ts b/packages/contracts/src/tailnetDevices.ts index 6d146fbdaeeaa..20ae0577d27ef 100644 --- a/packages/contracts/src/tailnetDevices.ts +++ b/packages/contracts/src/tailnetDevices.ts @@ -148,6 +148,10 @@ export const TailnetDeviceClientCredential = Schema.Struct({ }); export type TailnetDeviceClientCredential = typeof TailnetDeviceClientCredential.Type; +/** Server to one of my devices, over its device session: a credential for my app. */ +export const TailnetDeviceClientCredentialRequest = Schema.Struct({ from: EnvironmentId }); +export type TailnetDeviceClientCredentialRequest = typeof TailnetDeviceClientCredentialRequest.Type; + export const TailnetDeviceStatus = Schema.Literals(["online", "offline", "revoked"]); export type TailnetDeviceStatus = typeof TailnetDeviceStatus.Type;