1/**
2 * DirectEndpoints - the LAN and tailnet addresses this server listens on now.
3 *
4 * Clients connected one way (often T3 Connect) save these as extra routes so
5 * they can move to a faster path when one is reachable, and replace a saved
6 * LAN address when DHCP or a new Wi-Fi network changes it. Only addresses the
7 * server is actually bound to are listed: a loopback-only server lists none,
8 * because its loopback address means a different machine to every client.
9 */
10import type { ServerDirectEndpoint } from "@t3tools/contracts";
11import {
12 buildTailscaleHttpsBaseUrl,
13 probeTailscaleHttpsEndpoint,
14 readTailscaleStatus,
15} from "@t3tools/tailscale";
16import * as Context from "effect/Context";
17import * as Duration from "effect/Duration";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import { HttpClient } from "effect/http";
22import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
23import * as NodeNet from "node:net";
24import * as NodeOS from "node:os";
25
26import { isPrivateNetworkHost, isTailnetHost } from "@t3tools/shared/hostClassification";
27
28import * as ServerConfig from "../config.ts";
29import { formatHostForUrl, isLoopbackHost, isWildcardHost } from "../startupAccess.ts";
30
31type NetworkInterfacesMap = ReturnType<typeof NodeOS.networkInterfaces>;
32
33/**
34 * Stays under the config read's own discovery timeout, so a slow `tailscale`
35 * CLI drops only the tailnet name and never the LAN addresses. An empty list
36 * would make clients forget the routes they learned.
37 */
38const TAILSCALE_ENDPOINT_TIMEOUT = Duration.seconds(3);
39
40export class DirectEndpoints extends Context.Service<
41 DirectEndpoints,
42 {
43 readonly resolve: () => Effect.Effect<ReadonlyArray<ServerDirectEndpoint>>;
44 }
45>()("t3/environment/DirectEndpoints") {}
46
47/**
48 * Only numeric private-network IPv4 addresses, including VPN ranges such as
49 * 100.64.0.0/10, are reported. These routes are plain HTTP and carry the
50 * client's credential, so a public address would send it across the internet
51 * unencrypted, and a name (`server.local`) can resolve to a different machine
52 * on each client's network.
53 */
54const isAdvertisableAddress = (address: string): boolean =>
55 NodeNet.isIPv4(address) &&
56 !address.startsWith("127.") &&
57 !address.startsWith("169.254.") &&
58 isPrivateNetworkHost(address);
59
60/**
61 * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and
62 * macOS's `bridge100`+) have private addresses only this machine reaches. A
63 * host bridge such as `br0` or Proxmox's `vmbr0` carries the LAN address, so
64 * it stays listed.
65 */
66const VIRTUAL_INTERFACE =
67 /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge1\d\d)/;
68
69/**
70 * Tailscale's own interface: `tailscale0` on Linux, "Tailscale" on Windows.
71 * macOS names every tunnel `utunN`, so there it is the one that also carries
72 * an address in Tailscale's IPv6 range. Cloudflare WARP and other VPNs assign
73 * from the same IPv4 range, so the IPv4 address alone proves nothing.
74 */
75const isTailscaleInterface = (
76 name: string,
77 entries: ReadonlyArray<NodeOS.NetworkInterfaceInfo>,
78): boolean =>
79 /^tailscale/i.test(name) ||
80 entries.some((entry) => entry.family === "IPv6" && isTailnetHost(entry.address));
81
82/**
83 * Plain HTTP endpoints for the private addresses a server bound to `host`
84 * accepts. IPv4 only: link-local and temporary IPv6 addresses change too
85 * often to be worth saving.
86 */
87export function resolveBoundEndpoints(input: {
88 readonly host: string | undefined;
89 readonly port: number;
90 readonly interfaces: NetworkInterfacesMap;
91}): ReadonlyArray<ServerDirectEndpoint> {
92 if (isLoopbackHost(input.host)) return [];
93 const tailscaleAddresses = new Set(
94 Object.entries(input.interfaces).flatMap(([name, entries = []]) =>
95 isTailscaleInterface(name, entries) ? entries.map((entry) => entry.address) : [],
96 ),
97 );
98 const addresses = isWildcardHost(input.host)
99 ? Object.entries(input.interfaces)
100 .flatMap(([name, entries]) => (VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? [])))
101 .filter(
102 (entry) =>
103 !entry.internal && entry.family === "IPv4" && isAdvertisableAddress(entry.address),
104 )
105 .map((entry) => entry.address)
106 : [input.host!].filter(isAdvertisableAddress);
107 return [...new Set(addresses)].map((address) => ({
108 kind: tailscaleAddresses.has(address) ? "tailnet" : "lan",
109 httpBaseUrl: `http://${formatHostForUrl(address)}:${input.port}/`,
110 }));
111}
112
113/** @public Service construction is part of the canonical Effect module API. */
114export const make = Effect.gen(function* () {
115 const config = yield* ServerConfig.ServerConfig;
116 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
117 const httpClient = yield* HttpClient.HttpClient;
118
119 const resolve = Effect.gen(function* () {
120 const endpoints = [
121 ...resolveBoundEndpoints({
122 host: config.host,
123 port: config.port,
124 interfaces: NodeOS.networkInterfaces(),
125 }),
126 ];
127 // Tailscale Serve terminates HTTPS on the tailnet name and forwards to
128 // loopback, so it works even for a loopback-only server. The name is only
129 // listed once it answers as this server: Serve setup can fail without
130 // stopping startup. Anything slower than the probe leaves it out rather
131 // than holding back the addresses already found.
132 if (config.tailscaleServeEnabled) {
133 const servedUrl = yield* readTailscaleStatus.pipe(
134 Effect.map((status) => status.magicDnsName),
135 Effect.flatMap((magicDnsName) => {
136 if (magicDnsName === null) return Effect.succeed(null);
137 const httpBaseUrl = buildTailscaleHttpsBaseUrl({
138 magicDnsName,
139 servePort: config.tailscaleServePort,
140 });
141 return probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe(
142 Effect.map((reachable) => (reachable ? httpBaseUrl : null)),
143 );
144 }),
145 Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT),
146 Effect.map(Option.flatMap(Option.fromNullishOr)),
147 Effect.orElseSucceed(() => Option.none<string>()),
148 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
149 Effect.provideService(HttpClient.HttpClient, httpClient),
150 );
151 if (Option.isSome(servedUrl)) {
152 endpoints.push({ kind: "tailnet", httpBaseUrl: servedUrl.value });
153 }
154 }
155 return endpoints;
156 });
157
158 return DirectEndpoints.of({ resolve: () => resolve });
159});
160
161export const layer = Layer.effect(DirectEndpoints, make);