diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index f35e760bea4d7..8a55a0d119041 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -742,7 +742,7 @@ it.effect("linking a picked machine that answers nowhere names it and links noth .pipe(Effect.flip); expect(failed.reason).toBe("unreachable"); expect(failed.message).toBe( - `This environment could not reach Box at ${impostor.url}, http://192.168.1.20:3773.`, + `This environment could not reach Box: ${new URL(impostor.url).origin} answered as another environment, Impostor. http://192.168.1.20:3773 is plain http, which would send the link's credentials unencrypted. Use its https address, or its Tailscale address.`, ); expect(yield* a.links.list).toEqual([]); expect(yield* linkedSessions(impostor)).toEqual([]); @@ -763,6 +763,29 @@ it.effect("linking a picked machine that answers nowhere names it and links noth ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.live("linking an address with nothing listening says so, unlike one that is not T3 Code", () => + Effect.scoped( + Effect.gen(function* () { + const closed = yield* Effect.scoped( + serveRaw(() => Effect.succeed(HttpServerResponse.empty({ status: 404 }))), + ); + const notT3 = yield* serveRaw(() => + Effect.succeed( + HttpServerResponse.text("

Not Found

", { status: 404, contentType: "text/html" }), + ), + ); + const a = yield* makeA(laptop); + const linking = (url: string) => + a.links.link({ url, pairingCode: "unused", access: "auto" }).pipe(Effect.flip); + expect((yield* linking(closed)).message).toBe(`${closed} did not answer (ECONNREFUSED).`); + expect((yield* linking(notT3)).message).toBe( + `${notT3} answered, but not as a T3 Code environment this version understands.`, + ); + expect(yield* a.links.list).toEqual([]); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + /** An https listener whose certificate this machine does not trust. */ const serveUntrusted = Layer.build( Layer.fresh( diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 550d3ed757f07..b9c70127971b6 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -5,7 +5,6 @@ import { EnvironmentHttpApi, EnvironmentId, type ExecutionEnvironmentCapabilities, - type ExecutionEnvironmentDescriptor, MCP_PEER_LINK_SOFTWARE_ID, type PeerLink, type PeerLinkCreateInput, @@ -13,6 +12,7 @@ import { type PeerLinkSummary, } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -31,6 +31,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpApi from "effect/http-api/HttpApi"; import * as HttpApiClient from "effect/http-api/HttpApiClient"; import { isLocalLoopbackHost, isTailnetHost } from "@t3tools/shared/hostClassification"; @@ -176,6 +177,38 @@ const peerOrigin = (url: string) => { return Effect.succeed(parsed.origin); }; +/** The system error under a failed connection, such as ECONNREFUSED or ENOTFOUND. */ +const connectionCode = (cause: unknown) => + [cause, Predicate.hasProperty(cause, "cause") ? cause.cause : undefined] + .map((error) => (Predicate.hasProperty(error, "code") ? error.code : undefined)) + .find(Predicate.isString); + +/** Why `origin` did not answer as T3 Code, short enough for a toast. Never echoes a body. */ +const probeFailure = ( + origin: string, + error: HttpClientError.HttpClientError | Schema.SchemaError | Cause.TimeoutError, + timeout: Duration.Duration, +) => { + const unreachable = (message: string) => new PeerLinkError({ reason: "unreachable", message }); + const certificate = certificateErrorCode(error); + if (certificate !== undefined) { + return unreachable(`${origin} presented a certificate this machine rejected (${certificate}).`); + } + if (Cause.isTimeoutError(error)) { + return unreachable(`${origin} did not answer within ${Duration.toSeconds(timeout)} seconds.`); + } + if (HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError") { + const code = connectionCode(error.reason.cause); + return unreachable( + code === undefined ? `${origin} did not answer.` : `${origin} did not answer (${code}).`, + ); + } + return new PeerLinkError({ + reason: "not_a_t3_environment", + message: `${origin} answered, but not as a T3 Code environment this version understands.`, + }); +}; + /** Whether a host is this machine's own loopback. Tests serve their peers there, so they narrow it. */ export const LoopbackHost = Context.Reference<(host: string) => boolean>( "t3/peer/PeerLinks/LoopbackHost", @@ -217,13 +250,10 @@ const make = Effect.gen(function* () { Effect.provideService(HttpClient.HttpClient, peerHttpClient), ); - /** - * The environment answering at `origin`, or nothing when it does not answer - * as T3 Code. A certificate this machine rejects fails instead, since - * the peer may well be there. - */ - const describe = (origin: string) => - clientFor(origin).pipe( + /** The environment answering at `origin`, or why nothing there answers as T3 Code. */ + const probe = (origin: string) => { + const timeout = probeTimeout(origin); + return clientFor(origin).pipe( Effect.flatMap((client) => client.metadata.descriptor()), // A brief network drop must not list a live peer as down; the timeout still bounds both tries. Effect.retry({ @@ -231,27 +261,18 @@ const make = Effect.gen(function* () { schedule: Schedule.spaced("1 second"), while: (error) => certificateErrorCode(error) === undefined, }), - Effect.timeout(probeTimeout(origin)), - Effect.asSome, - Effect.catch((error) => { - const code = certificateErrorCode(error); - return code === undefined - ? Effect.succeed(Option.none()) - : Effect.fail( - new PeerLinkError({ - reason: "unreachable", - message: `${origin} presented a certificate this machine rejected (${code}).`, - }), - ); - }), + Effect.timeout(timeout), + // No cause: a decode error can carry the response body to the client. + Effect.mapError((error) => probeFailure(origin, error, timeout)), ); + }; - /** Whether `url` answers as `environmentId`, counting an untrusted certificate as no. */ + /** Whether `url` answers as `environmentId`, counting any failure as no. */ const answersAs = Effect.fn("PeerLinks.answersAs")(function* ( url: string, environmentId: EnvironmentId, ) { - const found = yield* describe(url).pipe(Effect.orElseSucceed(() => Option.none())); + const found = yield* probe(url).pipe(Effect.option); return Option.filter(found, (peer) => peer.environmentId === environmentId); }); @@ -404,7 +425,8 @@ const make = Effect.gen(function* () { /** * The addresses that answer as `environmentId`, in the order given, with * what the first one answered. Addresses this environment would not send - * credentials to are skipped; when every one is, the failure says why. + * credentials to are skipped; when none answers as it, the failure says why + * for each. */ const answeringAs = Effect.fn("PeerLinks.answeringAs")(function* ( urls: ReadonlyArray, @@ -423,28 +445,28 @@ const make = Effect.gen(function* () { message: `${origin} is this machine, not ${name}.`, }); } - return { origin, descriptor: yield* describe(origin) }; + const descriptor = yield* probe(origin); + if (descriptor.environmentId !== environmentId) { + return yield* new PeerLinkError({ + reason: "unreachable", + message: `${origin} answered as another environment, ${descriptor.label}.`, + }); + } + return { origin, descriptor }; }).pipe(Effect.result), { concurrency: "unbounded" }, ); const matching = checked.flatMap((result) => - Result.isSuccess(result) && - Option.isSome(result.success.descriptor) && - result.success.descriptor.value.environmentId === environmentId - ? [{ origin: result.success.origin, descriptor: result.success.descriptor.value }] - : [], + Result.isSuccess(result) ? [result.success] : [], ); const first = matching[0]; if (first === undefined) { - const refusals = checked.flatMap((result) => + const reasons = checked.flatMap((result) => Result.isFailure(result) ? [result.failure.message] : [], ); return yield* new PeerLinkError({ reason: "unreachable", - message: - refusals.length === checked.length - ? `This environment could not reach ${name}: ${refusals.join(" ")}` - : `This environment could not reach ${name} at ${urls.join(", ")}.`, + message: `This environment could not reach ${name}: ${reasons.join(" ")}`, }); } // Each address is kept once, even when two spellings share an origin. @@ -456,14 +478,7 @@ const make = Effect.gen(function* () { const answeringAt = Effect.fn("PeerLinks.answeringAt")(function* (urls: ReadonlyArray) { const origins = yield* Effect.forEach(urls, peerOrigin); const primary = origins[0]!; - const descriptor = yield* describe(primary); - if (Option.isNone(descriptor)) { - return yield* new PeerLinkError({ - reason: "not_a_t3_environment", - message: `Nothing at ${primary} answered as a T3 Code environment.`, - }); - } - return { primary, peer: descriptor.value, origins }; + return { primary, peer: yield* probe(primary), origins }; }); const link: PeerLinks["Service"]["link"] = Effect.fn("PeerLinks.link")(function* (input) {