diff --git a/apps/server/src/access/ServerExposure.ts b/apps/server/src/access/ServerExposure.ts new file mode 100644 index 000000000000..c5c66963c014 --- /dev/null +++ b/apps/server/src/access/ServerExposure.ts @@ -0,0 +1,276 @@ +import * as NodeOS from "node:os"; + +import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint"; +import { + type AdvertisedEndpoint, + type AdvertisedEndpointProvider, + ServerExposureError, + type ServerExposureState, + type ServerTailscaleServeInput, +} from "@t3tools/contracts"; +import { + disableTailscaleServe, + ensureTailscaleServe, + readTailscaleStatus, +} from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import { HttpClient, HttpServer } from "effect/unstable/http"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import { ServerConfig } from "../config.ts"; +import { + formatHostForUrl, + isRemoteReachableHost, + resolveListeningPort, + resolveServerAdvertisedHost, +} from "../startupAccess.ts"; +import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; + +const SERVER_LOOPBACK_HOST = "127.0.0.1"; + +const SERVER_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "server-core", + label: "Server", + kind: "core", + isAddon: false, +}; + +interface TailscaleServeState { + readonly enabled: boolean; + readonly port: number; +} + +function resolveEndpointUrl(host: string | null, port: number): string | null { + return host ? `http://${formatHostForUrl(host)}:${port}` : null; +} + +function localBaseUrl(port: number): string { + return `http://${SERVER_LOOPBACK_HOST}:${port}`; +} + +function makeState(input: { + readonly host: string | undefined; + readonly port: number; + readonly tailscale: TailscaleServeState; +}): ServerExposureState { + const advertisedHost = resolveServerAdvertisedHost(input.host); + const endpointUrl = resolveEndpointUrl(advertisedHost, input.port); + return { + mode: isRemoteReachableHost(input.host) ? "network-accessible" : "local-only", + endpointUrl, + advertisedHost, + tailscaleServeEnabled: input.tailscale.enabled, + tailscaleServePort: input.tailscale.port, + }; +} + +function createServerEndpoint( + input: Omit< + Parameters[0], + "provider" | "source" | "desktopCompatibility" + >, +): AdvertisedEndpoint { + return createAdvertisedEndpoint({ + ...input, + provider: SERVER_CORE_ENDPOINT_PROVIDER, + source: "server", + desktopCompatibility: "compatible", + }); +} + +function resolveCoreAdvertisedEndpoints(input: { + readonly state: ServerExposureState; + readonly port: number; +}): readonly AdvertisedEndpoint[] { + const endpoints: AdvertisedEndpoint[] = [ + createServerEndpoint({ + id: `server-loopback:${input.port}`, + label: "This machine", + httpBaseUrl: localBaseUrl(input.port), + reachability: "loopback", + status: "available", + description: "Loopback endpoint for this server.", + }), + ]; + + if (input.state.endpointUrl) { + endpoints.push( + createServerEndpoint({ + id: `server-lan:${input.state.endpointUrl}`, + label: "Local network", + httpBaseUrl: input.state.endpointUrl, + reachability: "lan", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }), + ); + } + + return endpoints; +} + +export class ServerExposure extends Context.Service< + ServerExposure, + { + readonly getState: Effect.Effect; + readonly getAdvertisedEndpoints: Effect.Effect< + readonly AdvertisedEndpoint[], + ServerExposureError + >; + readonly setTailscaleServeEnabled: ( + input: ServerTailscaleServeInput, + ) => Effect.Effect; + } +>()("t3/access/ServerExposure") {} + +export const make = Effect.fn("makeServerExposure")(function* () { + const config = yield* ServerConfig; + const httpServer = yield* HttpServer.HttpServer; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const port = resolveListeningPort(httpServer.address, config.port); + const tailscaleRef = yield* Ref.make({ + enabled: config.tailscaleServeEnabled, + port: config.tailscaleServePort, + }); + + const readMagicDnsName: Effect.Effect = readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.orElseSucceed(() => null), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); + + if (config.tailscaleServeEnabled) { + yield* ensureTailscaleServe({ + localPort: port, + servePort: config.tailscaleServePort, + localHost: SERVER_LOOPBACK_HOST, + }).pipe( + Effect.tap(() => + Effect.logInfo("Tailscale Serve configured", { + localPort: port, + servePort: config.tailscaleServePort, + }), + ), + Effect.catch((cause) => + Effect.gen(function* () { + yield* Effect.logWarning("Failed to configure Tailscale Serve", { + cause, + localPort: port, + servePort: config.tailscaleServePort, + }); + yield* Ref.set(tailscaleRef, { + enabled: false, + port: config.tailscaleServePort, + }); + }), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); + } + + const getState = Ref.get(tailscaleRef).pipe( + Effect.map((tailscale) => makeState({ host: config.host, port, tailscale })), + ); + + const getAdvertisedEndpoints = Effect.gen(function* () { + const state = yield* getState; + const coreEndpoints = resolveCoreAdvertisedEndpoints({ state, port }); + const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ + port, + includeIpEndpoints: state.mode === "network-accessible", + serveEnabled: state.tailscaleServeEnabled, + servePort: state.tailscaleServePort, + networkInterfaces: NodeOS.networkInterfaces(), + readMagicDnsName, + }).pipe( + Effect.mapError( + (cause) => + new ServerExposureError({ + operation: "read", + message: "Failed to resolve Tailscale endpoints.", + cause, + }), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); + return [...coreEndpoints, ...tailscaleEndpoints]; + }).pipe(Effect.withSpan("serverExposure.getAdvertisedEndpoints")); + + const setTailscaleServeEnabled = Effect.fn("serverExposure.setTailscaleServeEnabled")(function* ( + input: ServerTailscaleServeInput, + ) { + const current = yield* Ref.get(tailscaleRef); + const servePort = input.port ?? current.port; + + if (input.enabled) { + yield* ensureTailscaleServe({ + localPort: port, + servePort, + localHost: SERVER_LOOPBACK_HOST, + }).pipe( + Effect.mapError( + (cause) => + new ServerExposureError({ + operation: "tailscale-serve", + message: cause.message, + cause, + }), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); + } else { + yield* disableTailscaleServe({ servePort }).pipe( + Effect.mapError( + (cause) => + new ServerExposureError({ + operation: "tailscale-serve", + message: cause.message, + cause, + }), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); + } + + const tailscale = { enabled: input.enabled, port: servePort }; + yield* Ref.set(tailscaleRef, tailscale); + return makeState({ host: config.host, port, tailscale }); + }); + + yield* Effect.addFinalizer(() => + Ref.get(tailscaleRef).pipe( + Effect.flatMap((tailscale) => + tailscale.enabled + ? disableTailscaleServe({ servePort: tailscale.port }).pipe( + Effect.tap(() => + Effect.logInfo("Tailscale Serve disabled", { + servePort: tailscale.port, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to disable Tailscale Serve", { + cause, + servePort: tailscale.port, + }), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ) + : Effect.void, + ), + ), + ); + + return ServerExposure.of({ + getState, + getAdvertisedEndpoints, + setTailscaleServeEnabled, + }); +}); + +export const layer = Layer.effect(ServerExposure, make()); \ No newline at end of file diff --git a/apps/server/src/access/tailscaleEndpointProvider.ts b/apps/server/src/access/tailscaleEndpointProvider.ts new file mode 100644 index 000000000000..cd62ed95c6c0 --- /dev/null +++ b/apps/server/src/access/tailscaleEndpointProvider.ts @@ -0,0 +1,148 @@ +import * as NodeOS from "node:os"; + +import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint"; +import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; +import { + buildTailscaleHttpsBaseUrl, + isTailscaleIpv4Address, + parseTailscaleMagicDnsName, + probeTailscaleHttpsEndpoint, + readTailscaleStatus, +} from "@t3tools/tailscale"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +type NetworkInterfaces = ReturnType; + +const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, +}; + +function isIpv4Family(family: string | number): boolean { + return family === "IPv4" || family === 4; +} + +function resolveTailscaleIpAdvertisedEndpoints(input: { + readonly port: number; + readonly networkInterfaces: NetworkInterfaces; +}): readonly AdvertisedEndpoint[] { + const seen = new Set(); + const endpoints: AdvertisedEndpoint[] = []; + + for (const interfaceAddresses of Object.values(input.networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if (address.internal) continue; + if (!isIpv4Family(address.family)) continue; + if (!isTailscaleIpv4Address(address.address)) continue; + if (seen.has(address.address)) continue; + seen.add(address.address); + + endpoints.push( + createAdvertisedEndpoint({ + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "server", + id: `tailscale-ip:http://${address.address}:${input.port}`, + label: "Tailscale IP", + httpBaseUrl: `http://${address.address}:${input.port}`, + reachability: "private-network", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }), + ); + } + } + + return endpoints; +} + +const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn( + "resolveTailscaleMagicDnsAdvertisedEndpoint", +)(function* (input: { + readonly dnsName: string | null; + readonly serveEnabled: boolean; + readonly servePort?: number; + readonly probe?: (baseUrl: string) => Effect.Effect; +}): Effect.fn.Return, never, HttpClient.HttpClient> { + if (!input.dnsName) { + return Option.none(); + } + + const httpBaseUrl = buildTailscaleHttpsBaseUrl({ + magicDnsName: input.dnsName, + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + }); + const probe = + input.probe?.(httpBaseUrl) ?? + probeTailscaleHttpsEndpoint({ + baseUrl: httpBaseUrl, + }); + const isReachable = input.serveEnabled ? yield* probe : false; + + return Option.some( + createAdvertisedEndpoint({ + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "server", + id: `tailscale-magicdns:${httpBaseUrl}`, + label: "Tailscale HTTPS", + httpBaseUrl, + reachability: "private-network", + hostedHttpsCompatibility: isReachable ? "compatible" : "requires-configuration", + status: isReachable ? "available" : "unavailable", + description: isReachable + ? "HTTPS endpoint served by Tailscale Serve." + : "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }), + ); +}); + +export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAdvertisedEndpoints")( + function* (input: { + readonly port: number; + readonly serveEnabled?: boolean; + readonly servePort?: number; + readonly networkInterfaces: NetworkInterfaces; + readonly includeIpEndpoints?: boolean; + readonly statusJson?: string | null; + readonly readMagicDnsName?: Effect.Effect; + readonly probe?: (baseUrl: string) => Effect.Effect; + }): Effect.fn.Return< + readonly AdvertisedEndpoint[], + never, + ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient + > { + const ipEndpoints = + input.includeIpEndpoints === false ? [] : resolveTailscaleIpAdvertisedEndpoints(input); + const readDnsName = + input.readMagicDnsName ?? + readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.orElseSucceed(() => null), + ); + const dnsName = + input.statusJson === undefined + ? yield* readDnsName + : input.statusJson + ? yield* parseTailscaleMagicDnsName(input.statusJson).pipe( + Effect.orElseSucceed(() => null), + ) + : null; + const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({ + dnsName, + serveEnabled: input.serveEnabled === true, + ...(input.servePort === undefined ? {} : { servePort: input.servePort }), + ...(input.probe === undefined ? {} : { probe: input.probe }), + }); + + return Option.match(magicDnsEndpoint, { + onNone: () => ipEndpoints, + onSome: (endpoint) => [...ipEndpoints, endpoint], + }); + }, +); diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 205c85b02345..de2cc21885d1 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import { ServerConfig } from "../config.ts"; import { resolveSessionCookieName } from "./utils.ts"; -import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; +import { isRemoteReachableHost } from "../startupAccess.ts"; export interface EnvironmentAuthPolicyShape { readonly getDescriptor: () => Effect.Effect; @@ -18,7 +18,7 @@ export class EnvironmentAuthPolicy extends Context.Service< export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { const config = yield* ServerConfig; - const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); + const isRemoteReachable = isRemoteReachableHost(config.host); const policy = config.mode === "desktop" diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 6fc5b3176c90..5a156ff14ba2 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -259,6 +261,51 @@ describe("ProcessDiagnostics", () => { }), ); + it.effect("returns the timeout message when the process query does not finish", () => + Effect.gen(function* () { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.sleep("2 seconds").pipe(Effect.as(ChildProcessSpawner.ExitCode(0))), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), + ); + + const fiber = yield* ProcessDiagnostics.readProcessRows.pipe( + Effect.provide(spawnerLayer), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + Effect.forkChild, + ); + yield* TestClock.adjust("1 second"); + const error = yield* Fiber.join(fiber); + + expect(error).toMatchObject({ + _tag: "ProcessDiagnosticsQueryTimeoutError", + command: "ps", + argCount: 2, + cwd: process.cwd(), + timeoutMillis: 1_000, + }); + expect(error.message).toBe( + `Process diagnostics query 'ps' timed out after 1000ms in '${process.cwd()}'.`, + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("does not allow signaling the diagnostics query process", () => Effect.gen(function* () { const spawnerLayer = Layer.succeed( diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index 43389d539822..05e0d1c9d16d 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -53,10 +53,22 @@ class ProcessDiagnosticsQueryTimeoutError extends Schema.TaggedErrorClass()( @@ -409,7 +421,7 @@ const runProcess = Effect.fn("runProcess")(function* (input: { Option.match(result, { onNone: () => Effect.fail( - new ProcessDiagnosticsQueryTimeoutError({ + makeProcessDiagnosticsQueryTimeoutError({ command: input.command, argCount: input.args.length, cwd, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index ded566e4640d..9fba52645576 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1311,6 +1311,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + ...(row.kind !== undefined ? { kind: row.kind } : {}), + ...(row.contextMarkdown !== undefined + ? { contextMarkdown: row.contextMarkdown } + : {}), + ...(row.contextVersion !== undefined + ? { contextVersion: row.contextVersion } + : {}), defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, createdAt: row.createdAt, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 06a8d11abaad..66cac7f13658 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -137,6 +137,7 @@ import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as ServerExposure from "./access/ServerExposure.ts"; import * as Data from "effect/Data"; const defaultProjectId = ProjectId.make("project-default"); @@ -643,6 +644,29 @@ const buildAppUnderTest = (options?: { }), }), ), + Layer.provide( + Layer.succeed( + ServerExposure.ServerExposure, + ServerExposure.ServerExposure.of({ + getState: Effect.succeed({ + mode: "local-only", + endpointUrl: null, + advertisedHost: null, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }), + getAdvertisedEndpoints: Effect.succeed([]), + setTailscaleServeEnabled: (input) => + Effect.succeed({ + mode: "local-only", + endpointUrl: null, + advertisedHost: null, + tailscaleServeEnabled: input.enabled, + tailscaleServePort: input.port ?? 443, + }), + }), + ), + ), Layer.provide(gitManagerLayer), Layer.provide(gitVcsDriverLayer), Layer.provide(gitWorkflowLayer), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6f9b3d9c17fa..a11a53ac6194 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -77,6 +77,7 @@ import * as CloudCliState from "./cloud/CliState.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import * as ServerExposure from "./access/ServerExposure.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -86,7 +87,7 @@ import { import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; + // Effect's default preemptive shutdown waits 20s before finalizing request scopes. // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer @@ -376,57 +377,6 @@ export const makeServerLayer = Layer.unwrap( () => clearPersistedServerRuntimeState(config.serverRuntimeStatePath), ), ); - const tailscaleServeLayer = config.tailscaleServeEnabled - ? Layer.effectDiscard( - Effect.acquireRelease( - Effect.gen(function* () { - const server = yield* HttpServer.HttpServer; - const address = server.address; - if (typeof address === "string" || !("port" in address)) { - return null; - } - - const localPort = address.port; - return yield* ensureTailscaleServe({ - localPort, - servePort: config.tailscaleServePort, - localHost: "127.0.0.1", - }).pipe( - Effect.as({ localPort, servePort: config.tailscaleServePort }), - Effect.tap(() => - Effect.logInfo("Tailscale Serve configured", { - localPort, - servePort: config.tailscaleServePort, - }), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to configure Tailscale Serve", { - cause, - localPort, - servePort: config.tailscaleServePort, - }).pipe(Effect.as(null)), - ), - ); - }), - (configured) => - configured - ? disableTailscaleServe({ servePort: configured.servePort }).pipe( - Effect.tap(() => - Effect.logInfo("Tailscale Serve disabled", { - servePort: configured.servePort, - }), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to disable Tailscale Serve", { - cause, - servePort: configured.servePort, - }), - ), - ) - : Effect.void, - ), - ) - : Layer.empty; const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( Effect.gen(function* () { if (!hasCloudPublicConfig) return; @@ -455,12 +405,12 @@ export const makeServerLayer = Layer.unwrap( }), httpListeningLayer, runtimeStateLayer, - tailscaleServeLayer, cloudDesiredLinkReconcileLayer, ); return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive), + Layer.provideMerge(ServerExposure.layer), Layer.provideMerge(serverRelayBrokerTracingLayer), Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index ae9a5ae9f2c9..1caa8d512aea 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -4,6 +4,7 @@ import { buildPairingUrl, formatHeadlessServeOutput, formatStartupAccessOutput, + isRemoteReachableHost, renderTerminalQrCode, resolveHeadlessConnectionHost, resolveHeadlessConnectionString, @@ -11,6 +12,7 @@ import { resolveLanConnectionString, resolveListeningPort, resolveLocalConnectionString, + resolveServerAdvertisedHost, } from "./startupAccess.ts"; it("prefers localhost when no explicit host is configured", () => { @@ -18,6 +20,35 @@ it("prefers localhost when no explicit host is configured", () => { expect(resolveHeadlessConnectionString(undefined, 3773)).toBe("http://localhost:3773"); }); +it("treats undefined and loopback hosts as local-only for remote reachability", () => { + expect(isRemoteReachableHost(undefined)).toBe(false); + expect(isRemoteReachableHost("127.0.0.1")).toBe(false); + expect(isRemoteReachableHost("localhost")).toBe(false); + expect(isRemoteReachableHost("0.0.0.0")).toBe(true); + expect(isRemoteReachableHost("192.168.1.42")).toBe(true); +}); + +it("resolves server advertised hosts only when the bind address is remote-reachable", () => { + const interfaces = { + en0: [ + { + address: "192.168.1.42", + netmask: "255.255.255.0", + family: "IPv4" as const, + mac: "00:00:00:00:00:00", + internal: false, + cidr: "192.168.1.42/24", + scopeid: 0, + }, + ], + }; + + expect(resolveServerAdvertisedHost(undefined, interfaces)).toBe(null); + expect(resolveServerAdvertisedHost("127.0.0.1", interfaces)).toBe(null); + expect(resolveServerAdvertisedHost("192.168.1.10", interfaces)).toBe("192.168.1.10"); + expect(resolveServerAdvertisedHost("0.0.0.0", interfaces)).toBe("192.168.1.42"); +}); + it("keeps explicit bind hosts in the connection string", () => { expect(resolveHeadlessConnectionString("127.0.0.1", 3773)).toBe("http://127.0.0.1:3773"); expect(resolveHeadlessConnectionString("::1", 3773)).toBe("http://[::1]:3773"); @@ -76,7 +107,7 @@ it("resolves a LAN connection string from the first external IPv4 interface", () { address: "192.168.1.42", netmask: "255.255.255.0", - family: "IPv4", + family: "IPv4" as const, mac: "00:00:00:00:00:00", internal: false, cidr: "192.168.1.42/24", diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index a92b92cdc9f5..50b6e5f2dbb3 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -39,6 +39,22 @@ export const isLoopbackHost = (host: string | undefined): boolean => { export const isWildcardHost = (host: string | undefined): boolean => host === "0.0.0.0" || host === "::" || host === "[::]"; +export const isRemoteReachableHost = (host: string | undefined): boolean => + isWildcardHost(host) || !isLoopbackHost(host); + +export const resolveServerAdvertisedHost = ( + host: string | undefined, + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), +): string | null => { + if (!isRemoteReachableHost(host)) { + return null; + } + if (host && !isWildcardHost(host)) { + return normalizeHost(host); + } + return resolveLanConnectionHost(interfaces) ?? null; +}; + export const formatHostForUrl = (host: string): string => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a316d2a26cd5..b1a83a693cdd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -17,6 +17,7 @@ import { AuthRelayWriteScope, AuthTerminalOperateScope, AuthAccessReadScope, + AuthAccessWriteScope, AuthAccessStreamError, type AuthAccessStreamEvent, type AuthEnvironmentScope, @@ -94,6 +95,7 @@ import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentit import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import type { AuthenticatedSession } from "./auth/EnvironmentAuth.ts"; +import * as ServerExposure from "./access/ServerExposure.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -272,6 +274,9 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], + [WS_METHODS.serverGetExposureState, AuthAccessReadScope], + [WS_METHODS.serverGetAdvertisedEndpoints, AuthAccessReadScope], + [WS_METHODS.serverSetTailscaleServeEnabled, AuthAccessWriteScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], @@ -376,6 +381,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const serverExposure = yield* ServerExposure.ServerExposure; const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; const automaticGitFetchInterval = serverSettings.getSettings.pipe( Effect.map((settings) => settings.automaticGitFetchInterval), @@ -1201,6 +1207,26 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetExposureState]: (_input) => + observeRpcEffect(WS_METHODS.serverGetExposureState, serverExposure.getState, { + "rpc.aggregate": "server", + }), + [WS_METHODS.serverGetAdvertisedEndpoints]: (_input) => + observeRpcEffect( + WS_METHODS.serverGetAdvertisedEndpoints, + serverExposure.getAdvertisedEndpoints, + { + "rpc.aggregate": "server", + }, + ), + [WS_METHODS.serverSetTailscaleServeEnabled]: (input) => + observeRpcEffect( + WS_METHODS.serverSetTailscaleServeEnabled, + serverExposure.setTailscaleServeEnabled(input), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.cloudGetRelayClientStatus]: (_input) => observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { "rpc.aggregate": "cloud", diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index f28ee365af14..2a7f0ee08bd9 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -28,6 +28,7 @@ import { type DesktopSshEnvironmentTarget, type DesktopServerExposureState, type EnvironmentId, + type ServerExposureState, } from "@t3tools/contracts"; import { WsRpcClient } from "@t3tools/client-runtime"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; @@ -131,6 +132,7 @@ import { webRuntime } from "~/lib/runtime"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; +type ConnectionServerExposureState = DesktopServerExposureState | ServerExposureState; const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", @@ -1323,6 +1325,7 @@ type AdvertisedEndpointListRowProps = { onSetupTailscaleServe: (endpoint: AdvertisedEndpoint) => void; onDisableTailscaleServe: (endpoint: AdvertisedEndpoint) => void; isUpdatingTailscaleServe: boolean; + tailscaleActionsRequireRestart?: boolean; }; const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ @@ -1333,7 +1336,9 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ onSetupTailscaleServe, onDisableTailscaleServe, isUpdatingTailscaleServe, + tailscaleActionsRequireRestart = true, }: AdvertisedEndpointListRowProps) { + const tailscaleUpdatingLabel = tailscaleActionsRequireRestart ? "Restarting…" : "Applying…"; const isAvailable = endpoint.status === "available"; const needsTailscaleSetup = isTailscaleHttpsEndpoint(endpoint) && endpoint.status !== "available"; const canDisableTailscaleServe = @@ -1377,7 +1382,7 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ onClick={() => onSetupTailscaleServe(endpoint)} disabled={isUpdatingTailscaleServe} > - {isUpdatingTailscaleServe ? "Restarting…" : "Setup"} + {isUpdatingTailscaleServe ? tailscaleUpdatingLabel : "Setup"} ) : null} {canDisableTailscaleServe ? ( @@ -1387,7 +1392,7 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ onClick={() => onDisableTailscaleServe(endpoint)} disabled={isUpdatingTailscaleServe} > - {isUpdatingTailscaleServe ? "Restarting…" : "Disable"} + {isUpdatingTailscaleServe ? tailscaleUpdatingLabel : "Disable"} ) : null} {!needsTailscaleSetup && !isDefault ? ( @@ -1884,7 +1889,7 @@ export function ConnectionsSettings() { const [connectingSshHostAlias, setConnectingSshHostAlias] = useState(null); const [desktopServerExposureState, setDesktopServerExposureState] = - useState(null); + useState(null); const [desktopAdvertisedEndpoints, setDesktopAdvertisedEndpoints] = useState< ReadonlyArray >([]); @@ -1942,7 +1947,7 @@ export function ConnectionsSettings() { String(DEFAULT_TAILSCALE_SERVE_PORT), ); const [pendingDesktopServerExposureMode, setPendingDesktopServerExposureMode] = useState< - DesktopServerExposureState["mode"] | null + ConnectionServerExposureState["mode"] | null >(null); const primaryServerConfig = useServerConfig(); const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig); @@ -2018,16 +2023,24 @@ export function ConnectionsSettings() { }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]); const handleConfirmTailscaleServeSetup = useCallback(async () => { - if (!desktopBridge) return; if (!isTailscaleServePortValid) return; setIsUpdatingTailscaleServe(true); setDesktopServerExposureError(null); try { - const nextState = await desktopBridge.setTailscaleServeEnabled({ - enabled: true, - port: parsedTailscaleServePort, - }); + const nextState = desktopBridge + ? await desktopBridge.setTailscaleServeEnabled({ + enabled: true, + port: parsedTailscaleServePort, + }) + : await getPrimaryEnvironmentConnection().client.server.setTailscaleServeEnabled({ + enabled: true, + port: parsedTailscaleServePort, + }); + const endpoints = desktopBridge + ? await desktopBridge.getAdvertisedEndpoints() + : await getPrimaryEnvironmentConnection().client.server.getAdvertisedEndpoints(); setDesktopServerExposureState(nextState); + setDesktopAdvertisedEndpoints(endpoints); setPendingTailscaleServeEndpoint(null); } catch (error) { const message = @@ -2056,15 +2069,25 @@ export function ConnectionsSettings() { ); const handleConfirmTailscaleServeDisable = useCallback(async () => { - if (!desktopBridge) return; setIsUpdatingTailscaleServe(true); setDesktopServerExposureError(null); try { - const nextState = await desktopBridge.setTailscaleServeEnabled({ - enabled: false, - port: desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT, - }); + const servePort = + desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT; + const nextState = desktopBridge + ? await desktopBridge.setTailscaleServeEnabled({ + enabled: false, + port: servePort, + }) + : await getPrimaryEnvironmentConnection().client.server.setTailscaleServeEnabled({ + enabled: false, + port: servePort, + }); + const endpoints = desktopBridge + ? await desktopBridge.getAdvertisedEndpoints() + : await getPrimaryEnvironmentConnection().client.server.getAdvertisedEndpoints(); setDesktopServerExposureState(nextState); + setDesktopAdvertisedEndpoints(endpoints); setDisableTailscaleServeDialogOpen(false); } catch (error) { const message = error instanceof Error ? error.message : "Failed to disable Tailscale HTTPS."; @@ -2458,9 +2481,31 @@ export function ConnectionsSettings() { setDesktopServerExposureError(message); }); } else { - setDesktopServerExposureState(null); - setDesktopAdvertisedEndpoints([]); - setDesktopServerExposureError(null); + const server = getPrimaryEnvironmentConnection().client.server; + void server + .getExposureState() + .then((state) => { + if (cancelled) return; + setDesktopServerExposureState(state); + }) + .catch((error: unknown) => { + if (cancelled) return; + const message = + error instanceof Error ? error.message : "Failed to load server exposure state."; + setDesktopServerExposureError(message); + }); + void server + .getAdvertisedEndpoints() + .then((endpoints) => { + if (cancelled) return; + setDesktopAdvertisedEndpoints(endpoints); + }) + .catch((error: unknown) => { + if (cancelled) return; + const message = + error instanceof Error ? error.message : "Failed to load reachable endpoints."; + setDesktopServerExposureError(message); + }); } return () => { @@ -2741,6 +2786,7 @@ export function ConnectionsSettings() { onSetupTailscaleServe={handleStartTailscaleServeSetup} onDisableTailscaleServe={handleStartTailscaleServeDisable} isUpdatingTailscaleServe={isUpdatingTailscaleServe} + tailscaleActionsRequireRestart={Boolean(desktopBridge)} /> ); }) @@ -2831,9 +2877,30 @@ export function ConnectionsSettings() { setIsAdvertisedEndpointListExpanded((expanded) => !expanded)} + fallback={ + desktopServerExposureState?.endpointUrl + ? `Reachable at ${desktopServerExposureState.endpointUrl}` + : desktopServerExposureState?.advertisedHost + ? `Exposed on all interfaces. Pairing links use ${desktopServerExposureState.advertisedHost}.` + : "Exposed on all interfaces." + } + /> + ) : currentAuthPolicy === "remote-reachable" ? ( + "This backend is already configured for remote access. Network exposure changes must be made where the server is launched." + ) : ( + "This backend is only reachable on this machine. Restart it with a non-loopback host to enable remote pairing." + ) + } + status={ + desktopServerExposureError ? ( + {desktopServerExposureError} + ) : null } control={ @@ -2885,6 +2952,8 @@ export function ConnectionsSettings() { ) : ( <> {renderDisabledNetworkAccessRow()} + {renderEndpointRows("endpoint-rail")} + {renderTailscaleRow()} )} @@ -2974,7 +3043,9 @@ export function ConnectionsSettings() { Disable Tailscale HTTPS? - more Code will restart the local backend without Tailscale Serve. + {desktopBridge + ? "more Code will restart the local backend without Tailscale Serve." + : "more Code will disable Tailscale Serve for this backend."} @@ -2992,10 +3063,12 @@ export function ConnectionsSettings() { {isUpdatingTailscaleServe ? ( <> - Restarting… + {desktopBridge ? "Restarting…" : "Applying…"} - ) : ( + ) : desktopBridge ? ( "Restart and disable" + ) : ( + "Disable" )} @@ -3012,8 +3085,9 @@ export function ConnectionsSettings() { Set up Tailscale HTTPS? - more Code will restart the local backend with Tailscale Serve enabled and ask - Tailscale to proxy HTTPS traffic to this backend. + {desktopBridge + ? "more Code will restart the local backend with Tailscale Serve enabled and ask Tailscale to proxy HTTPS traffic to this backend." + : "more Code will enable Tailscale Serve and ask Tailscale to proxy HTTPS traffic to this backend."} @@ -3058,7 +3132,7 @@ export function ConnectionsSettings() { {isUpdatingTailscaleServe ? ( <> - Restarting… + {desktopBridge ? "Restarting…" : "Applying…"} ) : ( "Enable" diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 3c4cd34c27b2..8e858512062e 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -155,6 +155,9 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { getProcessDiagnostics: vi.fn(), getProcessResourceHistory: vi.fn(), signalProcess: vi.fn(), + getExposureState: vi.fn(), + getAdvertisedEndpoints: vi.fn(), + setTailscaleServeEnabled: vi.fn(), }, }; return { diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index 31b22de93474..842d9703a240 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -152,6 +152,13 @@ export interface WsRpcClient { typeof WS_METHODS.serverGetProcessResourceHistory >; readonly signalProcess: RpcUnaryMethod; + readonly getExposureState: RpcUnaryNoArgMethod; + readonly getAdvertisedEndpoints: RpcUnaryNoArgMethod< + typeof WS_METHODS.serverGetAdvertisedEndpoints + >; + readonly setTailscaleServeEnabled: RpcUnaryMethod< + typeof WS_METHODS.serverSetTailscaleServeEnabled + >; }; readonly cloud: { readonly getRelayClientStatus: RpcUnaryNoArgMethod; @@ -330,6 +337,12 @@ export function createWsRpcClient( transport.request((client) => client[WS_METHODS.serverGetProcessResourceHistory](input)), signalProcess: (input) => transport.request((client) => client[WS_METHODS.serverSignalProcess](input)), + getExposureState: () => + transport.request((client) => client[WS_METHODS.serverGetExposureState]({})), + getAdvertisedEndpoints: () => + transport.request((client) => client[WS_METHODS.serverGetAdvertisedEndpoints]({})), + setTailscaleServeEnabled: (input) => + transport.request((client) => client[WS_METHODS.serverSetTailscaleServeEnabled](input)), }, cloud: { getRelayClientStatus: () => diff --git a/packages/contracts/src/remoteAccess.ts b/packages/contracts/src/remoteAccess.ts index e3de3c29e122..5bac9e31d2db 100644 --- a/packages/contracts/src/remoteAccess.ts +++ b/packages/contracts/src/remoteAccess.ts @@ -1,6 +1,6 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PortSchema, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const AdvertisedEndpointProviderKind = Schema.Literals([ "core", @@ -66,3 +66,30 @@ export const AdvertisedEndpoint = Schema.Struct({ description: Schema.optional(TrimmedNonEmptyString), }); export type AdvertisedEndpoint = typeof AdvertisedEndpoint.Type; + +export const ServerExposureMode = Schema.Literals(["local-only", "network-accessible"]); +export type ServerExposureMode = typeof ServerExposureMode.Type; + +export const ServerExposureState = Schema.Struct({ + mode: ServerExposureMode, + endpointUrl: Schema.NullOr(Schema.String), + advertisedHost: Schema.NullOr(Schema.String), + tailscaleServeEnabled: Schema.Boolean, + tailscaleServePort: Schema.Number, +}); +export type ServerExposureState = typeof ServerExposureState.Type; + +export const ServerTailscaleServeInput = Schema.Struct({ + enabled: Schema.Boolean, + port: Schema.optional(PortSchema), +}); +export type ServerTailscaleServeInput = typeof ServerTailscaleServeInput.Type; + +export class ServerExposureError extends Schema.TaggedErrorClass()( + "ServerExposureError", + { + operation: Schema.Literals(["read", "tailscale-serve"]), + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 508e6e987ebb..389a167f6960 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -43,6 +43,12 @@ import { ReviewDiffPreviewInput, ReviewDiffPreviewResult, } from "./review.ts"; +import { + AdvertisedEndpoint, + ServerExposureError, + ServerExposureState, + ServerTailscaleServeInput, +} from "./remoteAccess.ts"; import { KeybindingsConfigError } from "./keybindings.ts"; import { ClientOrchestrationCommand, @@ -174,6 +180,9 @@ export const WS_METHODS = { serverGetProcessDiagnostics: "server.getProcessDiagnostics", serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverSignalProcess: "server.signalProcess", + serverGetExposureState: "server.getExposureState", + serverGetAdvertisedEndpoints: "server.getAdvertisedEndpoints", + serverSetTailscaleServeEnabled: "server.setTailscaleServeEnabled", // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", @@ -276,6 +285,27 @@ export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, error: EnvironmentAuthorizationError, }); +export const WsServerGetExposureStateRpc = Rpc.make(WS_METHODS.serverGetExposureState, { + payload: Schema.Struct({}), + success: ServerExposureState, + error: Schema.Union([ServerExposureError, EnvironmentAuthorizationError]), +}); + +export const WsServerGetAdvertisedEndpointsRpc = Rpc.make(WS_METHODS.serverGetAdvertisedEndpoints, { + payload: Schema.Struct({}), + success: Schema.Array(AdvertisedEndpoint), + error: Schema.Union([ServerExposureError, EnvironmentAuthorizationError]), +}); + +export const WsServerSetTailscaleServeEnabledRpc = Rpc.make( + WS_METHODS.serverSetTailscaleServeEnabled, + { + payload: ServerTailscaleServeInput, + success: ServerExposureState, + error: Schema.Union([ServerExposureError, EnvironmentAuthorizationError]), + }, +); + export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { payload: Schema.Struct({}), success: RelayClientStatusSchema, @@ -568,6 +598,9 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetProcessDiagnosticsRpc, WsServerGetProcessResourceHistoryRpc, WsServerSignalProcessRpc, + WsServerGetExposureStateRpc, + WsServerGetAdvertisedEndpointsRpc, + WsServerSetTailscaleServeEnabledRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, WsSourceControlLookupRepositoryRpc,