diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index f53cde3e451d7..3474539b89bee 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -207,7 +207,7 @@ describe("DesktopServerExposure", () => { mode: "network-accessible", endpointUrl: "http://192.168.1.20:4173", advertisedHost: "192.168.1.20", - tailscaleServeEnabled: false, + tailscaleServeEnabled: true, tailscaleServePort: 443, }); @@ -375,6 +375,8 @@ describe("DesktopServerExposure", () => { Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 4173 }); + // Serve reads status on its own; this counts the endpoint cache's reads alone. + yield* serverExposure.setTailscaleServeEnabled({ enabled: false }); yield* serverExposure.setMode("network-accessible"); const initial = yield* serverExposure.getAdvertisedEndpoints; diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 0135225f90c48..ce1d377d247a1 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -274,7 +274,7 @@ describe("DesktopSettings", () => { mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", - tailscaleServeEnabled: false, + tailscaleServeEnabled: true, tailscaleServePort: 443, updateChannel: "nightly", updateChannelConfiguredByUser: false, @@ -303,7 +303,7 @@ describe("DesktopSettings", () => { mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", - tailscaleServeEnabled: false, + tailscaleServeEnabled: true, tailscaleServePort: 443, updateChannel: "latest", updateChannelConfiguredByUser: true, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 5767057b4a35d..0f18d1ca8e522 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -69,7 +69,8 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", - tailscaleServeEnabled: false, + // Tailnet devices dial each other back over Serve; it never publishes beyond the tailnet. + tailscaleServeEnabled: true, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, updateChannel: "latest", updateChannelConfiguredByUser: false, @@ -221,7 +222,7 @@ function normalizeDesktopSettingsDocument( mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", - tailscaleServeEnabled: parsed.tailscaleServeEnabled === true, + tailscaleServeEnabled: parsed.tailscaleServeEnabled !== false, tailscaleServePort: normalizeTailscaleServePort(parsed.tailscaleServePort), updateChannel: updateChannelConfiguredByUser ? Option.getOrElse(parsedUpdateChannel, () => defaultSettings.updateChannel) diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index fe98a1064036f..3903da594818a 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -22,6 +22,7 @@ import { DpopFailureReason, type DpopFailureReason as DpopFailureReasonType, mcpClientScopes, + type EnvironmentId, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Context from "effect/Context"; @@ -78,6 +79,8 @@ export interface McpClientSession { readonly access: AuthMcpClientAccess; /** Another T3 Code environment's link, whose work here stays within it. */ readonly peerLink: boolean; + /** That environment, when it said which one it is. */ + readonly peerEnvironmentId: EnvironmentId | undefined; } export interface AuthenticatedSession { @@ -539,6 +542,7 @@ export class EnvironmentAuth extends Context.Service< readonly label: string; readonly access: AuthMcpClientAccess; readonly peerLink: boolean; + readonly peerEnvironmentId?: EnvironmentId; readonly client: AuthClientMetadata; }) => Effect.Effect< { @@ -1288,6 +1292,7 @@ export const make = Effect.gen(function* () { ? (session.runtimeModeCeiling ?? "approval-required") : "read-only", peerLink: session.peerLink === true, + peerEnvironmentId: session.peerEnvironmentId, } satisfies McpClientSession) : Effect.fail( new ServerAuthInvalidCredentialError({ @@ -1308,6 +1313,9 @@ export const make = Effect.gen(function* () { ttl: MCP_CLIENT_SESSION_TTL, ...(input.access === "read-only" ? {} : { runtimeModeCeiling: input.access }), peerLink: input.peerLink, + ...(input.peerEnvironmentId === undefined + ? {} + : { peerEnvironmentId: input.peerEnvironmentId }), client: { ...input.client, label: input.label, deviceType: "bot" }, }) .pipe( diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 10aa0e430d742..6b7eca81493ef 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -13,6 +13,7 @@ import { type AuthEnvironmentScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + EnvironmentId, MCP_PEER_LINK_SOFTWARE_ID, mcpClientScopes, } from "@t3tools/contracts"; @@ -137,6 +138,8 @@ const ClientIdPayload = Schema.Struct({ r: Schema.Array(Schema.String), /** Registered as another T3 Code environment's peer link. */ p: Schema.optionalKey(Schema.Literal(1)), + /** The linking environment's id, as it said when registering. */ + e: Schema.optionalKey(EnvironmentId), }); const decodeClientIdPayload = Schema.decodeUnknownOption(Schema.fromJsonString(ClientIdPayload)); @@ -145,6 +148,7 @@ export interface McpOAuthClient { readonly name: string; readonly redirectUris: ReadonlyArray; readonly peerLink: boolean; + readonly peerEnvironmentId: EnvironmentId | undefined; } export class McpOAuthPageError extends Schema.TaggedError()( @@ -179,6 +183,7 @@ interface PendingCode { readonly clientId: string; readonly clientName: string; readonly peerLink: boolean; + readonly peerEnvironmentId: EnvironmentId | undefined; readonly redirectUri: string; readonly codeChallenge: string; readonly resource: string; @@ -252,9 +257,20 @@ const make = Effect.gen(function* () { const sign = (domain: string, payload: string) => signPayload(`${domain}.${payload}`, signingKey); - const signClientId = (name: string, redirectUris: ReadonlyArray, peerLink: boolean) => { + const signClientId = ( + name: string, + redirectUris: ReadonlyArray, + peerLink: boolean, + peerEnvironmentId: EnvironmentId | undefined, + ) => { const body = base64UrlEncode( - JSON.stringify({ v: 1, n: name, r: redirectUris, ...(peerLink ? { p: 1 } : {}) }), + JSON.stringify({ + v: 1, + n: name, + r: redirectUris, + ...(peerLink ? { p: 1 } : {}), + ...(peerLink && peerEnvironmentId !== undefined ? { e: peerEnvironmentId } : {}), + }), ); return `${body}.${sign("mcp-client-id", body)}`; }; @@ -276,6 +292,7 @@ const make = Effect.gen(function* () { name: payload.n, redirectUris: payload.r, peerLink: payload.p === 1, + peerEnvironmentId: payload.e, })), ), ); @@ -330,11 +347,13 @@ const make = Effect.gen(function* () { "Linked environments is off on that machine. Turn it on in its Settings → Labs.", }); } + const peerEnvironmentId = peerLink ? metadata.t3_environment_id : undefined; return { - clientId: signClientId(name, redirectUris, peerLink), + clientId: signClientId(name, redirectUris, peerLink, peerEnvironmentId), name, redirectUris, peerLink, + peerEnvironmentId, }; }, ); @@ -418,6 +437,7 @@ const make = Effect.gen(function* () { clientId: authorization.client.clientId, clientName: authorization.client.name, peerLink: authorization.client.peerLink, + peerEnvironmentId: authorization.client.peerEnvironmentId, redirectUri: authorization.redirectUri, codeChallenge: authorization.codeChallenge, resource: authorization.resource, @@ -520,6 +540,9 @@ const make = Effect.gen(function* () { label: pending.clientName, access: pending.access, peerLink: pending.peerLink, + ...(pending.peerEnvironmentId === undefined + ? {} + : { peerEnvironmentId: pending.peerEnvironmentId }), client: deriveAuthClientMetadata({ request }), }) .pipe( diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 0ef7a692fd6db..749c9d26f63d5 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -5,6 +5,7 @@ import { type AuthClientMetadata, type AuthClientSession, type AuthEnvironmentScope, + type EnvironmentId, AuthWebSocketTicketUse, RuntimeMode, type ServerAuthSessionMethod, @@ -61,6 +62,8 @@ export interface VerifiedSession { readonly runtimeModeCeiling?: RuntimeMode; /** An MCP client that is another T3 Code environment's peer link. */ readonly peerLink?: true; + /** The environment holding the session, when another T3 server holds it. */ + readonly peerEnvironmentId?: EnvironmentId; } export interface VerifiedWebSocketTicket extends VerifiedSession { @@ -383,6 +386,7 @@ export class SessionStore extends Context.Service< readonly proofKeyThumbprint?: string; readonly runtimeModeCeiling?: RuntimeMode; readonly peerLink?: boolean; + readonly peerEnvironmentId?: EnvironmentId; readonly replaceActiveForSubjectAndMethod?: boolean; readonly replaceSessionId?: AuthSessionId; }) => Effect.Effect; @@ -566,6 +570,9 @@ const make = Effect.gen(function* () { scopes: row.value.scopes, method: row.value.method, client: toClientMetadata(row.value.client), + ...(row.value.peerEnvironmentId === null + ? {} + : { peerEnvironmentId: row.value.peerEnvironmentId }), issuedAt: row.value.issuedAt, expiresAt: row.value.expiresAt, lastConnectedAt: row.value.lastConnectedAt, @@ -689,6 +696,9 @@ const make = Effect.gen(function* () { os: client.os ?? null, browser: client.browser ?? null, }, + ...(input?.peerEnvironmentId === undefined + ? {} + : { peerEnvironmentId: input.peerEnvironmentId }), issuedAt, expiresAt, } satisfies AuthSessions.CreateAuthSessionInput; @@ -723,6 +733,9 @@ const make = Effect.gen(function* () { scopes: claims.scopes, method: claims.method, client, + ...(input?.peerEnvironmentId === undefined + ? {} + : { peerEnvironmentId: input.peerEnvironmentId }), issuedAt, expiresAt, lastConnectedAt: null, @@ -838,6 +851,9 @@ const make = Effect.gen(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), ...(claims.rtc ? { runtimeModeCeiling: claims.rtc } : {}), ...(claims.plk === 1 ? { peerLink: true as const } : {}), + ...(row.value.peerEnvironmentId === null + ? {} + : { peerEnvironmentId: row.value.peerEnvironmentId }), } satisfies VerifiedSession; }, ); @@ -969,6 +985,7 @@ const make = Effect.gen(function* () { scopes: row.scopes, method: row.method, client: toClientMetadata(row.client), + ...(row.peerEnvironmentId === null ? {} : { peerEnvironmentId: row.peerEnvironmentId }), issuedAt: row.issuedAt, expiresAt: row.expiresAt, lastConnectedAt: row.lastConnectedAt, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 5c48ceea78956..af787bc3b4271 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -680,7 +680,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, - tailscaleServeEnabled: false, + tailscaleServeEnabled: true, tailscaleServePort: undefined, }); }), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 6f2dd771f7079..29b8a3055ae03 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -398,7 +398,8 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct Option.fromUndefinedOr(env.tailscaleServeEnabled), Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled), ), - () => false, + // Tailnet devices dial each other back over Serve. A dev server never owns the machine's route. + () => devUrl === undefined, ); const tailscaleServePort = Option.getOrUndefined( resolveOptionPrecedence( diff --git a/apps/server/src/peer/PeerLinkRequests.test.ts b/apps/server/src/peer/PeerLinkRequests.test.ts index f59202bb140ae..1a86c55c2d889 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -15,11 +15,13 @@ import { RunId, ThreadId, TurnItemId, + type PeerLink, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as Ref from "effect/Ref"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -57,6 +59,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as PeerForwarding from "./PeerForwarding.ts"; import * as PeerLinkRequests from "./PeerLinkRequests.ts"; import * as PeerLinks from "./PeerLinks.ts"; +import * as TailnetDevices from "./TailnetDevices.ts"; import { descriptorOf, layerLinkingEnvironment, @@ -128,6 +131,8 @@ const scope: McpInvocationContext.McpInvocationScope = { /** The laptop: a real orchestrator, its orchestrator toolkit, and its links. */ const makeLaptop = Effect.gen(function* () { const linking = yield* layerLinkingEnvironment(laptop).pipe(Layer.build); + // What Tailscale proves: none of these machines is one of the user's tailnet devices unless set. + const tailnetLink = yield* Ref.make(Option.none()); // The orchestrator's own database, apart from the one links are kept in. const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); const layerDatabase = Layer.succeedContext(database); @@ -155,6 +160,9 @@ const makeLaptop = Effect.gen(function* () { Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(PeerLinkRequests.layer), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), + Layer.provide( + Layer.mock(TailnetDevices.TailnetDevices)({ linkIfMine: () => Ref.get(tailnetLink) }), + ), Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), Layer.provide(Layer.succeedContext(linking)), Layer.provide(NodeCrypto.layer), @@ -177,6 +185,7 @@ const makeLaptop = Effect.gen(function* () { const here = yield* Layer.build(layerHere); const server = Context.get(here, McpServer.McpServer); return { + tailnetLink, links: Context.get(linking, PeerLinks.PeerLinks), linksSql: Context.get(linking, SqlClient.SqlClient), requests: Context.get(here, PeerLinkRequests.PeerLinkRequests), @@ -916,6 +925,37 @@ it.live("naming a machine that is already linked returns the link at once, with ), ); +it.live("naming one of the user's tailnet devices links it at once, with no card", () => + withBoth((a) => + Effect.gen(function* () { + yield* seedThread(a); + const linkedAt = yield* DateTime.now; + yield* Ref.set( + a.tailnetLink, + Option.some({ + environmentId: box.environmentId, + label: "Box", + urls: ["https://box.tail.ts.net"], + access: "full-access", + origin: "tailnet", + linkedAt, + expiresAt: DateTime.add(linkedAt, { days: 30 }), + lastReachedAt: linkedAt, + lastError: null, + }), + ); + const asked = yield* a.call({ environmentId: box.environmentId }); + expect(asked.structuredContent).toEqual({ + status: "linked", + environmentId: box.environmentId, + label: "Box", + access: "full-access", + }); + expect(yield* cardOf(a)).toBeUndefined(); + }), + ), +); + it.live("a link that expired goes through the card, so the user can link it again", () => withBoth((a, b) => Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinkRequests.ts b/apps/server/src/peer/PeerLinkRequests.ts index 15f4877873276..4b3b25a28adbc 100644 --- a/apps/server/src/peer/PeerLinkRequests.ts +++ b/apps/server/src/peer/PeerLinkRequests.ts @@ -33,6 +33,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as PeerLinks from "./PeerLinks.ts"; +import * as TailnetDevices from "./TailnetDevices.ts"; type LinkRequestItem = Extract; @@ -84,6 +85,7 @@ export class PeerLinkRequests extends Context.Service< const make = Effect.gen(function* () { const threadManagement = yield* ThreadManagementService.ThreadManagementService; const peerLinks = yield* PeerLinks.PeerLinks; + const tailnetDevices = yield* TailnetDevices.TailnetDevices; const crypto = yield* Crypto.Crypto; const readCard = (threadId: ThreadId, turnItemId: TurnItemId) => @@ -179,6 +181,21 @@ const make = Effect.gen(function* () { } as const; } } + // One of my tailnet devices needs no card either: Tailscale proves it is mine. + if (input.environmentId !== undefined || input.url !== undefined) { + const linked = yield* tailnetDevices.linkIfMine({ + ...(input.environmentId === undefined ? {} : { environmentId: input.environmentId }), + ...(input.url === undefined ? {} : { url: input.url }), + }); + if (Option.isSome(linked)) { + return { + status: "linked", + environmentId: linked.value.environmentId, + label: linked.value.label, + access: linked.value.access, + } as const; + } + } const key = input.clientRequestId ?? (yield* crypto.randomUUIDv4().pipe(Effect.orDie)); // Turn item ids are global; scope the key to this thread and provider // session, since a later session may reuse it. A retry with the same diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index f16a91f26c0ad..8802bd50c447c 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -136,10 +136,14 @@ it.effect("links with a pairing code, and the peer holds its agents to the limit urls: [b.url], access: "auto", }); - // B lists the link under A's name, where it can be revoked. - expect((yield* linkedSessions(b)).map((session) => session.client.label)).toEqual([ - "T3 Code · Laptop", - ]); + // B lists the link under A's name and environment, where it can be revoked. + expect( + (yield* linkedSessions(b)).map((session) => [ + session.client.label, + session.peerEnvironmentId, + ]), + ).toEqual([["T3 Code · Laptop", laptop.environmentId]]); + expect(linked.origin).toBe("paired"); expect( (yield* a.links.list).map(({ environmentId, status }) => [environmentId, status]), ).toEqual([[box.environmentId, "reachable"]]); diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 36d62c8efcd5c..15397f87d9d62 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -9,6 +9,7 @@ import { type PeerLink, type PeerLinkCreateInput, PeerLinkError, + PeerLinkOrigin, type PeerLinkStatus, type PeerLinkSummary, } from "@t3tools/contracts"; @@ -65,6 +66,8 @@ export class PeerLinks extends Context.Service< /** Whether the Linked environments beta (Settings → Labs) is on here. */ readonly enabled: Effect.Effect; readonly link: (input: PeerLinkCreateInput) => Effect.Effect; + /** Keeps a link another flow already made, such as a tailnet device's grant, with its token. */ + readonly store: (link: PeerLink, token: string) => Effect.Effect; /** * The environment answering at `url`, or nothing when it is not a usable * address or does not answer as T3 Code. Sends no credential. @@ -104,11 +107,13 @@ interface PeerLinkRow { readonly last_reached_at: string | null; readonly last_error: string | null; readonly fenced: number; + readonly origin: string; } const decodeUrls = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Schema.String))); const encodeUrls = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Schema.String))); const decodeAccess = Schema.decodeUnknownEffect(AuthMcpClientAccess); +const decodeOrigin = Schema.decodeUnknownEffect(PeerLinkOrigin); /** Fails with a fixed storage error that keeps its cause; the log gets the details. */ const failStorage = (cause: unknown) => @@ -156,9 +161,24 @@ const peerOrigin = (url: string) => { }; /** Whether a peer holds a linked agent to its limits and keeps its work within its link. */ -const linkLimitsHold = (peer: { readonly capabilities: ExecutionEnvironmentCapabilities }) => +export const linkLimitsHold = (peer: { readonly capabilities: ExecutionEnvironmentCapabilities }) => peer.capabilities.mcpModeLimitHeader === true && peer.capabilities.linkFence === true; +/** + * A client for a peer's HTTP API. Only an origin the caller checked may receive + * a pairing code or token, and a redirect could forward them anywhere, so none + * is followed. + */ +export const peerApiClient = (httpClient: HttpClient.HttpClient, origin: string) => + HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.transform(httpClient, (response) => + Effect.provideService(response, FetchHttpClient.RequestInit, { redirect: "manual" }), + ), + ), + ); + const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const secrets = yield* ServerSecretStore.ServerSecretStore; @@ -177,16 +197,7 @@ const make = Effect.gen(function* () { Effect.orDie, ); - // Only the origin `peerOrigin` checked may receive a pairing code or token: - // a redirect could forward them anywhere, including plain http. - const peerHttpClient = HttpClient.transform(httpClient, (response) => - Effect.provideService(response, FetchHttpClient.RequestInit, { redirect: "manual" }), - ); - - const clientFor = (origin: string) => - HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }).pipe( - Effect.provideService(HttpClient.HttpClient, peerHttpClient), - ); + const clientFor = (origin: string) => peerApiClient(httpClient, origin); /** The environment answering at `origin`, or nothing when it does not answer as T3 Code. */ const describe = (origin: string) => @@ -202,7 +213,7 @@ const make = Effect.gen(function* () { label: row.label, urls: yield* decodeUrls(row.urls_json), access: yield* decodeAccess(row.access), - origin: "paired", + origin: yield* decodeOrigin(row.origin), linkedAt: DateTime.makeUnsafe(row.linked_at), // Its token predates link fencing, so the peer would not fence its work: // like an expired link, only a new pairing code brings it back. @@ -226,7 +237,12 @@ const make = Effect.gen(function* () { /** Signs in to the peer's `/mcp` with a pairing code: register, decide, exchange. */ const signIn = Effect.fn("PeerLinks.signIn")( - function* (origin: string, input: PeerLinkCreateInput, clientName: string) { + function* ( + origin: string, + input: PeerLinkCreateInput, + clientName: string, + selfEnvironmentId: EnvironmentId, + ) { const client = yield* clientFor(origin); // Only the peer's own refusals are rejections. A dropped connection or // an answer this version cannot read is classified below. @@ -236,6 +252,7 @@ const make = Effect.gen(function* () { client_name: clientName, redirect_uris: [LINK_REDIRECT_URI], software_id: MCP_PEER_LINK_SOFTWARE_ID, + t3_environment_id: selfEnvironmentId, }, }) .pipe( @@ -427,7 +444,7 @@ const make = Effect.gen(function* () { message: `${peer.label} runs a T3 Code version that cannot keep a linked agent's limits. Update it, then link again.`, }); } - const issued = yield* signIn(primary, input, `T3 Code · ${self.label}`); + const issued = yield* signIn(primary, input, `T3 Code · ${self.label}`, self.environmentId); const now = yield* DateTime.now; const linked: PeerLink = { environmentId: peer.environmentId, @@ -440,24 +457,31 @@ const make = Effect.gen(function* () { lastReachedAt: now, lastError: null, }; - // The token goes under a name of its own before the row points at it, - // so the row and its token are replaced together. - const secretName = yield* newSecretName; - yield* secrets - .set(secretName, new TextEncoder().encode(issued.access_token)) - .pipe(Effect.catch(failStorage)); - // A token no row names could never be removed. - const previous = yield* Effect.gen(function* () { - const urlsJson = yield* encodeUrls(origins).pipe(Effect.catch(failStorage)); - const previous = yield* readRow(peer.environmentId); - yield* sql` + yield* store(linked, issued.access_token); + return linked; + }); + + const store: PeerLinks["Service"]["store"] = Effect.fn("PeerLinks.store")( + function* (linked, token) { + // The token goes under a name of its own before the row points at it, + // so the row and its token are replaced together. + const secretName = yield* newSecretName; + yield* secrets + .set(secretName, new TextEncoder().encode(token)) + .pipe(Effect.catch(failStorage)); + // A token no row names could never be removed. + const previous = yield* Effect.gen(function* () { + const urlsJson = yield* encodeUrls(linked.urls).pipe(Effect.catch(failStorage)); + const previous = yield* readRow(linked.environmentId); + yield* sql` INSERT INTO peer_environment_links ( environment_id, secret_name, label, urls_json, access, linked_at, expires_at, - last_reached_at, last_error, fenced + last_reached_at, last_error, fenced, origin ) VALUES ( ${linked.environmentId}, ${secretName}, ${linked.label}, ${urlsJson}, ${linked.access}, ${DateTime.formatIso(linked.linkedAt)}, ${DateTime.formatIso(linked.expiresAt)}, - ${DateTime.formatIso(now)}, NULL, 1 + ${linked.lastReachedAt === null ? null : DateTime.formatIso(linked.lastReachedAt)}, + NULL, 1, ${linked.origin} ) ON CONFLICT (environment_id) DO UPDATE SET secret_name = excluded.secret_name, @@ -468,17 +492,18 @@ const make = Effect.gen(function* () { linked_at = excluded.linked_at, expires_at = excluded.expires_at, last_reached_at = excluded.last_reached_at, - last_error = NULL + last_error = NULL, + origin = excluded.origin `.pipe(Effect.catch(failStorage)); - return previous; - }).pipe(Effect.onError(() => secrets.remove(secretName).pipe(Effect.ignore))); - // Linking again replaces the old token. A racing link may leave its own - // behind; it is unused either way. - if (Option.isSome(previous) && previous.value.secret_name !== secretName) { - yield* secrets.remove(previous.value.secret_name).pipe(Effect.ignore); - } - return linked; - }); + return previous; + }).pipe(Effect.onError(() => secrets.remove(secretName).pipe(Effect.ignore))); + // Linking again replaces the old token. A racing link may leave its own + // behind; it is unused either way. + if (Option.isSome(previous) && previous.value.secret_name !== secretName) { + yield* secrets.remove(previous.value.secret_name).pipe(Effect.ignore); + } + }, + ); const describeUrl: PeerLinks["Service"]["describe"] = (url) => peerOrigin(url).pipe( @@ -609,6 +634,7 @@ const make = Effect.gen(function* () { return PeerLinks.of({ enabled, link, + store, describe: describeUrl, list, get, diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts index 8041dea8abedc..fad5697fb5b94 100644 --- a/apps/server/src/peer/PeerMcpClient.ts +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -6,6 +6,8 @@ import { type RuntimeMode, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Option from "effect/Option"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; @@ -16,6 +18,7 @@ import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import * as PeerLinks from "./PeerLinks.ts"; +import * as TailnetDevices from "./TailnetDevices.ts"; /** The only MCP protocol version a T3 Code `/mcp` registers. */ const PROTOCOL_VERSION = "2025-06-18"; @@ -26,6 +29,14 @@ export interface PeerCallLimits { readonly interactionMode: ProviderInteractionMode; } +export interface PeerCallInput { + readonly environmentId: EnvironmentId; + readonly tool: string; + readonly arguments: Record; + readonly limits: PeerCallLimits; + readonly success: S; +} + /** * Calls a linked environment's T3 MCP tools as this environment's outside * agent. Each call carries the calling agent's limits in `T3-Mode-Limit`, so @@ -35,13 +46,9 @@ export class PeerMcpClient extends Context.Service< PeerMcpClient, { /** Calls `tool` on the peer and decodes its structured result with `success`. */ - readonly call: >(input: { - readonly environmentId: EnvironmentId; - readonly tool: string; - readonly arguments: Record; - readonly limits: PeerCallLimits; - readonly success: S; - }) => Effect.Effect; + readonly call: >( + input: PeerCallInput, + ) => Effect.Effect; } >()("t3/peer/PeerMcpClient") {} @@ -83,6 +90,15 @@ const linkedEnvironmentsOffThere = new OrchestratorMcpFailure({ "The linked environment has Linked environments turned off. Turn it on in its Settings → Labs.", }); +/** The peer answered 401: the link's token no longer works there. */ +class PeerTokenRefused extends Data.TaggedError("PeerTokenRefused") {} + +const linkRefused = new OrchestratorMcpFailure({ + code: "capability_denied", + message: + "The linked environment no longer accepts this link. It may have been revoked there; link it again.", +}); + /** The body of an MCP response: JSON, or a single server-sent event carrying JSON. */ const responseJson = (contentType: string | undefined, text: string) => contentType?.includes("text/event-stream") === true @@ -96,6 +112,8 @@ const responseJson = (contentType: string | undefined, text: string) => const make = Effect.gen(function* () { const links = yield* PeerLinks.PeerLinks; const httpClient = yield* HttpClient.HttpClient; + // Absent where nothing links tailnet devices, as in the CLI. + const tailnetDevices = yield* Effect.serviceOption(TailnetDevices.TailnetDevices); // One MCP session per peer and address, reused across calls. The peer keeps // sessions in memory, so a restart there answers 404 and we start a new one. const sessions = yield* Ref.make>(new Map()); @@ -134,13 +152,11 @@ const make = Effect.gen(function* () { ); const sessionId = initialized.headers["mcp-session-id"]; if (initialized.status === 403) return yield* linkedEnvironmentsOffThere; + if (initialized.status === 401) return yield* new PeerTokenRefused(); if (initialized.status !== 200 || sessionId === undefined) { return yield* new OrchestratorMcpFailure({ - code: initialized.status === 401 ? "capability_denied" : "orchestration_error", - message: - initialized.status === 401 - ? "The linked environment no longer accepts this link. It may have been revoked there; link it again." - : `The linked environment refused to start an MCP session (HTTP ${initialized.status}).`, + code: "orchestration_error", + message: `The linked environment refused to start an MCP session (HTTP ${initialized.status}).`, }); } yield* post( @@ -152,95 +168,111 @@ const make = Effect.gen(function* () { return sessionId; }); + // `call` owns the span; a retry after a refreshed link is a second attempt inside it. + const attempt = Effect.fnUntraced(function* >( + input: PeerCallInput, + ) { + const resolved = yield* links.resolve(input.environmentId).pipe(Effect.mapError(fromLinkError)); + const limitHeader = { + [McpInvocationContext.MODE_LIMIT_HEADER]: `${input.limits.runtimeMode}/${input.limits.interactionMode}`, + }; + const sessionKey = `${input.environmentId} ${resolved.url}`; + const callOnce = (sessionId: string) => + post( + resolved.url, + resolved.token, + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: input.tool, arguments: input.arguments }, + }, + { ...limitHeader, "mcp-session-id": sessionId, "mcp-protocol-version": PROTOCOL_VERSION }, + ); + const existing = (yield* Ref.get(sessions)).get(sessionKey); + const sessionId = existing ?? (yield* openSession(resolved.url, resolved.token, limitHeader)); + let response = yield* callOnce(sessionId); + if (response.status === 404) { + const renewed = yield* openSession(resolved.url, resolved.token, limitHeader); + yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, renewed)); + response = yield* callOnce(renewed); + } else if (existing === undefined) { + yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, sessionId)); + } + if (response.status === 401) return yield* new PeerTokenRefused(); + if (response.status === 403) return yield* linkedEnvironmentsOffThere; + if (response.status !== 200) { + return yield* new OrchestratorMcpFailure({ + code: "orchestration_error", + message: `The linked environment answered HTTP ${response.status}.`, + }); + } + const body = yield* decodeToolCallResponse( + responseJson(response.headers["content-type"], yield* response.text), + ).pipe( + Effect.mapError( + () => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: "The linked environment answered in an unexpected shape.", + }), + ), + ); + if (body.error !== undefined || body.result === undefined) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: body.error?.message ?? "The linked environment returned no result.", + }); + } + const text = body.result.content.map((part) => part.text ?? "").join(""); + if (body.result.isError === true) { + // A refusal keeps the peer's own code, so the agent sees why it was refused. + const refused = decodeFailure(text); + return yield* refused._tag === "Some" + ? new OrchestratorMcpFailure({ code: refused.value.code, message: refused.value.message }) + : new OrchestratorMcpFailure({ + code: "orchestration_error", + message: text || "The linked environment refused the call.", + }); + } + // Display text may be compacted for the model; peers consume the typed wire payload. + const success: Schema.Decoder<(typeof input.success)["Type"]> = + body.result.structuredContent === undefined + ? Schema.fromJsonString(input.success) + : input.success; + const payload = + body.result.structuredContent === undefined ? text : body.result.structuredContent; + return yield* Schema.decodeEffect(success)(payload).pipe( + Effect.mapError( + () => + new OrchestratorMcpFailure({ + code: "orchestration_error", + message: + "The linked environment's answer did not match this version. Update both environments.", + }), + ), + ); + }); + + /** A tailnet device's link is renewed by its handshake, then tried once more. */ + const retryAfterRefresh = Effect.fn("PeerMcpClient.retryAfterRefresh")(function* < + S extends Schema.Decoder, + >(input: PeerCallInput) { + const link = yield* links + .get(input.environmentId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(tailnetDevices) || Option.isNone(link) || link.value.origin !== "tailnet") { + return yield* new PeerTokenRefused(); + } + yield* tailnetDevices.value.refresh(input.environmentId); + return yield* attempt(input); + }); + const call: PeerMcpClient["Service"]["call"] = Effect.fn("PeerMcpClient.call")( function* (input) { - const resolved = yield* links - .resolve(input.environmentId) - .pipe(Effect.mapError(fromLinkError)); - const limitHeader = { - [McpInvocationContext.MODE_LIMIT_HEADER]: `${input.limits.runtimeMode}/${input.limits.interactionMode}`, - }; - const sessionKey = `${input.environmentId} ${resolved.url}`; - const callOnce = (sessionId: string) => - post( - resolved.url, - resolved.token, - { - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { name: input.tool, arguments: input.arguments }, - }, - { ...limitHeader, "mcp-session-id": sessionId, "mcp-protocol-version": PROTOCOL_VERSION }, - ); - const existing = (yield* Ref.get(sessions)).get(sessionKey); - const sessionId = existing ?? (yield* openSession(resolved.url, resolved.token, limitHeader)); - let response = yield* callOnce(sessionId); - if (response.status === 404) { - const renewed = yield* openSession(resolved.url, resolved.token, limitHeader); - yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, renewed)); - response = yield* callOnce(renewed); - } else if (existing === undefined) { - yield* Ref.update(sessions, (current) => new Map(current).set(sessionKey, sessionId)); - } - if (response.status === 401) { - return yield* new OrchestratorMcpFailure({ - code: "capability_denied", - message: - "The linked environment no longer accepts this link. It may have been revoked there; link it again.", - }); - } - if (response.status === 403) return yield* linkedEnvironmentsOffThere; - if (response.status !== 200) { - return yield* new OrchestratorMcpFailure({ - code: "orchestration_error", - message: `The linked environment answered HTTP ${response.status}.`, - }); - } - const body = yield* decodeToolCallResponse( - responseJson(response.headers["content-type"], yield* response.text), - ).pipe( - Effect.mapError( - () => - new OrchestratorMcpFailure({ - code: "orchestration_error", - message: "The linked environment answered in an unexpected shape.", - }), - ), - ); - if (body.error !== undefined || body.result === undefined) { - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: body.error?.message ?? "The linked environment returned no result.", - }); - } - const text = body.result.content.map((part) => part.text ?? "").join(""); - if (body.result.isError === true) { - // A refusal keeps the peer's own code, so the agent sees why it was refused. - const refused = decodeFailure(text); - return yield* refused._tag === "Some" - ? new OrchestratorMcpFailure({ code: refused.value.code, message: refused.value.message }) - : new OrchestratorMcpFailure({ - code: "orchestration_error", - message: text || "The linked environment refused the call.", - }); - } - // Display text may be compacted for the model; peers consume the typed wire payload. - const success: Schema.Decoder<(typeof input.success)["Type"]> = - body.result.structuredContent === undefined - ? Schema.fromJsonString(input.success) - : input.success; - const payload = - body.result.structuredContent === undefined ? text : body.result.structuredContent; - return yield* Schema.decodeEffect(success)(payload).pipe( - Effect.mapError( - () => - new OrchestratorMcpFailure({ - code: "orchestration_error", - message: - "The linked environment's answer did not match this version. Update both environments.", - }), - ), + return yield* attempt(input).pipe( + Effect.catchTags({ PeerTokenRefused: () => retryAfterRefresh(input) }), + Effect.catchTags({ PeerTokenRefused: () => Effect.fail(linkRefused) }), ); }, (effect, input) => diff --git a/apps/server/src/peer/TailnetDevices.test.ts b/apps/server/src/peer/TailnetDevices.test.ts new file mode 100644 index 0000000000000..8128954965053 --- /dev/null +++ b/apps/server/src/peer/TailnetDevices.test.ts @@ -0,0 +1,426 @@ +import { NodeHttpServer } from "@effect/platform-node"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { + EnvironmentHttpApi, + EnvironmentId, + type ExecutionEnvironmentDescriptor, + TAILNET_DEVICE_CAPABILITY, +} from "@t3tools/contracts"; +import type { Tailnet, TailnetWhois } from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpRouter from "effect/http/HttpRouter"; +import * as HttpServer from "effect/http/HttpServer"; +import * as HttpServerRequest from "effect/http/HttpServerRequest"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; +import { describe, expect } from "vite-plus/test"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as DirectEndpoints from "../environment/DirectEndpoints.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as ServerHttp from "../http.ts"; +import * as Sqlite from "../persistence/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import { descriptorOf } from "./PeerLinks.testkit.ts"; +import * as TailnetDevices from "./TailnetDevices.ts"; +import * as TailnetDevicesHttp from "./tailnetDevicesHttp.ts"; +import * as TailnetIdentity from "./TailnetIdentity.ts"; + +class DeviceTestApi extends HttpApi.make("environment") + .add(EnvironmentHttpApi.groups.metadata) + .add(EnvironmentHttpApi.groups.tailnetDevices) {} + +const OWNER = "1001"; + +interface Node { + readonly address: string; + readonly whois: TailnetWhois; +} + +/** + * The tailnet as Tailscale reports it: which node owns each address. Devices + * run on loopback, so each origin is mapped to the tailnet address a real + * MagicDNS name or IP would reach. + */ +const makeTailnet = Effect.gen(function* () { + const byOrigin = yield* Ref.make(new Map()); + const identityFor = (selfUserId: string) => + Layer.succeed(TailnetIdentity.TailnetIdentity, { + read: Effect.succeedSome({ + self: { + userId: selfUserId, + magicDnsName: null, + tailnetAddresses: [], + httpsServeEnabled: true, + }, + peers: [], + }), + locate: (url) => { + const origin = URL.parse(url)?.origin; + const node = origin === undefined ? undefined : Ref.getUnsafe(byOrigin).get(origin); + return node === undefined + ? Option.none() + : Option.some({ origin: origin!, address: node.address }); + }, + whois: (address) => + Ref.get(byOrigin).pipe( + Effect.map((nodes) => + Option.fromNullishOr( + [...nodes.values()].find((node) => node.address === address)?.whois, + ), + ), + ), + probeUrls: () => [], + }); + const join = (origin: string, whois: Partial = {}) => + Ref.update(byOrigin, (nodes) => { + const address = `100.64.0.${nodes.size + 1}`; + return new Map(nodes).set(origin, { + address, + whois: { + userId: OWNER, + loginName: "owner@example.com", + nodeName: `node-${nodes.size + 1}`, + tags: [], + capabilities: [], + ...whois, + }, + }); + }); + return { identityFor, join }; +}); + +type FakeTailnet = Effect.Success; + +/** One T3 server on the tailnet: its own database, auth and links, answering on a socket of its own. */ +const serveDevice = ( + descriptor: ExecutionEnvironmentDescriptor, + tailnet: FakeTailnet, + options: { readonly userId?: string } = {}, +) => + Layer.build(Layer.fresh(NodeHttpServer.layerTest)).pipe( + Effect.flatMap((server) => + serveDeviceOn(descriptor, tailnet, options).pipe(Effect.provideContext(server)), + ), + ); + +const serveDeviceOn = ( + descriptor: ExecutionEnvironmentDescriptor, + tailnet: FakeTailnet, + options: { readonly userId?: string } = {}, +) => + Effect.gen(function* () { + const url = yield* Ref.make("http://127.0.0.1:0"); + const grants = yield* Ref.make(0); + const context = yield* TailnetDevices.layer.pipe( + Layer.provideMerge(PeerLinks.layer), + Layer.provideMerge(EnvironmentAuth.layer), + Layer.provide(tailnet.identityFor(options.userId ?? OWNER)), + Layer.provide( + Layer.succeed(DirectEndpoints.DirectEndpoints, { + resolve: () => + Ref.get(url).pipe( + Effect.map((origin) => [{ kind: "tailnet" as const, httpBaseUrl: `${origin}/` }]), + ), + }), + ), + Layer.provideMerge(ServerSettings.layerTest({ linkedEnvironmentsEnabled: true })), + Layer.provideMerge(Sqlite.layerMemory), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(ServerEnvironment.layerIdentity), + Layer.provideMerge( + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(descriptor.environmentId), + getDescriptor: Effect.succeed(descriptor), + }), + ), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-tailnet-device-" })), + Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(NodeServices.layer), + Layer.fresh, + Layer.build, + ); + const countGrants = HttpRouter.middleware( + (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.url.startsWith("/api/tailnet-devices/grant")) { + yield* Ref.update(grants, (count) => count + 1); + } + return yield* httpEffect; + }), + { global: true }, + ); + yield* HttpRouter.serve( + HttpApiBuilder.layer(DeviceTestApi).pipe( + Layer.provide(TailnetDevicesHttp.layer), + Layer.provide(ServerHttp.layerServerEnvironmentHttpApi), + Layer.provide(countGrants), + Layer.provide(Layer.succeedContext(context)), + ), + { disableListenLog: true, disableLogger: true }, + ).pipe(Layer.fresh, Layer.build); + const address = (yield* HttpServer.HttpServer).address; + if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address"); + const origin = `http://127.0.0.1:${address.port}`; + yield* Ref.set(url, origin); + const auth = Context.get(context, EnvironmentAuth.EnvironmentAuth); + return { + url: origin, + descriptor, + devices: Context.get(context, TailnetDevices.TailnetDevices), + links: Context.get(context, PeerLinks.PeerLinks), + settings: Context.get(context, ServerSettings.ServerSettingsService), + /** Grants delivered to this device, each carrying a token. */ + grants, + /** Sessions this device issued to other devices. */ + issued: auth + .listSessions() + .pipe( + Effect.map((sessions) => + sessions.filter((session) => session.peerEnvironmentId !== undefined), + ), + ), + }; + }); + +const laptop = descriptorOf("env-laptop", "Laptop"); +const studio = descriptorOf("env-studio", "Studio"); + +/** Calls a device's request endpoint as anyone on the network could. */ +const requestAs = ( + issuer: { readonly url: string; readonly descriptor: ExecutionEnvironmentDescriptor }, + requester: { readonly environmentId: string; readonly urls: ReadonlyArray }, +) => + Effect.gen(function* () { + const client = yield* PeerLinks.peerApiClient(yield* HttpClient.HttpClient, issuer.url); + return yield* client.tailnetDevices.request({ + payload: { + requester: { + environmentId: EnvironmentId.make(requester.environmentId), + label: "Requester", + urls: requester.urls, + }, + issuerEnvironmentId: issuer.descriptor.environmentId, + challenge: "c".repeat(43), + }, + }); + }).pipe(Effect.provide(FetchHttpClient.layer)); + +describe("tailnet device handshake", () => { + it.live("links both ways from one request between two of my devices", () => + 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); + + const linked = yield* a.devices.linkIfMine({ url: b.url }); + + expect(Option.map(linked, (link) => [link.environmentId, link.origin])).toEqual( + Option.some([studio.environmentId, "tailnet"]), + ); + const back = yield* b.links.get(laptop.environmentId); + expect(Option.map(back, (link) => [link.origin, link.urls])).toEqual( + Option.some(["tailnet", [a.url]]), + ); + // Each side holds exactly the session the other issued it, labelled by device. + expect((yield* a.issued).map((session) => session.peerEnvironmentId)).toEqual([ + studio.environmentId, + ]); + expect((yield* b.issued).map((session) => session.peerEnvironmentId)).toEqual([ + laptop.environmentId, + ]); + expect( + (yield* a.devices.list).devices.map((device) => [ + device.environmentId, + device.incoming, + device.outgoing, + ]), + ).toEqual([[studio.environmentId, true, "tailnet"]]); + }), + ); + + it.live.each([ + { name: "same user but tagged", whois: { tags: ["tag:server"] }, joins: true }, + { name: "another user", whois: { userId: "2002" }, joins: true }, + { name: "a non-tailnet address", whois: {}, joins: false }, + ])("refuses $name without sending a token", ({ whois, joins }) => + Effect.gen(function* () { + const tailnet = yield* makeTailnet; + const issuer = yield* serveDevice(studio, tailnet); + const requester = yield* serveDevice(laptop, tailnet); + yield* tailnet.join(issuer.url); + if (joins) yield* tailnet.join(requester.url, whois); + + const result = yield* requestAs(issuer, { + environmentId: laptop.environmentId, + urls: [requester.url], + }); + + expect(result).toEqual({ outcome: "refused", reason: "not_my_device" }); + expect(yield* Ref.get(requester.grants)).toBe(0); + expect(yield* issuer.issued).toEqual([]); + }), + ); + + it.live("refuses a forged request naming a real device's address without sending a token", () => + Effect.gen(function* () { + const tailnet = yield* makeTailnet; + const issuer = yield* serveDevice(studio, tailnet); + const victim = yield* serveDevice(laptop, tailnet); + yield* tailnet.join(issuer.url); + yield* tailnet.join(victim.url); + + // Anyone can claim the laptop's identity and address; the laptop has no pending challenge. + const result = yield* requestAs(issuer, { + environmentId: laptop.environmentId, + urls: [victim.url], + }); + + expect(result).toEqual({ outcome: "refused", reason: "grant_rejected" }); + expect(yield* Ref.get(victim.grants)).toBe(0); + expect(yield* issuer.issued).toEqual([]); + expect(yield* victim.links.get(studio.environmentId)).toEqual(Option.none()); + }), + ); + + it.live("refuses a request whose address answers as another environment", () => + Effect.gen(function* () { + const tailnet = yield* makeTailnet; + const issuer = yield* serveDevice(studio, tailnet); + const requester = yield* serveDevice(laptop, tailnet); + yield* tailnet.join(issuer.url); + yield* tailnet.join(requester.url); + + const result = yield* requestAs(issuer, { + environmentId: "env-impostor", + urls: [requester.url], + }); + + expect(result).toEqual({ outcome: "refused", reason: "descriptor_mismatch" }); + expect(yield* Ref.get(requester.grants)).toBe(0); + expect(yield* issuer.issued).toEqual([]); + }), + ); + + it.live("counts only nodes with the device capability when it is required", () => + Effect.gen(function* () { + const tailnet = yield* makeTailnet; + const a = yield* serveDevice(laptop, tailnet); + const b = yield* serveDevice(studio, tailnet); + const c = yield* serveDevice(descriptorOf("env-phone", "Phone"), tailnet); + yield* tailnet.join(a.url, { capabilities: [TAILNET_DEVICE_CAPABILITY] }); + yield* tailnet.join(b.url, { capabilities: [TAILNET_DEVICE_CAPABILITY] }); + yield* tailnet.join(c.url); + yield* b.devices.updateSettings({ requireCapability: true }); + + expect(yield* requestAs(b, { environmentId: "env-phone", urls: [c.url] })).toEqual({ + outcome: "refused", + reason: "not_my_device", + }); + expect(yield* Ref.get(c.grants)).toBe(0); + + const linked = yield* a.devices.linkIfMine({ url: b.url }); + expect(Option.map(linked, (link) => link.environmentId)).toEqual( + Option.some(studio.environmentId), + ); + }), + ); + + it.live("links a device another device found, once they sync", () => + Effect.gen(function* () { + const tailnet = yield* makeTailnet; + const a = yield* serveDevice(laptop, tailnet); + const b = yield* serveDevice(studio, tailnet); + const c = yield* serveDevice(descriptorOf("env-box", "Box"), tailnet); + yield* tailnet.join(a.url); + yield* tailnet.join(b.url); + yield* tailnet.join(c.url); + yield* a.devices.linkIfMine({ url: b.url }); + yield* b.devices.linkIfMine({ url: c.url }); + expect(yield* a.links.get(EnvironmentId.make("env-box"))).toEqual(Option.none()); + + // The scan's probes find nothing; only the studio knows the box. + yield* a.devices.scan; + + const linked = yield* a.links.get(EnvironmentId.make("env-box")); + expect(Option.map(linked, (link) => [link.origin, link.urls])).toEqual( + Option.some(["tailnet", [c.url]]), + ); + expect(Option.isSome(yield* c.links.get(laptop.environmentId))).toBe(true); + }), + ); + + it.live("refuses every request while linking tailnet devices is off", () => + 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* b.devices.updateSettings({ autoLink: false }); + + expect(yield* requestAs(b, { environmentId: laptop.environmentId, urls: [a.url] })).toEqual({ + outcome: "refused", + reason: "disabled", + }); + expect(yield* a.devices.linkIfMine({ url: b.url })).toEqual(Option.none()); + expect(yield* Ref.get(a.grants)).toBe(0); + }), + ); +}); + +describe("locating a tailnet target", () => { + const tailnet: Tailnet = { + self: { + userId: OWNER, + magicDnsName: "laptop.tail.ts.net", + tailnetAddresses: [], + httpsServeEnabled: true, + }, + peers: [ + { + nodeName: "studio", + magicDnsName: "studio.tail.ts.net", + tailnetAddresses: ["100.64.0.7", "fd7a:115c:a1e0::7"], + userId: OWNER, + tags: [], + online: true, + os: "macOS", + }, + ], + }; + + it.each([ + ["https://studio.tail.ts.net", { origin: "https://studio.tail.ts.net", address: "100.64.0.7" }], + [ + "https://STUDIO.tail.ts.net:8443/x", + { origin: "https://studio.tail.ts.net:8443", address: "100.64.0.7" }, + ], + ["http://100.64.0.9:3773", { origin: "http://100.64.0.9:3773", address: "100.64.0.9" }], + [ + "http://[fd7a:115c:a1e0::7]:3773", + { origin: "http://[fd7a:115c:a1e0::7]:3773", address: "fd7a:115c:a1e0::7" }, + ], + // A name over plain http is whatever DNS says; it is never trusted. + ["http://studio.tail.ts.net", null], + ["https://unknown.tail.ts.net", null], + ["http://192.168.1.20:3773", null], + ["https://example.com", null], + ["ftp://100.64.0.7", null], + ])("%s", (url, expected) => { + expect(TailnetIdentity.locateTailnetTarget(url, tailnet)).toEqual(expected); + }); +}); diff --git a/apps/server/src/peer/TailnetDevices.ts b/apps/server/src/peer/TailnetDevices.ts new file mode 100644 index 0000000000000..a83a11eb2bcee --- /dev/null +++ b/apps/server/src/peer/TailnetDevices.ts @@ -0,0 +1,916 @@ +import { + type EnvironmentId, + type ExecutionEnvironmentDescriptor, + type PeerLink, + DEFAULT_TAILNET_DEVICES_SETTINGS, + TAILNET_DEVICE_CAPABILITY, + type TailnetChallengeConfirm, + type TailnetDevice, + type TailnetDeviceDescriptor, + TailnetDevicesError, + type TailnetDevicesSnapshot, + TailnetDevicesSettings, + type TailnetDevicesSyncPayload, + TailnetDevicesUnauthorizedError, + type TailnetDevicesUpdateSettingsInput, + type TailnetGrant, + type TailnetGrantResult, + type TailnetHlc, + type TailnetLinkRefusal, + type TailnetLinkRequest, + type TailnetLinkResult, +} from "@t3tools/contracts"; +import type { Tailnet, TailnetWhois } from "@t3tools/tailscale"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Base64Url from "effect/encoding/Base64Url"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import type * as HttpServerRequest from "effect/http/HttpServerRequest"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as DirectEndpoints from "../environment/DirectEndpoints.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as PeerLinks from "./PeerLinks.ts"; +import * as TailnetIdentity from "./TailnetIdentity.ts"; + +/** How long a requester waits for the issuer it asked to dial back. */ +const CHALLENGE_TTL = Duration.minutes(2); +const PROBE_TIMEOUT = Duration.millis(2500); +const PROBE_CONCURRENCY = 4; +/** Each handshake whoises and dials out; an unauthenticated caller must not run many at once. */ +const HANDSHAKE_CONCURRENCY = 4; +/** An issuer's whole answer, including its own handshake to link back. */ +const HANDSHAKE_TIMEOUT = Duration.seconds(40); +const REQUEST_TIMEOUT = Duration.seconds(60); +const DESCRIBE_TIMEOUT = Duration.seconds(5); +/** A requester names a few addresses; more would let it make this server dial on its behalf. */ +const MAX_REQUESTER_URLS = 8; +const REFRESH_BEFORE_EXPIRY = Duration.days(7); +const FULL_SCAN_INTERVAL = Duration.minutes(10); +const TAILNET_POLL_INTERVAL = Duration.minutes(1); +/** A device answered a scan this recently, so it counts as online. */ +const ONLINE_WINDOW = Duration.minutes(15); + +/** Tailscale says the node belongs to this node's user, untagged, and holds the capability when it is required. */ +const isMyDevice = ( + whois: TailnetWhois, + tailnet: Tailnet, + settings: Pick, +): boolean => + whois.userId !== null && + whois.userId === tailnet.self.userId && + whois.tags.length === 0 && + (!settings.requireCapability || whois.capabilities.includes(TAILNET_DEVICE_CAPABILITY)); + +interface PendingChallenge { + readonly issuer: EnvironmentId; + /** The issuer's address this environment proved and asked; the link keeps it. */ + readonly origin: string; + readonly label: string; + readonly expiresAtMs: number; +} + +interface ProvenDevice { + readonly origin: string; + readonly descriptor: ExecutionEnvironmentDescriptor; + readonly nodeName: string; +} + +interface DeviceRow { + readonly environment_id: string; + readonly label: string; + readonly urls_json: string; + readonly tailnet_name: string | null; + readonly last_seen_at: string | null; +} + +const decodeUrls = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Schema.String))); +const encodeUrls = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Schema.String))); +const decodeSettings = Schema.decodeUnknownEffect(Schema.fromJsonString(TailnetDevicesSettings)); +const encodeSettings = Schema.encodeEffect(Schema.fromJsonString(TailnetDevicesSettings)); + +const REFUSAL_MESSAGES: Record = { + disabled: "Linking tailnet devices is off on that machine.", + not_my_device: "Tailscale does not say that machine is one of your devices.", + revoked: "That machine revoked this one.", + wrong_issuer: "That address answered as another machine.", + unreachable: "That machine could not reach this one back over Tailscale.", + descriptor_mismatch: "This machine's address answered as another machine.", + grant_rejected: "This machine did not accept the link.", + storage: "That machine could not save the link.", +}; + +const unreachable = (message: string, cause?: unknown) => + new TailnetDevicesError({ + reason: "unreachable", + message, + ...(cause === undefined ? {} : { cause }), + }); + +const storageError = (cause: unknown) => + new TailnetDevicesError({ + reason: "storage", + message: "Tailnet devices could not be read or saved here. The server log has details.", + cause, + }); + +/** + * One owner's T3 servers on their tailnet, linked both ways without a pairing + * code. Each side proves the other through Tailscale before giving or using + * access: the issuer whoises the address it dials, never an inbound header or + * source address, and the token only travels to that address. + */ +export class TailnetDevices extends Context.Service< + TailnetDevices, + { + readonly settings: Effect.Effect; + readonly updateSettings: ( + input: TailnetDevicesUpdateSettingsInput, + ) => Effect.Effect; + readonly list: Effect.Effect; + /** + * Links a device directly when Tailscale proves it is one of mine, from its + * id (found on the tailnet or named by a linked device) or an address. + * Nothing when it is not one, so the caller can fall back to a pairing code. + */ + readonly linkIfMine: (target: { + readonly environmentId?: EnvironmentId; + readonly url?: string; + }) => 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; + readonly handleConfirm: (confirm: TailnetChallengeConfirm) => Effect.Effect; + readonly handleGrant: (grant: TailnetGrant) => Effect.Effect; + readonly handleSync: ( + request: HttpServerRequest.HttpServerRequest, + payload: TailnetDevicesSyncPayload, + ) => 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. */ + readonly start: Effect.Effect; + } +>()("t3/peer/TailnetDevices") {} + +const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const crypto = yield* Crypto.Crypto; + const httpClient = yield* HttpClient.HttpClient; + const environment = yield* ServerEnvironment.ServerEnvironment; + const directEndpoints = yield* DirectEndpoints.DirectEndpoints; + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const links = yield* PeerLinks.PeerLinks; + const identity = yield* TailnetIdentity.TailnetIdentity; + const pending = yield* Ref.make>(new Map()); + const lastStamp = yield* Ref.make(null); + const handshakes = yield* Semaphore.make(HANDSHAKE_CONCURRENCY); + + const settings: TailnetDevices["Service"]["settings"] = sql<{ settings_json: string }>` + SELECT settings_json FROM tailnet_device_settings WHERE id = 1 + `.pipe( + Effect.flatMap((rows) => + rows[0] === undefined + ? Effect.succeed(DEFAULT_TAILNET_DEVICES_SETTINGS) + : decodeSettings(rows[0].settings_json), + ), + Effect.tapError((cause) => Effect.logWarning("Tailnet device settings unreadable", { cause })), + Effect.orElseSucceed(() => DEFAULT_TAILNET_DEVICES_SETTINGS), + ); + + /** A stamp later than every one this environment made or saw. */ + const stamp = Effect.gen(function* () { + const node = yield* environment.getEnvironmentId; + const now = yield* Clock.currentTimeMillis; + return yield* Ref.modify(lastStamp, (last): [TailnetHlc, TailnetHlc] => { + const next = + last === null || now > last.wallMs + ? { wallMs: now, counter: 0, node } + : { wallMs: last.wallMs, counter: last.counter + 1, node }; + return [next, next]; + }); + }); + + const writeSettings = (next: TailnetDevicesSettings) => + encodeSettings(next).pipe( + Effect.flatMap( + (json) => sql` + INSERT INTO tailnet_device_settings (id, settings_json) VALUES (1, ${json}) + ON CONFLICT (id) DO UPDATE SET settings_json = excluded.settings_json + `, + ), + Effect.mapError(storageError), + ); + + const updateSettings: TailnetDevices["Service"]["updateSettings"] = Effect.fn( + "TailnetDevices.updateSettings", + )(function* (input) { + const current = yield* settings; + const updatedAt = yield* stamp; + const next: TailnetDevicesSettings = { + autoLink: input.autoLink ?? current.autoLink, + defaultAccess: input.defaultAccess ?? current.defaultAccess, + requireCapability: input.requireCapability ?? current.requireCapability, + updatedAt, + updatedBy: updatedAt.node, + }; + yield* writeSettings(next); + return next; + }); + + /** Linked environments is on, and so is linking tailnet devices automatically. */ + const enabled = Effect.all([links.enabled, settings]).pipe( + Effect.map(([linksOn, current]) => linksOn && current.autoLink), + ); + + const readDevices = sql`SELECT * FROM tailnet_devices`.pipe( + Effect.mapError(storageError), + ); + + const upsertDevice = ( + device: TailnetDeviceDescriptor, + seen: { readonly tailnetName: string; readonly at: DateTime.Utc } | null, + ) => + encodeUrls(device.urls).pipe( + Effect.flatMap( + (urlsJson) => sql` + INSERT INTO tailnet_devices (environment_id, label, urls_json, tailnet_name, last_seen_at) + VALUES ( + ${device.environmentId}, ${device.label}, ${urlsJson}, + ${seen?.tailnetName ?? null}, ${seen === null ? null : DateTime.formatIso(seen.at)} + ) + ON CONFLICT (environment_id) DO UPDATE SET + label = excluded.label, + urls_json = CASE WHEN excluded.last_seen_at IS NULL + THEN tailnet_devices.urls_json ELSE excluded.urls_json END, + tailnet_name = COALESCE(excluded.tailnet_name, tailnet_devices.tailnet_name), + last_seen_at = COALESCE(excluded.last_seen_at, tailnet_devices.last_seen_at) + `, + ), + Effect.tapError((cause) => Effect.logWarning("Could not record a tailnet device", { cause })), + Effect.ignore, + ); + + /** Hints from another device only add addresses; what was seen here stays. */ + const addHint = (device: TailnetDeviceDescriptor) => + sql`SELECT * FROM tailnet_devices WHERE environment_id = ${device.environmentId}`.pipe( + Effect.flatMap((rows) => + rows[0] === undefined + ? upsertDevice(device, null) + : decodeUrls(rows[0].urls_json).pipe( + Effect.flatMap((known) => + encodeUrls([...new Set([...known, ...device.urls])].slice(0, MAX_REQUESTER_URLS)), + ), + Effect.flatMap( + (urlsJson) => + sql`UPDATE tailnet_devices SET urls_json = ${urlsJson} WHERE environment_id = ${device.environmentId}`, + ), + ), + ), + Effect.ignore, + ); + + /** This environment as another device should reach it: its tailnet addresses only. */ + const selfDescriptor = Effect.gen(function* () { + const descriptor = yield* environment.getDescriptor; + const endpoints = yield* directEndpoints.resolve(); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + urls: endpoints + .filter((endpoint) => endpoint.kind === "tailnet") + .map((endpoint) => new URL(endpoint.httpBaseUrl).origin), + } satisfies TailnetDeviceDescriptor; + }); + + const clientFor = (origin: string) => PeerLinks.peerApiClient(httpClient, origin); + + const describe = (origin: string, timeout: Duration.Duration) => + clientFor(origin).pipe( + Effect.flatMap((client) => client.metadata.descriptor()), + Effect.timeout(timeout), + Effect.option, + ); + + /** + * The first of `urls` that Tailscale proves belongs to one of my devices and + * answers as `environmentId`. Otherwise the most telling reason it was not. + */ + const prove = Effect.fn("TailnetDevices.prove")(function* ( + urls: ReadonlyArray, + environmentId: EnvironmentId, + tailnet: Tailnet, + current: TailnetDevicesSettings, + ) { + let refusal: TailnetLinkRefusal = "not_my_device"; + for (const url of urls.slice(0, MAX_REQUESTER_URLS)) { + const target = identity.locate(url, tailnet); + if (Option.isNone(target)) continue; + const whois = yield* identity.whois(target.value.address); + if (Option.isNone(whois) || !isMyDevice(whois.value, tailnet, current)) continue; + const descriptor = yield* describe(target.value.origin, DESCRIBE_TIMEOUT); + if (Option.isNone(descriptor)) { + refusal = "unreachable"; + continue; + } + if (descriptor.value.environmentId !== environmentId) { + refusal = "descriptor_mismatch"; + continue; + } + return { + _tag: "proven" as const, + device: { + origin: target.value.origin, + descriptor: descriptor.value, + nodeName: whois.value.nodeName, + } satisfies ProvenDevice, + }; + } + return { _tag: "refused" as const, reason: refusal }; + }); + + const newChallenge = crypto.randomBytes(32).pipe(Effect.map(Base64Url.encode), Effect.orDie); + + /** Asks a device to link this environment, after proving it is one of mine. */ + const requestLink = Effect.fn("TailnetDevices.requestLink")(function* ( + device: TailnetDeviceDescriptor, + ) { + const self = yield* selfDescriptor; + if (device.environmentId === self.environmentId) { + return yield* new TailnetDevicesError({ reason: "self", message: "That is this machine." }); + } + const tailnet = yield* identity.read; + if (Option.isNone(tailnet)) { + return yield* unreachable("Tailscale is not running on this machine."); + } + const current = yield* settings; + const proof = yield* prove(device.urls, device.environmentId, tailnet.value, current); + if (proof._tag === "refused") { + return yield* new TailnetDevicesError({ + reason: proof.reason === "not_my_device" ? "forbidden" : "unreachable", + message: + proof.reason === "not_my_device" + ? `Tailscale does not say ${device.label} is one of your devices.` + : `${device.label} did not answer over Tailscale.`, + }); + } + const issuer = proof.device; + if (!PeerLinks.linkLimitsHold(issuer.descriptor)) { + return yield* unreachable( + `${issuer.descriptor.label} runs a T3 Code version that cannot link automatically. Update it.`, + ); + } + if (self.urls.length === 0) { + return yield* unreachable( + "This machine has no tailnet address another device can reach. Turn on Tailscale Serve.", + ); + } + const challenge = yield* newChallenge; + const expiresAtMs = (yield* Clock.currentTimeMillis) + Duration.toMillis(CHALLENGE_TTL); + yield* Ref.update(pending, (current) => + new Map(current).set(challenge, { + issuer: device.environmentId, + origin: issuer.origin, + label: issuer.descriptor.label, + expiresAtMs, + }), + ); + const result = yield* clientFor(issuer.origin).pipe( + Effect.flatMap((client) => + client.tailnetDevices.request({ + payload: { requester: self, issuerEnvironmentId: device.environmentId, challenge }, + }), + ), + Effect.timeout(REQUEST_TIMEOUT), + Effect.mapError((cause) => + unreachable(`${issuer.descriptor.label} stopped answering while linking.`, cause), + ), + Effect.ensuring( + Ref.update(pending, (current) => { + const next = new Map(current); + next.delete(challenge); + return next; + }), + ), + ); + if (result.outcome === "refused") { + return yield* new TailnetDevicesError({ + reason: result.reason === "revoked" ? "revoked" : "forbidden", + message: `${issuer.descriptor.label} refused: ${REFUSAL_MESSAGES[result.reason]}`, + }); + } + yield* upsertDevice( + { + environmentId: device.environmentId, + label: issuer.descriptor.label, + urls: [issuer.origin], + }, + { tailnetName: issuer.nodeName, at: yield* DateTime.now }, + ); + const linked = yield* links.get(device.environmentId).pipe(Effect.mapError(storageError)); + if (Option.isNone(linked)) { + return yield* new TailnetDevicesError({ + reason: "storage", + message: `${issuer.descriptor.label} granted the link, but it was not saved here.`, + }); + } + return linked.value; + }); + + const handleConfirm: TailnetDevices["Service"]["handleConfirm"] = Effect.fn( + "TailnetDevices.handleConfirm", + )(function* (confirm) { + const now = yield* Clock.currentTimeMillis; + const challenge = (yield* Ref.get(pending)).get(confirm.challenge); + return { + accepted: + challenge !== undefined && + challenge.expiresAtMs > now && + challenge.issuer === confirm.issuer.environmentId, + }; + }); + + const handleGrant: TailnetDevices["Service"]["handleGrant"] = Effect.fn( + "TailnetDevices.handleGrant", + )(function* (grant) { + const now = yield* Clock.currentTimeMillis; + // Taken once: a grant replayed with the same challenge finds nothing. + const challenge = yield* Ref.modify(pending, (current) => { + const found = current.get(grant.challenge); + if (found === undefined) return [undefined, current]; + const next = new Map(current); + next.delete(grant.challenge); + return [found, next]; + }); + if ( + challenge === undefined || + challenge.expiresAtMs <= now || + challenge.issuer !== grant.issuer.environmentId + ) { + return { accepted: false }; + } + const linkedAt = yield* DateTime.now; + const stored = yield* links + .store( + { + environmentId: challenge.issuer, + label: challenge.label, + // The address this environment proved, never one the grant names. + urls: [challenge.origin], + access: grant.access, + origin: "tailnet", + linkedAt, + expiresAt: grant.expiresAt, + lastReachedAt: linkedAt, + lastError: null, + }, + grant.token, + ) + .pipe(Effect.result); + return { accepted: stored._tag === "Success" }; + }); + + const refuse = (reason: TailnetLinkRefusal, requester: TailnetDeviceDescriptor) => + Effect.logInfo("Refused a tailnet device link", { + reason, + requester: requester.environmentId, + }).pipe(Effect.as({ outcome: "refused", reason })); + + const hasUsableLink = Effect.fn("TailnetDevices.hasUsableLink")(function* ( + environmentId: EnvironmentId, + ) { + const link = yield* links.get(environmentId).pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(link)) return false; + const now = yield* DateTime.now; + return DateTime.isGreaterThan( + link.value.expiresAt, + DateTime.add(now, { milliseconds: Duration.toMillis(REFRESH_BEFORE_EXPIRY) }), + ); + }); + + const handleRequest: TailnetDevices["Service"]["handleRequest"] = Effect.fn( + "TailnetDevices.handleRequest", + )( + function* (request) { + const requester = request.requester; + if (!(yield* enabled)) return yield* refuse("disabled", requester); + const self = yield* selfDescriptor; + if ( + request.issuerEnvironmentId !== self.environmentId || + requester.environmentId === self.environmentId + ) { + return yield* refuse("wrong_issuer", requester); + } + const tailnet = yield* identity.read; + if (Option.isNone(tailnet)) return yield* refuse("unreachable", requester); + const current = yield* settings; + const proof = yield* prove(requester.urls, requester.environmentId, tailnet.value, current); + if (proof._tag === "refused") return yield* refuse(proof.reason, requester); + const device = proof.device; + const client = yield* clientFor(device.origin).pipe(Effect.orDie); + // Nothing is minted until the address's owner says the challenge is its own. + const confirmed = yield* client.tailnetDevices + .confirm({ payload: { challenge: request.challenge, issuer: self } }) + .pipe(Effect.timeout(DESCRIBE_TIMEOUT), Effect.option); + if (Option.isNone(confirmed)) return yield* refuse("unreachable", requester); + if (!confirmed.value.accepted) return yield* refuse("grant_rejected", requester); + const access = current.defaultAccess; + const issued = yield* auth + .issueMcpClientSession({ + label: `T3 Code · ${device.descriptor.label}`, + access, + peerLink: true, + peerEnvironmentId: requester.environmentId, + client: { deviceType: "bot" }, + }) + .pipe(Effect.option); + if (Option.isNone(issued)) return yield* refuse("storage", requester); + const delivered = yield* client.tailnetDevices + .grant({ + payload: { + challenge: request.challenge, + issuer: self, + token: issued.value.token, + access, + expiresAt: DateTime.toUtc(issued.value.expiresAt), + }, + }) + .pipe( + Effect.timeout(DESCRIBE_TIMEOUT), + Effect.map((result) => result.accepted), + Effect.orElseSucceed(() => false), + ); + if (!delivered) { + yield* auth.revokeSession(issued.value.sessionId).pipe(Effect.ignore); + return yield* refuse("grant_rejected", requester); + } + yield* upsertDevice( + { + environmentId: requester.environmentId, + label: device.descriptor.label, + urls: [device.origin], + }, + { tailnetName: device.nodeName, at: yield* DateTime.now }, + ); + // Each direction is proven by the side giving access, so the link back is its own + // handshake. It runs before answering: the requester already holds its link, so it + // does not ask back again. + if (!(yield* hasUsableLink(requester.environmentId))) { + yield* requestLink({ + environmentId: requester.environmentId, + label: device.descriptor.label, + urls: [device.origin], + }).pipe( + Effect.tapError((cause) => + Effect.logInfo("Could not link back to a tailnet device", { cause }), + ), + Effect.ignore, + ); + } + return { outcome: "granted" } satisfies TailnetLinkResult; + }, + handshakes.withPermits(1), + Effect.timeoutOption(HANDSHAKE_TIMEOUT), + Effect.map( + Option.getOrElse((): TailnetLinkResult => ({ outcome: "refused", reason: "unreachable" })), + ), + ); + + const knownDescriptors = Effect.gen(function* () { + const rows = yield* readDevices.pipe(Effect.orElseSucceed(() => [])); + return yield* Effect.forEach(rows, (row) => + decodeUrls(row.urls_json).pipe( + Effect.map((urls): TailnetDeviceDescriptor => ({ + environmentId: row.environment_id as EnvironmentId, + label: row.label, + urls, + })), + Effect.orElseSucceed(() => null), + ), + ).pipe(Effect.map((found) => found.filter((device) => device !== null))); + }); + + const syncPayload = Effect.gen(function* () { + return { + from: yield* selfDescriptor, + devices: yield* knownDescriptors, + policy: [], + settings: yield* settings, + } satisfies TailnetDevicesSyncPayload; + }); + + const mergeSync = (payload: TailnetDevicesSyncPayload, self: EnvironmentId) => + Effect.forEach( + payload.devices.filter((device) => device.environmentId !== self), + addHint, + { discard: true }, + ); + + const handleSync: TailnetDevices["Service"]["handleSync"] = Effect.fn( + "TailnetDevices.handleSync", + )(function* (request, payload) { + const caller = yield* auth.authenticateMcpClient(request).pipe(Effect.option); + if ( + Option.isNone(caller) || + caller.value.peerEnvironmentId === undefined || + caller.value.peerEnvironmentId !== payload.from.environmentId + ) { + return yield* new TailnetDevicesUnauthorizedError({ + message: "Sync needs the session this machine issued to the calling device.", + }); + } + yield* mergeSync(payload, yield* environment.getEnvironmentId); + return yield* syncPayload; + }); + + /** Exchanges device hints with a linked device; a refused token starts a new handshake. */ + const syncWith = Effect.fn("TailnetDevices.syncWith")( + function* (link: PeerLink) { + const resolved = yield* links.resolve(link.environmentId); + const authorized = HttpClient.mapRequest( + httpClient, + HttpClientRequest.bearerToken(resolved.token), + ); + const payload = yield* syncPayload; + const answer = yield* PeerLinks.peerApiClient(authorized, resolved.url).pipe( + Effect.flatMap((client) => client.tailnetDevices.sync({ payload })), + Effect.timeout(DESCRIBE_TIMEOUT), + ); + yield* mergeSync(answer, yield* environment.getEnvironmentId); + }, + (effect, link) => + effect.pipe( + Effect.catchTags({ TailnetDevicesUnauthorizedError: () => refresh(link.environmentId) }), + Effect.tapError((cause) => + Effect.logDebug("Tailnet device sync failed", { + environmentId: link.environmentId, + cause, + }), + ), + Effect.ignore, + ), + ); + + const descriptorFor = Effect.fn("TailnetDevices.descriptorFor")(function* ( + environmentId: EnvironmentId, + ) { + const known = (yield* knownDescriptors).find( + (device) => device.environmentId === environmentId, + ); + const link = yield* links.get(environmentId).pipe(Effect.orElseSucceed(() => Option.none())); + const urls = [...(Option.isSome(link) ? link.value.urls : []), ...(known?.urls ?? [])]; + if (urls.length === 0) return Option.none(); + return Option.some({ + environmentId, + label: known?.label ?? (Option.isSome(link) ? link.value.label : environmentId), + urls: [...new Set(urls)], + }); + }); + + const refresh: TailnetDevices["Service"]["refresh"] = Effect.fn("TailnetDevices.refresh")( + function* (environmentId) { + if (!(yield* enabled)) return; + const device = yield* descriptorFor(environmentId); + if (Option.isNone(device)) return; + yield* requestLink(device.value); + }, + (effect, environmentId) => + effect.pipe( + Effect.tapError((cause) => + Effect.logInfo("Could not refresh a tailnet device link", { environmentId, cause }), + ), + Effect.ignore, + ), + ); + + const linkIfMine: TailnetDevices["Service"]["linkIfMine"] = Effect.fn( + "TailnetDevices.linkIfMine", + )( + function* (target) { + if (!(yield* enabled)) return Option.none(); + if (target.environmentId !== undefined) { + const device = yield* descriptorFor(target.environmentId); + if (Option.isNone(device)) return Option.none(); + return Option.some(yield* requestLink(device.value)); + } + if (target.url === undefined) return Option.none(); + const tailnet = yield* identity.read; + if (Option.isNone(tailnet)) return Option.none(); + const located = identity.locate(target.url, tailnet.value); + if (Option.isNone(located)) return Option.none(); + const descriptor = yield* describe(located.value.origin, DESCRIBE_TIMEOUT); + if (Option.isNone(descriptor)) return Option.none(); + return Option.some( + yield* requestLink({ + environmentId: descriptor.value.environmentId, + label: descriptor.value.label, + urls: [located.value.origin], + }), + ); + }, + // Not one of mine, or not answering: the caller offers the pairing-code card instead. + Effect.tapError((cause) => Effect.logInfo("Direct tailnet link failed", { cause })), + Effect.orElseSucceed(() => Option.none()), + ); + + /** The first probe URL of `peer` that answers as a T3 server. */ + const probe = (urls: ReadonlyArray) => + Effect.findFirst(urls, (url) => + describe(new URL(url).origin, PROBE_TIMEOUT).pipe(Effect.map(Option.isSome)), + ).pipe( + Effect.flatMap((url) => + Option.isNone(url) + ? Effect.succeed( + Option.none<{ origin: string; descriptor: ExecutionEnvironmentDescriptor }>(), + ) + : describe(new URL(url.value).origin, PROBE_TIMEOUT).pipe( + Effect.map( + Option.map((descriptor) => ({ origin: new URL(url.value).origin, descriptor })), + ), + ), + ), + ); + + const scan: TailnetDevices["Service"]["scan"] = Effect.gen(function* () { + if (!(yield* enabled)) return; + const tailnet = yield* identity.read; + if (Option.isNone(tailnet)) return; + const self = yield* environment.getEnvironmentId; + const known = yield* readDevices.pipe(Effect.orElseSucceed(() => [])); + const usable = new Set(); + for (const row of known) { + if (yield* hasUsableLink(row.environment_id as EnvironmentId)) usable.add(row.environment_id); + } + const linkedNodes = new Set( + known.flatMap((row) => + usable.has(row.environment_id) && row.tailnet_name !== null ? [row.tailnet_name] : [], + ), + ); + const candidates = tailnet.value.peers.filter( + (peer) => + peer.online && + peer.userId !== null && + peer.userId === tailnet.value.self.userId && + peer.tags.length === 0 && + !linkedNodes.has(peer.nodeName), + ); + const now = yield* DateTime.now; + const found = yield* Effect.forEach( + candidates, + (peer) => + probe(identity.probeUrls(peer)).pipe( + Effect.tap((answer) => + Option.isNone(answer) || answer.value.descriptor.environmentId === self + ? Effect.void + : upsertDevice( + { + environmentId: answer.value.descriptor.environmentId, + label: answer.value.descriptor.label, + urls: [answer.value.origin], + }, + { tailnetName: peer.nodeName, at: now }, + ), + ), + ), + { concurrency: PROBE_CONCURRENCY }, + ); + const toLink = new Set( + found.flatMap((answer) => + Option.isSome(answer) && answer.value.descriptor.environmentId !== self + ? [answer.value.descriptor.environmentId] + : [], + ), + ); + // Gossip first: a linked device may know addresses the scan cannot guess. + const current = yield* links.list.pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + current.filter((link) => link.origin === "tailnet"), + syncWith, + { concurrency: PROBE_CONCURRENCY, discard: true }, + ); + for (const device of yield* knownDescriptors) { + if (device.environmentId !== self) toLink.add(device.environmentId); + } + for (const environmentId of toLink) { + const link = current.find((candidate) => candidate.environmentId === environmentId); + // A pairing-code link is the user's choice; only tailnet links are made or renewed here. + if (link !== undefined && link.origin === "paired" && link.status !== "expired") continue; + if (yield* hasUsableLink(environmentId)) continue; + yield* refresh(environmentId); + } + }).pipe(Effect.withSpan("TailnetDevices.scan")); + + const start: TailnetDevices["Service"]["start"] = Effect.gen(function* () { + const signatureOf = (tailnet: Option.Option) => + Option.isNone(tailnet) + ? "" + : tailnet.value.peers + .filter((peer) => peer.online) + .map((peer) => peer.nodeName) + .toSorted() + .join(","); + let lastSignature: string | null = null; + let lastScanMs = 0; + yield* Effect.gen(function* () { + const signature = signatureOf(yield* identity.read); + const now = yield* Clock.currentTimeMillis; + if ( + signature !== lastSignature || + now - lastScanMs >= Duration.toMillis(FULL_SCAN_INTERVAL) + ) { + lastSignature = signature; + lastScanMs = now; + yield* scan.pipe( + Effect.catchCause((cause) => Effect.logWarning("Tailnet scan failed", { cause })), + ); + } + yield* Effect.sleep(TAILNET_POLL_INTERVAL); + }).pipe(Effect.forever, Effect.forkScoped); + }); + + const list: TailnetDevices["Service"]["list"] = Effect.gen(function* () { + const self = yield* selfDescriptor; + const current = yield* settings; + const rows = yield* readDevices; + const linked = yield* links.list.pipe(Effect.mapError(storageError)); + const sessions = yield* auth.listSessions().pipe(Effect.mapError(storageError)); + const now = yield* DateTime.now; + const ids = new Set([ + ...rows.map((row) => row.environment_id), + ...linked.map((link) => link.environmentId), + ...sessions.flatMap((session) => + session.peerEnvironmentId === undefined ? [] : [session.peerEnvironmentId], + ), + ]); + ids.delete(self.environmentId); + const devices: Array = []; + for (const id of ids) { + const environmentId = id as EnvironmentId; + const row = rows.find((candidate) => candidate.environment_id === id); + const link = linked.find((candidate) => candidate.environmentId === id); + const lastSeenAt = row?.last_seen_at == null ? null : DateTime.makeUnsafe(row.last_seen_at); + const seenRecently = + lastSeenAt !== null && + DateTime.isGreaterThan( + lastSeenAt, + DateTime.subtract(now, { milliseconds: Duration.toMillis(ONLINE_WINDOW) }), + ); + const label = + link?.label ?? + row?.label ?? + sessions + .find((session) => session.peerEnvironmentId === id) + ?.client.label?.replace(/^T3 Code · /u, "") ?? + id; + devices.push({ + environmentId, + label, + urls: + link?.urls ?? + (row === undefined + ? [] + : yield* decodeUrls(row.urls_json).pipe(Effect.orElseSucceed(() => []))), + status: link?.status === "reachable" || seenRecently ? "online" : "offline", + access: link?.origin === "paired" ? link.access : current.defaultAccess, + policy: null, + outgoing: link?.origin ?? null, + incoming: sessions.some((session) => session.peerEnvironmentId === id), + tailnetName: row?.tailnet_name ?? null, + lastSeenAt, + }); + } + return { + self, + settings: current, + devices: devices.toSorted((a, b) => a.label.localeCompare(b.label)), + } satisfies TailnetDevicesSnapshot; + }).pipe(Effect.withSpan("TailnetDevices.list")); + + return TailnetDevices.of({ + settings, + updateSettings, + list, + linkIfMine, + refresh, + handleRequest, + handleConfirm, + handleGrant, + handleSync, + scan, + start, + }); +}); + +export const layer = Layer.effect(TailnetDevices, make); diff --git a/apps/server/src/peer/TailnetIdentity.ts b/apps/server/src/peer/TailnetIdentity.ts new file mode 100644 index 0000000000000..8389d606f17ef --- /dev/null +++ b/apps/server/src/peer/TailnetIdentity.ts @@ -0,0 +1,92 @@ +import { + isTailnetAddress, + readTailnet, + type Tailnet, + type TailnetPeer, + type TailnetWhois, + whoisTailnetAddress, +} from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as NodeNet from "node:net"; + +import { DEFAULT_PORT } from "../config.ts"; + +/** Where a URL reaches on the tailnet, and the address whose owner answers there. */ +export interface TailnetTarget { + readonly origin: string; + readonly address: string; +} + +/** + * The tailnet address a URL reaches, only when this node can tell which node + * answers there without trusting DNS: a literal tailnet IP, or an https + * MagicDNS name of a peer, whose certificate Tailscale issues to that node + * alone. Plain http on a name, LAN and public addresses are never located. + */ +export const locateTailnetTarget = (url: string, tailnet: Tailnet): TailnetTarget | null => { + const parsed = URL.parse(url); + if (parsed === null || (parsed.protocol !== "https:" && parsed.protocol !== "http:")) { + return null; + } + const host = parsed.hostname.replace(/^\[(.*)\]$/u, "$1").toLowerCase(); + if (NodeNet.isIP(host) !== 0) { + return isTailnetAddress(host) ? { origin: parsed.origin, address: host } : null; + } + if (parsed.protocol !== "https:") return null; + const peer = tailnet.peers.find((candidate) => candidate.magicDnsName?.toLowerCase() === host); + const address = peer?.tailnetAddresses[0]; + return address === undefined ? null : { origin: parsed.origin, address }; +}; + +/** The Serve ports T3 picks, then its own port on the tailnet address. */ +const probeUrlsFor = (peer: TailnetPeer): ReadonlyArray => [ + ...(peer.magicDnsName === null + ? [] + : [443, 8443, 3443].map((port) => + port === 443 ? `https://${peer.magicDnsName}` : `https://${peer.magicDnsName}:${port}`, + )), + ...peer.tailnetAddresses + .filter((address) => NodeNet.isIPv4(address)) + .map((address) => `http://${address}:${DEFAULT_PORT}`), +]; + +/** What Tailscale says about this node and the others, which the tailnet device handshake trusts. */ +export class TailnetIdentity extends Context.Service< + TailnetIdentity, + { + /** This node and its peers, or nothing when Tailscale is not running here. */ + readonly read: Effect.Effect>; + readonly locate: (url: string, tailnet: Tailnet) => Option.Option; + /** Who owns a tailnet address, or nothing when nobody does or Tailscale cannot say. */ + readonly whois: (address: string) => Effect.Effect>; + /** Where a peer's T3 server may answer. */ + readonly probeUrls: (peer: TailnetPeer) => ReadonlyArray; + } +>()("t3/peer/TailnetIdentity") {} + +export const layer = Layer.effect( + TailnetIdentity, + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return TailnetIdentity.of({ + read: readTailnet.pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.tapError((cause) => Effect.logDebug("Tailscale status unavailable", { cause })), + Effect.option, + ), + locate: (url, tailnet) => Option.fromNullishOr(locateTailnetTarget(url, tailnet)), + whois: Effect.fn("TailnetIdentity.whois")( + (address: string) => whoisTailnetAddress(address), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.tapError((cause) => Effect.logDebug("Tailscale whois failed", { cause })), + Effect.map(Option.fromNullishOr), + Effect.orElseSucceed(() => Option.none()), + ), + probeUrls: probeUrlsFor, + }); + }), +); diff --git a/apps/server/src/peer/tailnetDevicesHttp.ts b/apps/server/src/peer/tailnetDevicesHttp.ts new file mode 100644 index 0000000000000..c160d5208a654 --- /dev/null +++ b/apps/server/src/peer/tailnetDevicesHttp.ts @@ -0,0 +1,18 @@ +import { EnvironmentHttpApi } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; + +import * as TailnetDevices from "./TailnetDevices.ts"; + +export const layer = HttpApiBuilder.group( + EnvironmentHttpApi, + "tailnetDevices", + Effect.fnUntraced(function* (handlers) { + const devices = yield* TailnetDevices.TailnetDevices; + return handlers + .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)); + }), +); diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 7b82b012e674f..7ea5e9c6508c7 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -11,6 +11,7 @@ import { AuthClientMetadataDeviceType, AuthEnvironmentScopes, AuthSessionId, + EnvironmentId, ServerAuthSessionMethod, } from "@t3tools/contracts"; @@ -37,6 +38,8 @@ export const AuthSessionRecord = Schema.Struct({ scopes: AuthEnvironmentScopes, method: ServerAuthSessionMethod, client: AuthSessionClientMetadataRecord, + /** The T3 environment holding the session, when another server holds it. */ + peerEnvironmentId: Schema.NullOr(EnvironmentId), issuedAt: Schema.DateTimeUtcFromString, expiresAt: Schema.DateTimeUtcFromString, lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), @@ -50,6 +53,7 @@ export const CreateAuthSessionInput = Schema.Struct({ scopes: AuthEnvironmentScopes, method: ServerAuthSessionMethod, client: AuthSessionClientMetadataRecord, + peerEnvironmentId: Schema.optionalKey(EnvironmentId), issuedAt: Schema.DateTimeUtcFromString, expiresAt: Schema.DateTimeUtcFromString, }); @@ -133,6 +137,7 @@ const AuthSessionDbRow = Schema.Struct({ clientDeviceType: Schema.Literals(["desktop", "mobile", "tablet", "bot", "unknown"]), clientOs: Schema.NullOr(Schema.String), clientBrowser: Schema.NullOr(Schema.String), + peerEnvironmentId: Schema.NullOr(EnvironmentId), issuedAt: Schema.DateTimeUtcFromString, expiresAt: Schema.DateTimeUtcFromString, lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), @@ -150,6 +155,7 @@ const AuthSessionRawDbRow = Schema.Struct({ clientDeviceType: Schema.Unknown, clientOs: Schema.Unknown, clientBrowser: Schema.Unknown, + peerEnvironmentId: Schema.Unknown, issuedAt: Schema.Unknown, expiresAt: Schema.Unknown, lastConnectedAt: Schema.Unknown, @@ -172,6 +178,7 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco os: row.clientOs, browser: row.clientBrowser, }, + peerEnvironmentId: row.peerEnvironmentId, issuedAt: row.issuedAt, expiresAt: row.expiresAt, lastConnectedAt: row.lastConnectedAt, @@ -214,6 +221,7 @@ export const make = Effect.gen(function* () { client_device_type, client_os, client_browser, + peer_environment_id, issued_at, expires_at, revoked_at @@ -229,6 +237,7 @@ export const make = Effect.gen(function* () { ${input.client.deviceType}, ${input.client.os}, ${input.client.browser}, + ${input.peerEnvironmentId ?? null}, ${input.issuedAt}, ${input.expiresAt}, NULL @@ -255,6 +264,7 @@ export const make = Effect.gen(function* () { client_device_type AS "clientDeviceType", client_os AS "clientOs", client_browser AS "clientBrowser", + peer_environment_id AS "peerEnvironmentId", issued_at AS "issuedAt", expires_at AS "expiresAt", last_connected_at AS "lastConnectedAt", @@ -299,6 +309,7 @@ export const make = Effect.gen(function* () { client_device_type AS "clientDeviceType", client_os AS "clientOs", client_browser AS "clientBrowser", + peer_environment_id AS "peerEnvironmentId", issued_at AS "issuedAt", expires_at AS "expiresAt", last_connected_at AS "lastConnectedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 43134895b714a..3ef9cf4cc2145 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -89,6 +89,7 @@ import ForkMigration008 from "./Migrations/fork/008_ProjectionThreadSlimPayloads import ForkMigration009 from "./Migrations/fork/009_ShellRunlessRootTurnIndex.ts"; import ForkMigration010 from "./Migrations/fork/010_ProjectionThreadParentIndex.ts"; import ForkMigration011 from "./Migrations/fork/011_ProfileSnapshots.ts"; +import ForkMigration012 from "./Migrations/fork/012_TailnetDevices.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -180,6 +181,7 @@ const forkMigrationEntries = [ [9, "ShellRunlessRootTurnIndex", ForkMigration009, 68], [10, "ProjectionThreadParentIndex", ForkMigration010, 68], [11, "ProfileSnapshots", ForkMigration011, 68], + [12, "TailnetDevices", ForkMigration012, 72], ] as const; export const forkMigrationManifest = forkMigrationEntries.map( diff --git a/apps/server/src/persistence/Migrations/fork/012_TailnetDevices.ts b/apps/server/src/persistence/Migrations/fork/012_TailnetDevices.ts new file mode 100644 index 0000000000000..4345556406a7c --- /dev/null +++ b/apps/server/src/persistence/Migrations/fork/012_TailnetDevices.ts @@ -0,0 +1,35 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // The environment that holds the session, when it is another T3 server. + yield* sql`ALTER TABLE auth_sessions ADD COLUMN peer_environment_id TEXT`; + yield* sql` + CREATE INDEX auth_sessions_peer_environment_idx + ON auth_sessions(peer_environment_id) WHERE peer_environment_id IS NOT NULL + `; + + yield* sql` + ALTER TABLE peer_environment_links ADD COLUMN origin TEXT NOT NULL DEFAULT 'paired' + `; + + // Servers found on the tailnet or named by a linked device, linked or not yet. + yield* sql` + CREATE TABLE tailnet_devices ( + environment_id TEXT PRIMARY KEY, + label TEXT NOT NULL, + urls_json TEXT NOT NULL, + tailnet_name TEXT, + last_seen_at TEXT + ) + `; + + yield* sql` + CREATE TABLE tailnet_device_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + settings_json TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7b1b79c89d297..7dbc2beec126f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -79,6 +79,9 @@ import * as PeerForwarding from "./peer/PeerForwarding.ts"; import * as RemoteDelegation from "./peer/RemoteDelegation.ts"; import * as PeerLinkRequests from "./peer/PeerLinkRequests.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; +import * as TailnetDevices from "./peer/TailnetDevices.ts"; +import * as TailnetDevicesHttp from "./peer/tailnetDevicesHttp.ts"; +import * as TailnetIdentity from "./peer/TailnetIdentity.ts"; import * as PeerMcpClient from "./peer/PeerMcpClient.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; @@ -769,6 +772,7 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide(ProjectHttp.layer), Layer.provide(ServerHttp.layerServerEnvironmentHttpApi), Layer.provide(WebhookRoute.layer.pipe(Layer.provide(RelayDeliveryProof.layer))), + Layer.provide(TailnetDevicesHttp.layer), Layer.provide(AuthHttp.layerAuthenticatedAuth), ), ServerHttp.layerOtlpTracesProxyRoute, @@ -795,6 +799,10 @@ const layerMakeRoutes = Layer.mergeAll( Layer.effectDiscard( RemoteDelegation.RemoteDelegation.pipe(Effect.flatMap((remote) => remote.start())), ), + // Finds and links this user's other T3 servers on the tailnet. + Layer.effectDiscard( + TailnetDevices.TailnetDevices.pipe(Effect.flatMap((devices) => devices.start)), + ), ).pipe( Layer.provide( PullRequestReviewGuideService.layer.pipe(Layer.provide(PullRequestReviewGuides.layer)), @@ -816,6 +824,7 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide( PeerForwarding.layer.pipe( Layer.provideMerge(PeerMcpClient.layer), + Layer.provideMerge(TailnetDevices.layer.pipe(Layer.provide(TailnetIdentity.layer))), Layer.provideMerge(PeerLinks.layer), ), ), diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index a1b519c2c5f2a..a8ee29cc91e67 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -57,6 +57,7 @@ import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as PeerLinkRequests from "./peer/PeerLinkRequests.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; +import * as TailnetDevices from "./peer/TailnetDevices.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; @@ -500,6 +501,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(OtlpEndpointCheck.OtlpEndpointCheck)({}), Layer.mock(PairingGrantStore.PairingGrantStore)({}), Layer.mock(PeerLinks.PeerLinks)({}), + Layer.mock(TailnetDevices.TailnetDevices)({}), Layer.mock(PeerLinkRequests.PeerLinkRequests)({}), Layer.mock(PortScanner.PortDiscovery)({ trackAgent: () => () => undefined }), Layer.mock(PreviewManager.PreviewManager)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index bbdaab69a8dd6..d36b01bc6820d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -6,6 +6,7 @@ import * as Crypto from "effect/Crypto"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as PeerLinkRequests from "./peer/PeerLinkRequests.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; +import * as TailnetDevices from "./peer/TailnetDevices.ts"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -1372,6 +1373,7 @@ export const layerWsRpc = ( const databaseStorage = yield* DatabaseStorage.DatabaseStorage; const serverSettings = yield* ServerSettings.ServerSettingsService; const peerLinks = yield* PeerLinks.PeerLinks; + const tailnetDevices = yield* TailnetDevices.TailnetDevices; const tailnetDevicesUnavailable = Effect.fail( new TailnetDevicesError({ reason: "unreachable", @@ -2612,9 +2614,9 @@ export const layerWsRpc = ( Effect.andThen(peerLinkRequests.answer(input)), ), // Tailnet devices land with the handshake; until then this server has none to manage. - [WS_METHODS.tailnetDevicesList]: () => tailnetDevicesUnavailable, + [WS_METHODS.tailnetDevicesList]: () => tailnetDevices.list, [WS_METHODS.tailnetDevicesSetAccess]: () => tailnetDevicesUnavailable, - [WS_METHODS.tailnetDevicesUpdateSettings]: () => tailnetDevicesUnavailable, + [WS_METHODS.tailnetDevicesUpdateSettings]: (input) => tailnetDevices.updateSettings(input), [WS_METHODS.tailnetDevicesOpenDevice]: () => tailnetDevicesUnavailable, [WS_METHODS.serverUpdateSettings]: /* Request instrumentation already owns the span; keep this helper in that context. */ @@ -3390,14 +3392,22 @@ const provideWsRpcDependencies = ( services: { readonly sql: SqlClient.SqlClient; readonly peerLinks: PeerLinks.PeerLinks["Service"]; + readonly tailnetDevices: TailnetDevices.TailnetDevices["Service"]; readonly peerLinkRequests: PeerLinkRequests.PeerLinkRequests["Service"]; readonly serverSelfUpdate: ServerSelfUpdate.ServerSelfUpdate["Service"]; readonly pullRequests: PullRequestService.PullRequestService["Service"]; readonly reviewGuides: PullRequestReviewGuideService.PullRequestReviewGuideService["Service"]; }, ) => { - const { sql, serverSelfUpdate, pullRequests, reviewGuides, peerLinks, peerLinkRequests } = - services; + const { + sql, + serverSelfUpdate, + pullRequests, + reviewGuides, + peerLinks, + tailnetDevices, + peerLinkRequests, + } = services; return handlers.pipe( Layer.provideMerge(RpcSerialization.layerJson), // Request fibers run in the handlers' context, so this reporter sees @@ -3405,6 +3415,7 @@ const provideWsRpcDependencies = ( Layer.provide(DefectReporter.layer), Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), Layer.provide(Layer.succeed(PeerLinks.PeerLinks, peerLinks)), + Layer.provide(Layer.succeed(TailnetDevices.TailnetDevices, tailnetDevices)), Layer.provide(Layer.succeed(PeerLinkRequests.PeerLinkRequests, peerLinkRequests)), Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), @@ -3451,6 +3462,7 @@ export const makeInProcessWsRpcLayer = (session: EnvironmentAuth.AuthenticatedSe { sql: yield* SqlClient.SqlClient, peerLinks: yield* PeerLinks.PeerLinks, + tailnetDevices: yield* TailnetDevices.TailnetDevices, peerLinkRequests: yield* PeerLinkRequests.PeerLinkRequests, serverSelfUpdate: yield* ServerSelfUpdate.ServerSelfUpdate, pullRequests: yield* PullRequestService.PullRequestService, @@ -3470,6 +3482,7 @@ export const layer = Layer.unwrap( const pullRequests = yield* PullRequestService.PullRequestService; const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; const peerLinks = yield* PeerLinks.PeerLinks; + const tailnetDevices = yield* TailnetDevices.TailnetDevices; const peerLinkRequests = yield* PeerLinkRequests.PeerLinkRequests; const sql = yield* SqlClient.SqlClient; return HttpRouter.add( @@ -3562,7 +3575,15 @@ export const layer = Layer.unwrap( mcpGatewayBroker, clientFocusBroker, ), - { sql, serverSelfUpdate, pullRequests, reviewGuides, peerLinks, peerLinkRequests }, + { + sql, + serverSelfUpdate, + pullRequests, + reviewGuides, + peerLinks, + tailnetDevices, + peerLinkRequests, + }, ), ), ); diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 31c0479fc77b5..19776e8448c28 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -104,6 +104,25 @@ Unlinking on the linking side only forgets the token. receiving `/mcp` sends nothing while a wait is open and T3 Connect's edge drops idle requests after about 100 s. +### Tailnet devices + +[`TailnetDevices`](../../apps/server/src/peer/TailnetDevices.ts) makes the same +kind of link without a pairing code between servers Tailscale says are the +same user's untagged nodes. Requests arriving through Serve come from loopback +with headers any local process can forge, so nothing inbound is trusted: + +- **The side giving access dials the other.** The issuer resolves the + requester's address itself (a tailnet IP, or an https MagicDNS name, whose + certificate only that node holds; plain http on a name is never used), + whoises it, and checks its descriptor. It then asks that address to confirm + the requester's pending challenge before minting anything, and delivers the + token there. A forged request naming a real device's address dies at the + confirmation, so no token is ever created for it. +- **Each direction is its own handshake.** The issuer links back before + answering, so one request yields a mutual link, each half proven by the side + granting it. Gossip between linked devices only adds addresses to try. +- The requester stores the issuer address it proved, never one the grant names. + **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/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 7e4cc84b74156..1df36dbe1aef7 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -6,6 +6,7 @@ import { ForwardCompatibleArray, ClientSurface, ClientWebDeployment, + EnvironmentId, TrimmedNonEmptyString, } from "./baseSchemas.ts"; @@ -288,6 +289,8 @@ export const AuthClientSession = Schema.Struct({ ...authScopeResponseFields, method: ServerAuthSessionMethod, client: AuthClientMetadata, + /** The T3 environment holding the session, when another server holds it. */ + peerEnvironmentId: Schema.optionalKey(EnvironmentId), issuedAt: Schema.DateTimeUtc, expiresAt: Schema.DateTimeUtc, lastConnectedAt: Schema.NullOr(Schema.DateTimeUtc), @@ -454,6 +457,8 @@ export const MCP_PEER_LINK_SOFTWARE_ID = "t3code-peer-link"; export const AuthMcpClientRegistration = Schema.Struct({ client_name: Schema.optionalKey(Schema.String), software_id: Schema.optionalKey(Schema.String), + /** A T3 Code environment linking itself names itself, so the peer can list it by machine. */ + t3_environment_id: Schema.optionalKey(EnvironmentId), redirect_uris: Schema.optionalKey(Schema.Array(Schema.String)), token_endpoint_auth_method: Schema.optionalKey(Schema.String), }); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 30c33deebd55c..2ee976b73808f 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -55,6 +55,15 @@ import { OrchestrationV2ThreadHistoryPage, OrchestrationV2ThreadTranscript, } from "./orchestrationV2.ts"; +import { + TailnetChallengeConfirm, + TailnetDevicesSyncPayload, + TailnetDevicesUnauthorizedError, + TailnetGrant, + TailnetGrantResult, + TailnetLinkRequest, + TailnetLinkResult, +} from "./tailnetDevices.ts"; import { Project, ProjectMutation, ProjectSnapshot } from "./project.ts"; import { PullRequestDiffInput, @@ -835,6 +844,38 @@ class EnvironmentWebhooksHttpApi extends HttpApiGroup.make("webhooks") .add(HttpApiEndpoint.patch("webhookPatch", WEBHOOK_PATH, webhookEndpoint)) .add(HttpApiEndpoint.get("webhookGet", WEBHOOK_PATH, webhookEndpoint)) {} +/** + * Linking one owner's tailnet devices. `request`, `confirm` and `grant` carry + * no credential: each side proves the other through Tailscale before trusting + * it. `sync` needs a session this environment issued to the calling device. + */ +class EnvironmentTailnetDevicesHttpApi extends HttpApiGroup.make("tailnetDevices") + .add( + HttpApiEndpoint.post("request", "/api/tailnet-devices/request", { + payload: TailnetLinkRequest, + success: TailnetLinkResult, + }), + ) + .add( + HttpApiEndpoint.post("confirm", "/api/tailnet-devices/confirm", { + payload: TailnetChallengeConfirm, + success: TailnetGrantResult, + }), + ) + .add( + HttpApiEndpoint.post("grant", "/api/tailnet-devices/grant", { + payload: TailnetGrant, + success: TailnetGrantResult, + }), + ) + .add( + HttpApiEndpoint.post("sync", "/api/tailnet-devices/sync", { + payload: TailnetDevicesSyncPayload, + success: TailnetDevicesSyncPayload, + error: TailnetDevicesUnauthorizedError, + }), + ) {} + export class EnvironmentHttpApi extends HttpApi.make("environment") .add(EnvironmentMetadataHttpApi) .add(EnvironmentAuthHttpApi) @@ -843,4 +884,5 @@ export class EnvironmentHttpApi extends HttpApi.make("environment") .add(EnvironmentPullRequestsHttpApi) .add(EnvironmentProjectsHttpApi) .add(EnvironmentConnectHttpApi) - .add(EnvironmentWebhooksHttpApi) {} + .add(EnvironmentWebhooksHttpApi) + .add(EnvironmentTailnetDevicesHttpApi) {} diff --git a/packages/contracts/src/tailnetDevices.ts b/packages/contracts/src/tailnetDevices.ts index 0efb7f53395d7..6d146fbdaeeaa 100644 --- a/packages/contracts/src/tailnetDevices.ts +++ b/packages/contracts/src/tailnetDevices.ts @@ -10,6 +10,9 @@ import { EnvironmentId, NonNegativeInt, TrimmedNonEmptyString } from "./baseSche * so no inbound header or source address is ever trusted. */ +/** The Tailscale app capability that, when required, marks a node as one of my devices. */ +export const TAILNET_DEVICE_CAPABILITY = "t3.codes/device"; + /** A hybrid logical clock stamp. Replicated writes merge last-writer-wins by it. */ export const TailnetHlc = Schema.Struct({ wallMs: NonNegativeInt, @@ -46,6 +49,14 @@ export const TailnetDevicesSettings = Schema.Struct({ }); export type TailnetDevicesSettings = typeof TailnetDevicesSettings.Type; +export const DEFAULT_TAILNET_DEVICES_SETTINGS: TailnetDevicesSettings = { + autoLink: true, + defaultAccess: "full-access", + requireCapability: false, + updatedAt: null, + updatedBy: null, +}; + /** How this environment's outgoing link to a peer was made. */ export const PeerLinkOrigin = Schema.Literals(["tailnet", "paired"]); export type PeerLinkOrigin = typeof PeerLinkOrigin.Type; @@ -93,6 +104,16 @@ export const TailnetLinkResult = Schema.Union([ ]); export type TailnetLinkResult = typeof TailnetLinkResult.Type; +/** + * Issuer to requester, before minting anything: is this challenge yours, and + * pending for me? A forged request names an address whose owner says no. + */ +export const TailnetChallengeConfirm = Schema.Struct({ + challenge: TailnetChallenge, + issuer: TailnetDeviceDescriptor, +}); +export type TailnetChallengeConfirm = typeof TailnetChallengeConfirm.Type; + /** Issuer to requester, at the address the issuer resolved and verified itself. */ export const TailnetGrant = Schema.Struct({ challenge: TailnetChallenge, @@ -186,3 +207,10 @@ export class TailnetDevicesError extends Schema.TaggedError cause: Schema.optionalKey(Schema.Defect()), }, ) {} + +/** A tailnet device endpoint was called without a session this environment issued to a device. */ +export class TailnetDevicesUnauthorizedError extends Schema.TaggedError()( + "TailnetDevicesUnauthorizedError", + { message: Schema.String }, + { httpApiStatus: 401 }, +) {}