Skip to content
Source
Sign inSign up
Open History to see the last commit

startupAccess.ts

211 lines6.8 KB

1import * as NodeNet from "node:net";
2import * as NodeOS from "node:os";
3​
4import {
5 formatHostForUrl,
6 isPrivateNetworkHost,
7 isSharedAddressSpaceHost,
8 isWildcardHost,
9} from "@t3tools/shared/hostClassification";
10import { QrCode } from "@t3tools/shared/qrCode";
11import { buildPairingAppLink } from "@t3tools/shared/remote";
12import * as Effect from "effect/Effect";
13import { HttpServer } from "effect/http";
14import * as NetAddress from "effect/net/NetAddress";
15​
16import * as ServerConfig from "./config.ts";
17import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
18​
19export interface HeadlessServeAccessInfo {
20 readonly connectionString: string;
21 readonly token: string;
22 readonly pairingUrl: string;
24​
25type NetworkInterfacesMap = ReturnType<typeof NodeOS.networkInterfaces>;
26​
27export const isLoopbackHost = (host: string | undefined): boolean => {
28 if (!host || host.length === 0) {
29 return true;
30 }
31​
32 return (
33 host === "localhost" ||
34 host === "127.0.0.1" ||
35 host === "::1" ||
36 host === "[::1]" ||
37 host.startsWith("127.")
38 );
39};
40​
41/**
42 * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and
43 * macOS's `bridge100`+) have private addresses only this machine reaches. A
44 * host bridge such as `br0` or Proxmox's `vmbr0` carries the LAN address, so
45 * it stays listed.
46 */
47export const VIRTUAL_INTERFACE =
48 /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge[1-9]\d\d)/;
49​
50/**
51 * Loopback or private-range IPv4 (LAN, tailnet, CGNAT), not link-local, in
52 * plain or IPv4-mapped IPv6 form.
53 */
54export const isPrivateNetworkAddress = (address: string): boolean => {
55 const ipv4 = address.replace(/^::ffff:/i, "");
56 return NodeNet.isIPv4(ipv4) && !ipv4.startsWith("169.254.") && isPrivateNetworkHost(ipv4);
57};
58​
59/**
60 * A private-network listener binds every interface but serves only
61 * connections that stay on loopback, a LAN, or the tailnet. Both ends must be
62 * private: a cloud VM or a router port forward hands public clients to a
63 * private local address.
64 */
65export const acceptsPrivateNetworkConnection = (socket: {
66 readonly localAddress?: string | undefined;
67 readonly remoteAddress?: string | undefined;
68}): boolean =>
69 [socket.localAddress, socket.remoteAddress].every(
70 (address) => address !== undefined && isPrivateNetworkAddress(address),
71 );
72​
73export const loopbackUrlHostForAddress = (address: NetAddress.InetAddress): string =>
74 NetAddress.isUnspecified(address.address)
75 ? "127.0.0.1"
76 : NetAddress.formatUrlHostString(NetAddress.formatIp(address.address));
77​
78const normalizeHost = (host: string): string =>
79 host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
80​
81const isIpv4Family = (family: string | number): boolean => family === "IPv4" || family === 4;
82​
83const isIpv6Family = (family: string | number): boolean => family === "IPv6" || family === 6;
84​
85const resolveHeadlessConnectionHost = (
86 host: string | undefined,
87 interfaces: NetworkInterfacesMap,
88 privateNetworkOnly: boolean,
89): string => {
90 if (!host) {
91 return "localhost";
92 }
93​
94 if (!isWildcardHost(host)) {
95 return normalizeHost(host);
96 }
97​
98 const interfaceEntries = Object.entries(interfaces).flatMap(([name, entries]) =>
99 privateNetworkOnly && VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? []),
100 );
101 const externalIpv4Entries = interfaceEntries.filter(
102 (entry) =>
103 !entry.internal &&
104 isIpv4Family(entry.family) &&
105 (!privateNetworkOnly || isPrivateNetworkAddress(entry.address)),
106 );
107 // A tailnet or other CGNAT address reaches fewer devices than the LAN one.
108 const externalIpv4 = privateNetworkOnly
109 ? (externalIpv4Entries.find((entry) => !isSharedAddressSpaceHost(entry.address)) ??
110 externalIpv4Entries[0])
111 : externalIpv4Entries[0];
112 if (externalIpv4) {
113 return externalIpv4.address;
114 }
115 if (privateNetworkOnly) {
116 return "localhost";
117 }
118​
119 const externalIpv6 = interfaceEntries.find(
120 (entry) => !entry.internal && isIpv6Family(entry.family),
121 );
122 return externalIpv6 ? normalizeHost(externalIpv6.address) : "localhost";
123};
124​
125export const resolveHeadlessConnectionString = (
126 host: string | undefined,
127 port: number,
128 options: {
129 readonly interfaces?: NetworkInterfacesMap;
130 readonly privateNetworkOnly?: boolean | undefined;
131 } = {},
132): string => {
133 const connectionHost = resolveHeadlessConnectionHost(
134 host,
135 options.interfaces ?? NodeOS.networkInterfaces(),
136 options.privateNetworkOnly === true,
137 );
138 return `http://${formatHostForUrl(connectionHost)}:${port}`;
139};
140​
141const resolveListeningPort = (address: unknown, fallbackPort: number): number => {
142 if (
143 typeof address === "object" &&
144 address !== null &&
145 "port" in address &&
146 typeof address.port === "number"
147 ) {
148 return address.port;
149 }
150 return fallbackPort;
151};
152​
153export const buildPairingUrl = (connectionString: string, token: string): string => {
154 const url = new URL(connectionString);
155 url.pathname = "/pair";
156 url.searchParams.delete("token");
157 url.hash = new URLSearchParams([["token", token]]).toString();
158 return url.toString();
159};
160​
161export const renderTerminalQrCode = (value: string, margin = 2): string => {
162 const qrCode = QrCode.encodeText(value, QrCode.Ecc.MEDIUM);
163 const rows: Array<string> = [];
164 const isDark = (x: number, y: number): boolean =>
165 x >= 0 && x < qrCode.size && y >= 0 && y < qrCode.size && qrCode.getModule(x, y);
166​
167 for (let y = -margin; y < qrCode.size + margin; y += 2) {
168 let row = "";
169​
170 for (let x = -margin; x < qrCode.size + margin; x += 1) {
171 const topDark = isDark(x, y);
172 const bottomDark = isDark(x, y + 1);
173​
174 row += topDark ? (bottomDark ? "█" : "▀") : bottomDark ? "▄" : " ";
175 }
176​
177 rows.push(row);
178 }
179​
180 return rows.join("\n");
181};
182​
183export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): string =>
184 [
185 "T3 Code server is ready.",
186 `Connection string: ${accessInfo.connectionString}`,
187 `Token: ${accessInfo.token}`,
188 `Pairing URL: ${accessInfo.pairingUrl}`,
189 `App link: ${buildPairingAppLink(accessInfo.pairingUrl)}`,
190 "",
191 renderTerminalQrCode(accessInfo.pairingUrl),
192 "",
193 ].join("\n");
194​
195export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () {
196 const serverConfig = yield* ServerConfig.ServerConfig;
197 const httpServer = yield* HttpServer.HttpServer;
198 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
199 const connectionString = resolveHeadlessConnectionString(
200 serverConfig.host,
201 resolveListeningPort(httpServer.address, serverConfig.port),
202 { privateNetworkOnly: serverConfig.privateNetworkOnly },
203 );
204 const issued = yield* serverAuth.issueStartupPairingCredential();
205​
206 return {
207 connectionString,
208 token: issued.credential,
209 pairingUrl: buildPairingUrl(connectionString, issued.credential),
210 } satisfies HeadlessServeAccessInfo;
211});