diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index def0434140c3..2b022cd069d2 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -9,6 +9,7 @@ import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as HttpClient from "effect/http/HttpClient"; +import type * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { describe, expect, it } from "vite-plus/test"; @@ -250,12 +251,24 @@ const layerTailscaleUp = Layer.succeed( ), ); +const T3_DESCRIPTOR_BODY = JSON.stringify({ + environmentId: "serve-test", + label: "Serve test", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, +}); + +const serveProbeResponse = (request: HttpClientRequest.HttpClientRequest, status: number) => + HttpClientResponse.fromWeb( + request, + new Response(status === 200 ? T3_DESCRIPTOR_BODY : null, { status }), + ); + const layerServeProbe = (status: number) => Layer.succeed( HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))), - ), + HttpClient.make((request) => Effect.succeed(serveProbeResponse(request, status))), ); const layerServeConfig = (host = "127.0.0.1") => @@ -320,9 +333,7 @@ describe("DirectEndpoints Tailscale Serve", () => { ) : Effect.void, ), - Effect.map((status) => - HttpClientResponse.fromWeb(request, new Response(null, { status })), - ), + Effect.map((status) => serveProbeResponse(request, status)), ), ), ), diff --git a/packages/tailscale/package.json b/packages/tailscale/package.json index 859aaaf70eed..63052f5ab7fb 100644 --- a/packages/tailscale/package.json +++ b/packages/tailscale/package.json @@ -13,6 +13,7 @@ "test": "vp test run --config ../../vite.config.ts --dir ." }, "dependencies": { + "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "effect": "catalog:" }, diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 2e4b7834506c..a1203fabb6d6 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -7,6 +7,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/http"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/process"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -17,6 +18,7 @@ import { parseTailscaleServeRoutesTo, parseTailscaleSshEnabled, parseTailscaleStatus, + probeTailscaleHttpsEndpoint, readTailscaleSshEnabled, readTailscaleStatus, TailscaleCommandExitError, @@ -99,6 +101,27 @@ function layerMockSpawner( } describe("tailscale", () => { + it.effect("does not treat a non-T3 response on the HTTPS probe path as reachable", () => + Effect.gen(function* () { + for (const [body, status] of [ + ["Other application", 200], + [null, 204], + ] as const) { + const reachable = yield* probeTailscaleHttpsEndpoint({ + baseUrl: "https://desktop.example.ts.net", + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body, { status }))), + ), + ), + ); + assert.isFalse(reachable, `HTTP ${status} without a T3 descriptor`); + } + }), + ); + it("finds the Serve routes that forward to the bound address, and only those", () => { const status = JSON.stringify({ TCP: { diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 1ede40904a47..e31aba093f68 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -1,3 +1,4 @@ +import { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { normalizeHostname } from "@t3tools/shared/hostClassification"; import * as Duration from "effect/Duration"; @@ -5,7 +6,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import { HttpClient, HttpClientRequest } from "effect/http"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; export const DEFAULT_TAILSCALE_SERVE_PORT = 443; @@ -658,16 +659,16 @@ export const readTailscaleServeRoutesTo = (target: { export const probeTailscaleHttpsEndpoint = Effect.fn("tailscale.probeTailscaleHttpsEndpoint")( function* (input: { readonly baseUrl: string; readonly timeout?: Duration.Input }) { const client = yield* HttpClient.HttpClient; - const response = yield* Effect.gen(function* () { + const descriptor = yield* Effect.gen(function* () { const url = new URL("/.well-known/t3/environment", input.baseUrl); - const request = HttpClientRequest.get(url.toString()); - return yield* client.execute(request); + const response = yield* client + .execute(HttpClientRequest.get(url.toString())) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + // A Serve mapping to another app also answers 2xx; only a T3 descriptor proves this server. + return yield* HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)(response); }).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT)); - return Option.match(response, { - onNone: () => false, - onSome: (httpResponse) => httpResponse.status >= 200 && httpResponse.status < 300, - }); + return Option.isSome(descriptor); }, Effect.orElseSucceed(() => false), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61a7549a73d9..372808c93ddc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2461,6 +2461,9 @@ importers: packages/tailscale: dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts '@t3tools/shared': specifier: workspace:* version: link:../shared