From ea99c9a72289312eb010f45646049fb8d32669f7 Mon Sep 17 00:00:00 2001 From: Sullaimon Date: Mon, 29 Jun 2026 07:16:51 -0400 Subject: [PATCH 1/2] Add web access controls for local backend --- apps/server/src/access/ServerExposure.ts | 281 ++++++++++++++++++ .../src/access/tailscaleEndpointProvider.ts | 148 +++++++++ .../diagnostics/ProcessDiagnostics.test.ts | 47 +++ .../src/diagnostics/ProcessDiagnostics.ts | 22 +- .../Layers/ProjectionSnapshotQuery.ts | 7 + apps/server/src/server.test.ts | 24 ++ apps/server/src/server.ts | 2 + apps/server/src/startupAccess.test.ts | 2 +- apps/server/src/ws.ts | 26 ++ .../settings/ConnectionsSettings.tsx | 101 +++++-- .../service.threadSubscriptions.test.ts | 3 + packages/client-runtime/src/wsRpcClient.ts | 13 + packages/contracts/src/remoteAccess.ts | 27 ++ packages/contracts/src/rpc.ts | 33 ++ 14 files changed, 712 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/access/ServerExposure.ts create mode 100644 apps/server/src/access/tailscaleEndpointProvider.ts diff --git a/apps/server/src/access/ServerExposure.ts b/apps/server/src/access/ServerExposure.ts new file mode 100644 index 000000000000..7e04e1d021b3 --- /dev/null +++ b/apps/server/src/access/ServerExposure.ts @@ -0,0 +1,281 @@ +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, + isLoopbackHost, + isWildcardHost, + resolveLanConnectionHost, + resolveListeningPort, +} 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 normalizeHost(host: string): string { + return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; +} + +function isNetworkAccessibleHost(host: string | undefined): boolean { + return host === undefined || isWildcardHost(host) || !isLoopbackHost(host); +} + +function resolveAdvertisedHost(host: string | undefined): string | null { + if (!isNetworkAccessibleHost(host)) { + return null; + } + if (host && !isWildcardHost(host)) { + return normalizeHost(host); + } + return resolveLanConnectionHost() ?? null; +} + +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 = resolveAdvertisedHost(input.host); + const endpointUrl = resolveEndpointUrl(advertisedHost, input.port); + return { + mode: isNetworkAccessibleHost(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; +} + +function localHostForTailscaleServe(host: string | undefined): string { + return host && !isWildcardHost(host) ? normalizeHost(host) : SERVER_LOOPBACK_HOST; +} + +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 runtimeManagedTailscaleServeRef = yield* Ref.make(false); + + const readMagicDnsName: Effect.Effect = readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.orElseSucceed(() => null), + 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; + const localHost = localHostForTailscaleServe(config.host); + + if (input.enabled) { + yield* ensureTailscaleServe({ + localPort: port, + servePort, + localHost, + }).pipe( + Effect.mapError( + (cause) => + new ServerExposureError({ + operation: "tailscale-serve", + message: cause.message, + cause, + }), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); + yield* Ref.set(runtimeManagedTailscaleServeRef, true); + } else { + yield* disableTailscaleServe({ servePort }).pipe( + Effect.mapError( + (cause) => + new ServerExposureError({ + operation: "tailscale-serve", + message: cause.message, + cause, + }), + ), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); + yield* Ref.set(runtimeManagedTailscaleServeRef, false); + } + + const tailscale = { enabled: input.enabled, port: servePort }; + yield* Ref.set(tailscaleRef, tailscale); + return makeState({ host: config.host, port, tailscale }); + }); + + yield* Effect.addFinalizer(() => + Ref.get(runtimeManagedTailscaleServeRef).pipe( + Effect.flatMap((isRuntimeManaged) => + isRuntimeManaged + ? Ref.get(tailscaleRef).pipe( + Effect.flatMap((tailscale) => + disableTailscaleServe({ servePort: tailscale.port }).pipe( + Effect.tap(() => + Effect.logInfo("Runtime-managed Tailscale Serve disabled", { + servePort: tailscale.port, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to disable runtime-managed 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()); 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/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..f0096f836196 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, @@ -461,6 +462,7 @@ export const makeServerLayer = Layer.unwrap( 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..8040d543002b 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -76,7 +76,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/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..5d0ef6ac271b 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", @@ -1884,7 +1886,7 @@ export function ConnectionsSettings() { const [connectingSshHostAlias, setConnectingSshHostAlias] = useState(null); const [desktopServerExposureState, setDesktopServerExposureState] = - useState(null); + useState(null); const [desktopAdvertisedEndpoints, setDesktopAdvertisedEndpoints] = useState< ReadonlyArray >([]); @@ -1942,7 +1944,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 +2020,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 +2066,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 +2478,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 () => { @@ -2831,9 +2873,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 +2948,8 @@ export function ConnectionsSettings() { ) : ( <> {renderDisabledNetworkAccessRow()} + {renderEndpointRows("endpoint-rail")} + {renderTailscaleRow()} )} 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..5d91d3233c19 100644 --- a/packages/contracts/src/remoteAccess.ts +++ b/packages/contracts/src/remoteAccess.ts @@ -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(Schema.Number), +}); +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, From 1f2532635983309f972f171c6030b54d1752e71e Mon Sep 17 00:00:00 2001 From: sull Date: Wed, 1 Jul 2026 04:03:56 +0000 Subject: [PATCH 2/2] Fix ServerExposure review findings for web access controls Align host reachability with EnvironmentAuthPolicy, consolidate Tailscale Serve lifecycle into ServerExposure, validate serve ports in contracts, and use accurate UI copy when toggling Serve via server RPC instead of desktop restart. --- apps/server/src/access/ServerExposure.ts | 105 +++++++++--------- apps/server/src/auth/EnvironmentAuthPolicy.ts | 4 +- apps/server/src/server.ts | 54 +-------- apps/server/src/startupAccess.test.ts | 31 ++++++ apps/server/src/startupAccess.ts | 16 +++ .../settings/ConnectionsSettings.tsx | 25 +++-- packages/contracts/src/remoteAccess.ts | 4 +- 7 files changed, 119 insertions(+), 120 deletions(-) diff --git a/apps/server/src/access/ServerExposure.ts b/apps/server/src/access/ServerExposure.ts index 7e04e1d021b3..c5c66963c014 100644 --- a/apps/server/src/access/ServerExposure.ts +++ b/apps/server/src/access/ServerExposure.ts @@ -23,10 +23,9 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import { ServerConfig } from "../config.ts"; import { formatHostForUrl, - isLoopbackHost, - isWildcardHost, - resolveLanConnectionHost, + isRemoteReachableHost, resolveListeningPort, + resolveServerAdvertisedHost, } from "../startupAccess.ts"; import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; @@ -44,24 +43,6 @@ interface TailscaleServeState { readonly port: number; } -function normalizeHost(host: string): string { - return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; -} - -function isNetworkAccessibleHost(host: string | undefined): boolean { - return host === undefined || isWildcardHost(host) || !isLoopbackHost(host); -} - -function resolveAdvertisedHost(host: string | undefined): string | null { - if (!isNetworkAccessibleHost(host)) { - return null; - } - if (host && !isWildcardHost(host)) { - return normalizeHost(host); - } - return resolveLanConnectionHost() ?? null; -} - function resolveEndpointUrl(host: string | null, port: number): string | null { return host ? `http://${formatHostForUrl(host)}:${port}` : null; } @@ -75,10 +56,10 @@ function makeState(input: { readonly port: number; readonly tailscale: TailscaleServeState; }): ServerExposureState { - const advertisedHost = resolveAdvertisedHost(input.host); + const advertisedHost = resolveServerAdvertisedHost(input.host); const endpointUrl = resolveEndpointUrl(advertisedHost, input.port); return { - mode: isNetworkAccessibleHost(input.host) ? "network-accessible" : "local-only", + mode: isRemoteReachableHost(input.host) ? "network-accessible" : "local-only", endpointUrl, advertisedHost, tailscaleServeEnabled: input.tailscale.enabled, @@ -132,10 +113,6 @@ function resolveCoreAdvertisedEndpoints(input: { return endpoints; } -function localHostForTailscaleServe(host: string | undefined): string { - return host && !isWildcardHost(host) ? normalizeHost(host) : SERVER_LOOPBACK_HOST; -} - export class ServerExposure extends Context.Service< ServerExposure, { @@ -160,7 +137,6 @@ export const make = Effect.fn("makeServerExposure")(function* () { enabled: config.tailscaleServeEnabled, port: config.tailscaleServePort, }); - const runtimeManagedTailscaleServeRef = yield* Ref.make(false); const readMagicDnsName: Effect.Effect = readTailscaleStatus.pipe( Effect.map((status) => status.magicDnsName), @@ -168,6 +144,35 @@ export const make = Effect.fn("makeServerExposure")(function* () { 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 })), ); @@ -202,13 +207,12 @@ export const make = Effect.fn("makeServerExposure")(function* () { ) { const current = yield* Ref.get(tailscaleRef); const servePort = input.port ?? current.port; - const localHost = localHostForTailscaleServe(config.host); if (input.enabled) { yield* ensureTailscaleServe({ localPort: port, servePort, - localHost, + localHost: SERVER_LOOPBACK_HOST, }).pipe( Effect.mapError( (cause) => @@ -220,7 +224,6 @@ export const make = Effect.fn("makeServerExposure")(function* () { ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), ); - yield* Ref.set(runtimeManagedTailscaleServeRef, true); } else { yield* disableTailscaleServe({ servePort }).pipe( Effect.mapError( @@ -233,7 +236,6 @@ export const make = Effect.fn("makeServerExposure")(function* () { ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), ); - yield* Ref.set(runtimeManagedTailscaleServeRef, false); } const tailscale = { enabled: input.enabled, port: servePort }; @@ -242,29 +244,22 @@ export const make = Effect.fn("makeServerExposure")(function* () { }); yield* Effect.addFinalizer(() => - Ref.get(runtimeManagedTailscaleServeRef).pipe( - Effect.flatMap((isRuntimeManaged) => - isRuntimeManaged - ? Ref.get(tailscaleRef).pipe( - Effect.flatMap((tailscale) => - disableTailscaleServe({ servePort: tailscale.port }).pipe( - Effect.tap(() => - Effect.logInfo("Runtime-managed Tailscale Serve disabled", { - servePort: tailscale.port, - }), - ), - Effect.catch((cause) => - Effect.logWarning("Failed to disable runtime-managed Tailscale Serve", { - cause, - servePort: tailscale.port, - }), - ), - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - childProcessSpawner, - ), - ), + 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, ), @@ -278,4 +273,4 @@ export const make = Effect.fn("makeServerExposure")(function* () { }); }); -export const layer = Layer.effect(ServerExposure, make()); +export const layer = Layer.effect(ServerExposure, make()); \ No newline at end of file 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/server.ts b/apps/server/src/server.ts index f0096f836196..a11a53ac6194 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -87,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 @@ -377,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; @@ -456,7 +405,6 @@ export const makeServerLayer = Layer.unwrap( }), httpListeningLayer, runtimeStateLayer, - tailscaleServeLayer, cloudDesiredLinkReconcileLayer, ); diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 8040d543002b..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"); 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/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 5d0ef6ac271b..2a7f0ee08bd9 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1325,6 +1325,7 @@ type AdvertisedEndpointListRowProps = { onSetupTailscaleServe: (endpoint: AdvertisedEndpoint) => void; onDisableTailscaleServe: (endpoint: AdvertisedEndpoint) => void; isUpdatingTailscaleServe: boolean; + tailscaleActionsRequireRestart?: boolean; }; const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ @@ -1335,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 = @@ -1379,7 +1382,7 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ onClick={() => onSetupTailscaleServe(endpoint)} disabled={isUpdatingTailscaleServe} > - {isUpdatingTailscaleServe ? "Restarting…" : "Setup"} + {isUpdatingTailscaleServe ? tailscaleUpdatingLabel : "Setup"} ) : null} {canDisableTailscaleServe ? ( @@ -1389,7 +1392,7 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ onClick={() => onDisableTailscaleServe(endpoint)} disabled={isUpdatingTailscaleServe} > - {isUpdatingTailscaleServe ? "Restarting…" : "Disable"} + {isUpdatingTailscaleServe ? tailscaleUpdatingLabel : "Disable"} ) : null} {!needsTailscaleSetup && !isDefault ? ( @@ -2783,6 +2786,7 @@ export function ConnectionsSettings() { onSetupTailscaleServe={handleStartTailscaleServeSetup} onDisableTailscaleServe={handleStartTailscaleServeDisable} isUpdatingTailscaleServe={isUpdatingTailscaleServe} + tailscaleActionsRequireRestart={Boolean(desktopBridge)} /> ); }) @@ -3039,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."} @@ -3057,10 +3063,12 @@ export function ConnectionsSettings() { {isUpdatingTailscaleServe ? ( <> - Restarting… + {desktopBridge ? "Restarting…" : "Applying…"} - ) : ( + ) : desktopBridge ? ( "Restart and disable" + ) : ( + "Disable" )} @@ -3077,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."} @@ -3123,7 +3132,7 @@ export function ConnectionsSettings() { {isUpdatingTailscaleServe ? ( <> - Restarting… + {desktopBridge ? "Restarting…" : "Applying…"} ) : ( "Enable" diff --git a/packages/contracts/src/remoteAccess.ts b/packages/contracts/src/remoteAccess.ts index 5d91d3233c19..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", @@ -81,7 +81,7 @@ export type ServerExposureState = typeof ServerExposureState.Type; export const ServerTailscaleServeInput = Schema.Struct({ enabled: Schema.Boolean, - port: Schema.optional(Schema.Number), + port: Schema.optional(PortSchema), }); export type ServerTailscaleServeInput = typeof ServerTailscaleServeInput.Type;