1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it as effectIt } from "@effect/vitest";
3import type * as NodeOS from "node:os";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientResponse from "effect/http/HttpClientResponse";
10import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
11import { describe, expect, it } from "vite-plus/test";
12
13import * as ServerConfig from "../config.ts";
14import * as DirectEndpoints from "./DirectEndpoints.ts";
15import { resolveBoundEndpoints } from "./DirectEndpoints.ts";
16
17const INTERFACES: ReturnType<typeof NodeOS.networkInterfaces> = {
18 lo0: [
19 {
20 address: "127.0.0.1",
21 netmask: "255.0.0.0",
22 family: "IPv4",
23 mac: "00:00:00:00:00:00",
24 internal: true,
25 cidr: "127.0.0.1/8",
26 },
27 ],
28 en0: [
29 {
30 address: "192.168.1.10",
31 netmask: "255.255.255.0",
32 family: "IPv4",
33 mac: "aa:bb:cc:dd:ee:ff",
34 internal: false,
35 cidr: "192.168.1.10/24",
36 },
37 {
38 address: "fe80::1",
39 netmask: "ffff:ffff:ffff:ffff::",
40 family: "IPv6",
41 mac: "aa:bb:cc:dd:ee:ff",
42 internal: false,
43 cidr: "fe80::1/64",
44 scopeid: 4,
45 },
46 ],
47 en1: [
48 {
49 address: "203.0.113.20",
50 netmask: "255.255.255.0",
51 family: "IPv4",
52 mac: "aa:bb:cc:dd:ee:00",
53 internal: false,
54 cidr: "203.0.113.20/24",
55 },
56 ],
57 // Tailscale on macOS: an anonymous utun that also carries a Tailscale IPv6 address.
58 utun4: [
59 {
60 address: "100.101.102.103",
61 netmask: "255.255.255.255",
62 family: "IPv4",
63 mac: "00:00:00:00:00:00",
64 internal: false,
65 cidr: "100.101.102.103/32",
66 },
67 {
68 address: "fd7a:115c:a1e0::1",
69 netmask: "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
70 family: "IPv6",
71 mac: "00:00:00:00:00:00",
72 internal: false,
73 cidr: "fd7a:115c:a1e0::1/128",
74 scopeid: 0,
75 },
76 ],
77};
78
79/** A VPN interface with an address from the same 100.64.0.0/10 range. */
80const vpnInterface = (address: string) => [
81 {
82 address,
83 netmask: "255.255.255.255",
84 family: "IPv4" as const,
85 mac: "00:00:00:00:00:00",
86 internal: false,
87 cidr: `${address}/32`,
88 },
89];
90
91const virtualInterface = (address: string) => [
92 {
93 address,
94 netmask: "255.255.0.0",
95 family: "IPv4" as const,
96 mac: "02:42:ac:11:00:01",
97 internal: false,
98 cidr: `${address}/16`,
99 },
100];
101
102describe("resolveBoundEndpoints", () => {
103 it("lists nothing for a loopback-only server", () => {
104 expect(resolveBoundEndpoints({ host: undefined, port: 3773, interfaces: INTERFACES })).toEqual(
105 [],
106 );
107 expect(
108 resolveBoundEndpoints({ host: "127.0.0.1", port: 3773, interfaces: INTERFACES }),
109 ).toEqual([]);
110 });
111
112 it("lists every external IPv4 address for a wildcard bind, tagging the tailnet one", () => {
113 expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces: INTERFACES })).toEqual([
114 { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
115 { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" },
116 ]);
117 });
118
119 it("skips container and VM networks, which only this machine reaches", () => {
120 const interfaces = {
121 ...INTERFACES,
122 docker0: virtualInterface("172.17.0.1"),
123 "br-3f2a1b": virtualInterface("172.18.0.1"),
124 virbr0: virtualInterface("192.168.122.1"),
125 "vEthernet (WSL)": virtualInterface("172.24.0.1"),
126 bridge100: virtualInterface("192.168.64.1"),
127 vmbr0: virtualInterface("192.168.1.20"),
128 };
129 expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces })).toEqual([
130 { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
131 { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" },
132 { kind: "lan", httpBaseUrl: "http://192.168.1.20:3773/" },
133 ]);
134 });
135
136 it("tags only addresses on Tailscale's interface as tailnet, not other VPNs in its range", () => {
137 const interfaces = {
138 en0: INTERFACES.en0,
139 tailscale0: vpnInterface("100.70.1.2"),
140 CloudflareWARP: vpnInterface("100.96.0.1"),
141 utun7: vpnInterface("100.85.0.1"),
142 };
143 expect(resolveBoundEndpoints({ host: "0.0.0.0", port: 3773, interfaces })).toEqual([
144 { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" },
145 { kind: "tailnet", httpBaseUrl: "http://100.70.1.2:3773/" },
146 { kind: "lan", httpBaseUrl: "http://100.96.0.1:3773/" },
147 { kind: "lan", httpBaseUrl: "http://100.85.0.1:3773/" },
148 ]);
149 });
150
151 it("lists only the bound address for a specific bind", () => {
152 expect(
153 resolveBoundEndpoints({ host: "100.101.102.103", port: 3773, interfaces: INTERFACES }),
154 ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }]);
155 });
156
157 it("never reports a host name, which can resolve to another machine per client", () => {
158 for (const host of ["server.local", "devbox", "devbox.home.arpa"]) {
159 expect(resolveBoundEndpoints({ host, port: 3773, interfaces: INTERFACES })).toEqual([]);
160 }
161 });
162
163 it("never reports a public address, which would carry the credential over plain HTTP", () => {
164 expect(
165 resolveBoundEndpoints({ host: "203.0.113.20", port: 3773, interfaces: INTERFACES }),
166 ).toEqual([]);
167 });
168});
169
170const TAILSCALE_STATUS_JSON = JSON.stringify({
171 Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },
172});
173
174/** `tailscale status --json` reporting a MagicDNS name. */
175const layerTailscaleUp = Layer.succeed(
176 ChildProcessSpawner.ChildProcessSpawner,
177 ChildProcessSpawner.make(() =>
178 Effect.succeed(
179 ChildProcessSpawner.makeHandle({
180 pid: ChildProcessSpawner.ProcessId(1),
181 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
182 isRunning: Effect.succeed(false),
183 kill: () => Effect.void,
184 unref: Effect.succeed(Effect.void),
185 stdin: Sink.drain,
186 stdout: Stream.make(new TextEncoder().encode(TAILSCALE_STATUS_JSON)),
187 stderr: Stream.empty,
188 all: Stream.empty,
189 getInputFd: () => Sink.drain,
190 getOutputFd: () => Stream.empty,
191 }),
192 ),
193 ),
194);
195
196/** Answers the Serve probe with `status`. */
197const layerServeProbe = (status: number) =>
198 Layer.succeed(
199 HttpClient.HttpClient,
200 HttpClient.make((request) =>
201 Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status }))),
202 ),
203 );
204
205/** A loopback-only server with Tailscale Serve on, so only the Serve name can be listed. */
206const layerServeConfig = Layer.effect(
207 ServerConfig.ServerConfig,
208 Effect.map(ServerConfig.ServerConfig, (config) => ({
209 ...config,
210 host: "127.0.0.1",
211 tailscaleServeEnabled: true,
212 tailscaleServePort: 443,
213 })),
214).pipe(
215 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-direct-" })),
216 Layer.provide(NodeServices.layer),
217);
218
219const resolveWithServe = (probeStatus: number) =>
220 Effect.flatMap(DirectEndpoints.DirectEndpoints, (service) => service.resolve()).pipe(
221 Effect.provide(
222 DirectEndpoints.layer.pipe(
223 Layer.provide(
224 Layer.mergeAll(layerServeConfig, layerTailscaleUp, layerServeProbe(probeStatus)),
225 ),
226 ),
227 ),
228 );
229
230describe("DirectEndpoints Tailscale Serve", () => {
231 effectIt.effect("lists the tailnet name once Serve answers for this server", () =>
232 Effect.gen(function* () {
233 expect(yield* resolveWithServe(200)).toEqual([
234 { kind: "tailnet", httpBaseUrl: "https://bb-1.tail1234.ts.net/" },
235 ]);
236 }),
237 );
238
239 effectIt.effect("leaves the tailnet name out when Serve is not forwarding", () =>
240 Effect.gen(function* () {
241 expect(yield* resolveWithServe(502)).toEqual([]);
242 }),
243 );
244});