| 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 | */ |
| 10 | import type { ServerDirectEndpoint } from "@t3tools/contracts"; |
| 11 | import { |
| 12 | buildTailscaleHttpsBaseUrl, |
| 13 | probeTailscaleHttpsEndpoint, |
| 14 | readTailscaleStatus, |
| 15 | } from "@t3tools/tailscale"; |
| 16 | import * as Context from "effect/Context"; |
| 17 | import * as Duration from "effect/Duration"; |
| 18 | import * as Effect from "effect/Effect"; |
| 19 | import * as Layer from "effect/Layer"; |
| 20 | import * as Option from "effect/Option"; |
| 21 | import { HttpClient } from "effect/http"; |
| 22 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 23 | import * as NodeNet from "node:net"; |
| 24 | import * as NodeOS from "node:os"; |
| 25 | |
| 26 | import { isPrivateNetworkHost, isTailnetHost } from "@t3tools/shared/hostClassification"; |
| 27 | |
| 28 | import * as ServerConfig from "../config.ts"; |
| 29 | import { formatHostForUrl, isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; |
| 30 | |
| 31 | type 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 | */ |
| 38 | const TAILSCALE_ENDPOINT_TIMEOUT = Duration.seconds(3); |
| 39 | |
| 40 | export 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 | */ |
| 54 | const 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 | */ |
| 66 | const 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 | */ |
| 75 | const 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 | */ |
| 87 | export 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. */ |
| 114 | export 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 | |
| 161 | export const layer = Layer.effect(DirectEndpoints, make); |