| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { it as effectIt } from "@effect/vitest"; |
| 3 | import type * as NodeOS from "node:os"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Sink from "effect/Sink"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import * as HttpClient from "effect/http/HttpClient"; |
| 9 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 10 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 11 | import { describe, expect, it } from "vite-plus/test"; |
| 12 | |
| 13 | import * as ServerConfig from "../config.ts"; |
| 14 | import * as DirectEndpoints from "./DirectEndpoints.ts"; |
| 15 | import { resolveBoundEndpoints } from "./DirectEndpoints.ts"; |
| 16 | |
| 17 | const 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. */ |
| 80 | const 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 | |
| 91 | const 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 | |
| 102 | describe("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 | |
| 170 | const 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. */ |
| 175 | const 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`. */ |
| 197 | const 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. */ |
| 206 | const 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 | |
| 219 | const 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 | |
| 230 | describe("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 | }); |