Skip to content
Source
Sign inSign up

DirectEndpoints.test.ts

377 lines12.5 KB

1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { EnvironmentId } from "@t3tools/contracts";
3import { it as effectIt } from "@effect/vitest";
4import type * as NodeOS from "node:os";
5import * as Effect from "effect/Effect";
6import * as Deferred from "effect/Deferred";
7import * as Fiber from "effect/Fiber";
8import * as Ref from "effect/Ref";
9import * as Layer from "effect/Layer";
10import * as Sink from "effect/Sink";
11import * as Stream from "effect/Stream";
12import * as HttpClient from "effect/http/HttpClient";
13import type * as HttpClientRequest from "effect/http/HttpClientRequest";
14import * as HttpClientResponse from "effect/http/HttpClientResponse";
15import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
16import { describe, expect, it } from "vite-plus/test";
17​
18import * as ServerConfig from "../config.ts";
19import * as DirectEndpoints from "./DirectEndpoints.ts";
20import * as ServerEnvironment from "./ServerEnvironment.ts";
21import { resolveBoundEndpoints } from "./DirectEndpoints.ts";
22​
23const INTERFACES: ReturnType<typeof NodeOS.networkInterfaces> = {
24 lo0: [
25 {
26 address: "127.0.0.1",
27 netmask: "255.0.0.0",
28 family: "IPv4",
29 mac: "00:00:00:00:00:00",
30 internal: true,
31 cidr: "127.0.0.1/8",
32 },
33 ],
34 en0: [
35 {
36 address: "192.168.1.10",
37 netmask: "255.255.255.0",
38 family: "IPv4",
39 mac: "aa:bb:cc:dd:ee:ff",
40 internal: false,
41 cidr: "192.168.1.10/24",
42 },
43 {
44 address: "fe80::1",
45 netmask: "ffff:ffff:ffff:ffff::",
46 family: "IPv6",
47 mac: "aa:bb:cc:dd:ee:ff",
48 internal: false,
49 cidr: "fe80::1/64",
50 scopeid: 4,
51 },
52 ],
53 en1: [
54 {
55 address: "203.0.113.20",
56 netmask: "255.255.255.0",
57 family: "IPv4",
58 mac: "aa:bb:cc:dd:ee:00",
59 internal: false,
60 cidr: "203.0.113.20/24",
61 },
62 ],
63 utun4: [
64 {
65 address: "100.101.102.103",
66 netmask: "255.255.255.255",
67 family: "IPv4",
68 mac: "00:00:00:00:00:00",
69 internal: false,
70 cidr: "100.101.102.103/32",
71 },
72 {
73 address: "fd7a:115c:a1e0::1",
74 netmask: "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
75 family: "IPv6",
76 mac: "00:00:00:00:00:00",
77 internal: false,
78 cidr: "fd7a:115c:a1e0::1/128",
79 scopeid: 0,
80 },
81 ],
82};
83​
84const vpnInterface = (address: string) => [
85 {
86 address,
87 netmask: "255.255.255.255",
88 family: "IPv4" as const,
89 mac: "00:00:00:00:00:00",
90 internal: false,
91 cidr: `${address}/32`,
92 },
93];
94​
95const virtualInterface = (address: string, mac = "02:42:ac:11:00:01") => [
96 {
97 address,
98 netmask: "255.255.0.0",
99 family: "IPv4" as const,
100 mac,
101 internal: false,
102 cidr: `${address}/16`,
103 },
104];
105​
106describe("resolveBoundEndpoints", () => {
107 it("lists every external IPv4 address for a wildcard bind, tagging the tailnet one", () => {
108 expect(
109 resolveBoundEndpoints({
110 platform: "linux",
111 host: "0.0.0.0",
112 port: 3773,
113 interfaces: INTERFACES,
114 }),
115 ).toEqual([
116 { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
117 { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" },
118 ]);
119 });
120​
121 it("skips container and VM networks, which only this machine reaches", () => {
122 const interfaces = {
123 ...INTERFACES,
124 docker0: virtualInterface("172.17.0.1"),
125 "br-3f2a1b": virtualInterface("172.18.0.1"),
126 virbr0: virtualInterface("192.168.122.1"),
127 "vEthernet (WSL)": virtualInterface("172.24.0.1"),
128 bridge100: virtualInterface("192.168.64.1"),
129 vmbr0: virtualInterface("192.168.1.20"),
130 };
131 expect(
132 resolveBoundEndpoints({ platform: "linux", host: "0.0.0.0", port: 3773, interfaces }),
133 ).toEqual([
134 { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
135 { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" },
136 { kind: "lan", httpBaseUrl: "http://192.168.1.20:3773/" },
137 ]);
138 });
139​
140 it("omits Windows host-only adapters without excluding guest NICs or explicit binds", () => {
141 const interfaces = {
142 ...INTERFACES,
143 "VirtualBox-Host-Only-Netzwerk": virtualInterface("192.168.56.1", "0A:00:27:00:01:01"),
144 "Ethernet 2": virtualInterface("192.168.150.1", "00:50:56:c0:00:01"),
145 "Renamed network": virtualInterface("192.168.151.1", "00:50:56:c0:00:08"),
146 guest1: virtualInterface("192.168.1.30", "08:00:27:5e:1a:22"),
147 guest2: virtualInterface("192.168.1.31", "00:50:56:2b:8c:01"),
148 guest3: virtualInterface("192.168.1.32", "00:0c:29:4f:8e:35"),
149 guest4: virtualInterface("192.168.1.33", "00:50:56:80:8c:01"),
150 };
151 const input = { host: "0.0.0.0", port: 3773, interfaces };
152 const retained = [
153 { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
154 { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" },
155 ...[30, 31, 32, 33].map((last) => ({
156 kind: "lan",
157 httpBaseUrl: `http://192.168.1.${last}:3773/`,
158 })),
159 ];
160 expect(resolveBoundEndpoints({ ...input, platform: "win32" })).toEqual(retained);
161 for (const platform of ["linux", "darwin"] as const) {
162 expect(resolveBoundEndpoints({ ...input, platform })).toHaveLength(retained.length + 3);
163 }
164 for (const host of ["192.168.56.1", "192.168.150.1", "192.168.151.1"]) {
165 expect(resolveBoundEndpoints({ ...input, platform: "win32", host })).toEqual([
166 { kind: "lan", httpBaseUrl: `http://${host}:3773/` },
167 ]);
168 }
169 });
170​
171 it("tags only addresses on Tailscale's interface as tailnet, not other VPNs in its range", () => {
172 const interfaces = {
173 en0: INTERFACES.en0,
174 tailscale0: vpnInterface("100.70.1.2"),
175 CloudflareWARP: vpnInterface("100.96.0.1"),
176 utun7: vpnInterface("100.85.0.1"),
177 };
178 expect(
179 resolveBoundEndpoints({ platform: "linux", host: "0.0.0.0", port: 3773, interfaces }),
180 ).toEqual([
181 { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
182 { kind: "tailnet", httpBaseUrl: "http://100.70.1.2:3773/" },
183 { kind: "lan", httpBaseUrl: "http://100.96.0.1:3773/" },
184 { kind: "lan", httpBaseUrl: "http://100.85.0.1:3773/" },
185 ]);
186 });
187​
188 it("never reports a host name, which can resolve to another machine per client", () => {
189 for (const host of ["server.local", "devbox", "devbox.home.arpa"]) {
190 expect(
191 resolveBoundEndpoints({ platform: "linux", host, port: 3773, interfaces: INTERFACES }),
192 ).toEqual([]);
193 }
194 });
195​
196 it("never reports a public address, which would carry the credential over plain HTTP", () => {
197 expect(
198 resolveBoundEndpoints({
199 platform: "linux",
200 host: "203.0.113.20",
201 port: 3773,
202 interfaces: INTERFACES,
203 }),
204 ).toEqual([]);
205 });
206​
207 it("reports only the tailnet address on a cloud VM, whose private network no client shares", () => {
208 expect(
209 resolveBoundEndpoints({
210 platform: "linux",
211 host: "0.0.0.0",
212 port: 3773,
213 interfaces: INTERFACES,
214 onCloudVm: true,
215 }),
216 ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }]);
217 });
218​
219 it("never reports an IPv4-mapped host, which can name the client's own loopback", () => {
220 expect(
221 resolveBoundEndpoints({
222 platform: "linux",
223 host: "::ffff:127.0.0.1",
224 port: 3773,
225 interfaces: INTERFACES,
226 }),
227 ).toEqual([]);
228 });
229});
230​
231const TAILSCALE_STATUS_JSON = JSON.stringify({
232 Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },
233});
234​
235const layerTailscaleUp = Layer.succeed(
236 ChildProcessSpawner.ChildProcessSpawner,
237 ChildProcessSpawner.make(() =>
238 Effect.succeed(
239 ChildProcessSpawner.makeHandle({
240 pid: ChildProcessSpawner.ProcessId(1),
241 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
242 isRunning: Effect.succeed(false),
243 kill: () => Effect.void,
244 unref: Effect.succeed(Effect.void),
245 stdin: Sink.drain,
246 stdout: Stream.make(new TextEncoder().encode(TAILSCALE_STATUS_JSON)),
247 stderr: Stream.empty,
248 all: Stream.empty,
249 getInputFd: () => Sink.drain,
250 getOutputFd: () => Stream.empty,
251 }),
252 ),
253 ),
254);
255​
256const T3_DESCRIPTOR_BODY = JSON.stringify({
257 environmentId: "serve-test",
258 label: "Serve test",
259 platform: { os: "darwin", arch: "arm64" },
260 serverVersion: "0.0.0-test",
261 capabilities: { repositoryIdentity: true },
262});
263​
264const serveProbeResponse = (request: HttpClientRequest.HttpClientRequest, status: number) =>
265 HttpClientResponse.fromWeb(
266 request,
267 new Response(status === 200 ? T3_DESCRIPTOR_BODY : null, { status }),
268 );
269​
270const layerServeProbe = (status: number) =>
271 Layer.succeed(
272 HttpClient.HttpClient,
273 HttpClient.make((request) => Effect.succeed(serveProbeResponse(request, status))),
274 );
275​
276const layerServeConfig = (host = "127.0.0.1") =>
277 Layer.effect(
278 ServerConfig.ServerConfig,
279 Effect.map(ServerConfig.ServerConfig, (config) => ({
280 ...config,
281 host,
282 port: 3773,
283 tailscaleServeEnabled: true,
284 tailscaleServePort: 443,
285 })),
286 ).pipe(
287 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-direct-" })),
288 Layer.provide(NodeServices.layer),
289 Layer.merge(
290 Layer.succeed(ServerEnvironment.ServerEnvironment, {
291 getEnvironmentId: Effect.succeed(EnvironmentId.make("serve-test")),
292 getDescriptor: Effect.die("unused"),
293 }),
294 ),
295 );
296​
297const resolveWithServe = (probeStatus: number) =>
298 Effect.flatMap(DirectEndpoints.DirectEndpoints, (service) => service.resolve()).pipe(
299 Effect.provide(
300 DirectEndpoints.layer.pipe(
301 Layer.provide(
302 Layer.mergeAll(layerServeConfig(), layerTailscaleUp, layerServeProbe(probeStatus)),
303 ),
304 ),
305 ),
306 );
307​
308describe("DirectEndpoints Tailscale Serve", () => {
309 effectIt.effect("lists the tailnet name once Serve answers for this server", () =>
310 Effect.gen(function* () {
311 expect(yield* resolveWithServe(200)).toEqual([
312 { kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" },
313 ]);
314 }),
315 );
316​
317 effectIt.effect(
318 "returns bound endpoints during validation and reuses only a completed Serve result",
319 () =>
320 Effect.gen(function* () {
321 const status = yield* Ref.make(200);
322 const finishProbe = yield* Deferred.make<void>();
323 const probeStarted = yield* Deferred.make<void>();
324 const revalidationStarted = yield* Deferred.make<void>();
325 const finishRevalidation = yield* Deferred.make<void>();
326 const service = yield* DirectEndpoints.make.pipe(
327 Effect.provide(
328 Layer.mergeAll(
329 layerServeConfig("192.168.1.10"),
330 layerTailscaleUp,
331 Layer.succeed(
332 HttpClient.HttpClient,
333 HttpClient.make((request) =>
334 Deferred.succeed(probeStarted, undefined).pipe(
335 Effect.andThen(Deferred.await(finishProbe)),
336 Effect.andThen(Ref.get(status)),
337 Effect.tap((status) =>
338 status === 502
339 ? Deferred.succeed(revalidationStarted, undefined).pipe(
340 Effect.andThen(Deferred.await(finishRevalidation)),
341 )
342 : Effect.void,
343 ),
344 Effect.map((status) => serveProbeResponse(request, status)),
345 ),
346 ),
347 ),
348 ),
349 ),
350 );
351 const resolving = yield* Effect.forkScoped(service.resolve());
352 yield* Deferred.await(probeStarted);
353 const bound = { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" };
354 expect(yield* service.resolveAvailable()).toEqual([bound]);
355 yield* Deferred.succeed(finishProbe, undefined);
356 const validated = yield* Fiber.join(resolving);
357 expect(validated).toEqual([
358 bound,
359 { kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" },
360 ]);
361 expect(yield* service.resolveAvailable()).toEqual(validated);
362 yield* Ref.set(status, 502);
363 const revalidating = yield* Effect.forkScoped(service.resolve());
364 yield* Deferred.await(revalidationStarted);
365 expect(yield* service.resolveAvailable()).toEqual(validated);
366 yield* Deferred.succeed(finishRevalidation, undefined);
367 expect(yield* Fiber.join(revalidating)).toEqual([bound]);
368 expect(yield* service.resolveAvailable()).toEqual([bound]);
369 }),
370 );
371​
372 effectIt.effect("leaves the tailnet name out when Serve is not forwarding", () =>
373 Effect.gen(function* () {
374 expect(yield* resolveWithServe(502)).toEqual([]);
375 }),
376 );
377});