Skip to content
Source
Sign inSign up

DirectEndpoints.ts

183 lines7.1 KB

1import type { ServerDirectEndpoint } from "@t3tools/contracts";
2import {
3 buildTailscaleHttpsBaseUrl,
4 probeTailscaleHttpsEndpoint,
5 readTailscaleStatus,
6} from "@t3tools/tailscale";
7import * as Context from "effect/Context";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Ref from "effect/Ref";
13import { HttpClient } from "effect/http";
14import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
15import * as NodeNet from "node:net";
16import * as NodeOS from "node:os";
17​
18import * as HostProcess from "@t3tools/shared/HostProcess";
19import {
20 formatHostForUrl,
21 isTailnetHost,
22 isWildcardHost,
23} from "@t3tools/shared/hostClassification";
24​
25import * as ServerConfig from "../config.ts";
26import * as ServerEnvironment from "./ServerEnvironment.ts";
27import { isLoopbackHost, isPrivateNetworkAddress, VIRTUAL_INTERFACE } from "../startupAccess.ts";
28​
29type NetworkInterfacesMap = ReturnType<typeof NodeOS.networkInterfaces>;
30​
31// Stay below the config discovery timeout so slow Tailscale drops only the tailnet name.
32const TAILSCALE_ENDPOINT_TIMEOUT = Duration.seconds(3);
33​
34export class DirectEndpoints extends Context.Service<
35 DirectEndpoints,
36 {
37 readonly resolveAvailable: () => Effect.Effect<ReadonlyArray<ServerDirectEndpoint>>;
38 readonly resolve: () => Effect.Effect<ReadonlyArray<ServerDirectEndpoint>>;
39 }
40>()("t3/environment/DirectEndpoints") {}
41​
42// Credentials travel over HTTP; public IPs leak them and hostnames can resolve differently per client.
43const isAdvertisableAddress = (address: string): boolean =>
44 NodeNet.isIPv4(address) && !address.startsWith("127.") && isPrivateNetworkAddress(address);
45​
46// macOS utun names need Tailscale's IPv6 range to identify them; WARP shares its IPv4 range.
47const isTailscaleInterface = (
48 name: string,
49 entries: ReadonlyArray<NodeOS.NetworkInterfaceInfo>,
50): boolean =>
51 /^tailscale/i.test(name) ||
52 entries.some((entry) => entry.family === "IPv6" && isTailnetHost(entry.address));
53​
54// Windows adapter names are mutable; Linux bridges can inherit these MACs.
55const WINDOWS_HOST_ADAPTER_MAC = /^(0a:00:27|00:50:56:c0:00):/i;
56​
57export function resolveBoundEndpoints(input: {
58 readonly platform: NodeJS.Platform;
59 readonly host: string | undefined;
60 readonly port: number;
61 readonly interfaces: NetworkInterfacesMap;
62 /** A cloud VM's private network is its provider's, which no client shares. */
63 readonly onCloudVm?: boolean;
64}): ReadonlyArray<ServerDirectEndpoint> {
65 if (isLoopbackHost(input.host)) return [];
66 const tailscaleAddresses = new Set(
67 Object.entries(input.interfaces).flatMap(([name, entries = []]) =>
68 isTailscaleInterface(name, entries) ? entries.map((entry) => entry.address) : [],
69 ),
70 );
71 const addresses = isWildcardHost(input.host)
72 ? Object.entries(input.interfaces)
73 .flatMap(([name, entries]) => (VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? [])))
74 .filter(
75 (entry) =>
76 !entry.internal &&
77 entry.family === "IPv4" &&
78 isAdvertisableAddress(entry.address) &&
79 !(input.platform === "win32" && WINDOWS_HOST_ADAPTER_MAC.test(entry.mac)),
80 )
81 .map((entry) => entry.address)
82 : [input.host!].filter(isAdvertisableAddress);
83 return [...new Set(addresses)]
84 .filter((address) => !input.onCloudVm || tailscaleAddresses.has(address))
85 .map((address) => ({
86 kind: tailscaleAddresses.has(address) ? "tailnet" : "lan",
87 httpBaseUrl: `http://${formatHostForUrl(address)}:${input.port}/`,
88 }));
90​
91/**
92 * AWS, GCP, Azure, DigitalOcean, Hetzner, Linode, Vultr, and Oracle all serve
93 * instance metadata at this address. A plain TCP connect skips HTTP proxies,
94 * which would otherwise answer for a machine that is not in a cloud.
95 */
96const CLOUD_METADATA_ADDRESS = "169.254.169.254";
97const CLOUD_METADATA_TIMEOUT_MS = 300;
98​
99const probeCloudMetadata = Effect.callback<boolean>((resume) => {
100 const socket = NodeNet.connect({
101 host: CLOUD_METADATA_ADDRESS,
102 port: 80,
103 timeout: CLOUD_METADATA_TIMEOUT_MS,
104 });
105 let settled = false;
106 const settle = (found: boolean) => {
107 if (settled) return;
108 settled = true;
109 socket.destroy();
110 resume(Effect.succeed(found));
111 };
112 socket.once("connect", () => settle(true));
113 socket.once("timeout", () => settle(false));
114 socket.once("error", () => settle(false));
115 return Effect.sync(() => socket.destroy());
116});
117​
118/** @public */
119export const make = Effect.gen(function* () {
120 const config = yield* ServerConfig.ServerConfig;
121 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
122 const httpClient = yield* HttpClient.HttpClient;
123 const platform = yield* HostProcess.Platform;
124 const environmentId = yield* (yield* ServerEnvironment.ServerEnvironment).getEnvironmentId;
125 // Only the service default listens on every interface without being asked,
126 // so only it checks once whether it runs in a cloud. The check starts now so
127 // the first client does not wait for its timeout.
128 const onCloudVm = config.privateNetworkOnly
129 ? yield* Effect.cached(probeCloudMetadata)
130 : Effect.succeed(false);
131 yield* Effect.forkDetach(onCloudVm);
132​
133 const servedEndpoint = yield* Ref.make<Option.Option<string>>(Option.none());
134 const resolveAvailable = Effect.fn("DirectEndpoints.resolveAvailable")(function* () {
135 const endpoints = [
136 ...resolveBoundEndpoints({
137 platform,
138 host: config.host,
139 port: config.port,
140 interfaces: NodeOS.networkInterfaces(),
141 onCloudVm: yield* onCloudVm,
142 }),
143 ];
144 const servedUrl = yield* Ref.get(servedEndpoint);
145 if (Option.isSome(servedUrl)) endpoints.push({ kind: "tailnet", httpBaseUrl: servedUrl.value });
146 return endpoints;
147 });
148​
149 const resolve = Effect.gen(function* () {
150 // Serve setup can fail without stopping startup; advertise only after it answers as this server.
151 if (config.tailscaleServeEnabled) {
152 const servedUrl = yield* readTailscaleStatus.pipe(
153 Effect.map((status) => status.magicDnsName),
154 Effect.flatMap((magicDnsName) => {
155 if (magicDnsName === null) return Effect.succeed(null);
156 const httpBaseUrl = buildTailscaleHttpsBaseUrl({
157 magicDnsName,
158 servePort: config.tailscaleServePort,
159 });
160 return probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl, environmentId }).pipe(
161 Effect.map((reachable) => (reachable ? httpBaseUrl : null)),
162 );
163 }),
164 Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT),
165 Effect.map(Option.flatMap(Option.fromNullishOr)),
166 Effect.orElseSucceed(() => Option.none<string>()),
167 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
168 Effect.provideService(HttpClient.HttpClient, httpClient),
169 );
170 yield* Ref.set(servedEndpoint, servedUrl);
171 }
172 return yield* resolveAvailable();
173 });
174​
175 return DirectEndpoints.of({
176 resolveAvailable,
177 resolve: Effect.fn("DirectEndpoints.resolve")(function () {
178 return resolve;
179 }),
180 });
181});
182​
183export const layer = Layer.effect(DirectEndpoints, make);