1import {
2 isLocalLoopbackHost,
3 isPrivateNetworkHost,
4 isSharedAddressSpaceHost,
5 isTailnetHost,
6} from "@t3tools/shared/hostClassification";
7import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts";
8import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
9import * as Option from "effect/Option";
10
11import {
12 BearerConnectionProfile,
13 type ConnectionCatalogEntry,
14 type ConnectionRoute,
15} from "./catalog.ts";
16import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts";
17
18/**
19 * A saved environment can hold several routes: T3 Connect, direct URLs (LAN,
20 * tailnet, other VPNs, public), and SSH. The client connects over the first route in
21 * preference order that answers as the expected environment, and moves back
22 * to a better one when it becomes reachable again.
23 */
24
25export type ConnectionRouteKind =
26 | "relay"
27 | "loopback"
28 | "lan"
29 | "tailnet"
30 | "vpn"
31 | "public"
32 | "ssh";
33
34/** An environment has at most one T3 Connect route, so it needs no per-route id. */
35export const RELAY_ROUTE_ID = "relay";
36
37export function connectionRouteId(target: ConnectionTarget): string {
38 switch (target._tag) {
39 case "PrimaryConnectionTarget":
40 return "primary";
41 case "RelayConnectionTarget":
42 return RELAY_ROUTE_ID;
43 case "BearerConnectionTarget":
44 case "SshConnectionTarget":
45 return target.connectionId;
46 }
47}
48
49/** Every route of an entry, preferred first. */
50export function connectionRoutes(entry: ConnectionCatalogEntry): ReadonlyArray<ConnectionRoute> {
51 return [{ target: entry.target, profile: entry.profile }, ...(entry.alternateRoutes ?? [])];
52}
53
54/** Builds an entry whose preferred route is the first of `routes`, which must not be empty. */
55export function entryWithRoutes(
56 entry: ConnectionCatalogEntry,
57 routes: ReadonlyArray<ConnectionRoute>,
58): ConnectionCatalogEntry {
59 const [first, ...rest] = routes;
60 if (first === undefined) {
61 throw new Error("A saved environment needs at least one route.");
62 }
63 const { alternateRoutes: _previous, ...base } = entry;
64 return {
65 ...base,
66 target: first.target,
67 profile: first.profile,
68 ...(rest.length === 0 ? {} : { alternateRoutes: rest }),
69 };
70}
71
72/** The entry a single route connects with. */
73export function routeEntry(
74 entry: ConnectionCatalogEntry,
75 route: ConnectionRoute,
76): ConnectionCatalogEntry {
77 return entryWithRoutes(entry, [route]);
78}
79
80/** The base URL of a direct route, or null for T3 Connect and SSH. */
81export function routeHttpBaseUrl(route: ConnectionRoute): string | null {
82 if (route.target._tag === "PrimaryConnectionTarget") return route.target.httpBaseUrl;
83 const profile = Option.getOrNull(route.profile);
84 return profile?._tag === "BearerConnectionProfile" ? profile.httpBaseUrl : null;
85}
86
87function routeHostname(route: ConnectionRoute): string | null {
88 const httpBaseUrl = routeHttpBaseUrl(route);
89 if (httpBaseUrl === null) return null;
90 try {
91 return new URL(httpBaseUrl).hostname;
92 } catch {
93 return null;
94 }
95}
96
97export function connectionRouteKind(route: ConnectionRoute): ConnectionRouteKind {
98 switch (route.target._tag) {
99 case "RelayConnectionTarget":
100 return "relay";
101 case "SshConnectionTarget":
102 return "ssh";
103 case "PrimaryConnectionTarget":
104 case "BearerConnectionTarget": {
105 const hostname = routeHostname(route);
106 if (hostname === null) return "public";
107 if (isLocalLoopbackHost(hostname)) return "loopback";
108 const profile = Option.getOrNull(route.profile);
109 const tailscale =
110 profile?._tag === "BearerConnectionProfile" && profile.network === "tailscale";
111 if (tailscale || isTailnetHost(hostname)) return "tailnet";
112 if (isSharedAddressSpaceHost(hostname)) return "vpn";
113 return isPrivateNetworkHost(hostname) ? "lan" : "public";
114 }
115 }
116}
117
118const ROUTE_KIND_RANK: Record<ConnectionRouteKind, number> = {
119 loopback: 0,
120 lan: 1,
121 tailnet: 2,
122 vpn: 2,
123 public: 3,
124 ssh: 4,
125 relay: 5,
126};
127
128/**
129 * Where a newly added route goes: after every saved route of the same or a
130 * faster kind, so LAN lands ahead of tailnet and both ahead of T3 Connect.
131 * Users can reorder afterwards; this only picks a sensible starting point.
132 */
133export function insertRoute(
134 routes: ReadonlyArray<ConnectionRoute>,
135 route: ConnectionRoute,
136): ReadonlyArray<ConnectionRoute> {
137 const rank = ROUTE_KIND_RANK[connectionRouteKind(route)];
138 const index = routes.findIndex(
139 (existing) => ROUTE_KIND_RANK[connectionRouteKind(existing)] > rank,
140 );
141 return index === -1
142 ? [...routes, route]
143 : [...routes.slice(0, index), route, ...routes.slice(index)];
144}
145
146/** Replaces the route with the same id in place, or inserts it by kind. */
147export function upsertRoute(
148 routes: ReadonlyArray<ConnectionRoute>,
149 route: ConnectionRoute,
150): ReadonlyArray<ConnectionRoute> {
151 const id = connectionRouteId(route.target);
152 return routes.some((existing) => connectionRouteId(existing.target) === id)
153 ? routes.map((existing) => (connectionRouteId(existing.target) === id ? route : existing))
154 : insertRoute(routes, route);
155}
156
157/**
158 * A saved route that reaches the same address as `route`: the same bearer
159 * URL, or the same SSH target (alias, host, user, and port, as desktop keys
160 * its tunnels). Registering it again replaces that route, even when it was
161 * saved under another id.
162 */
163export function findRouteToSameAddress(
164 routes: ReadonlyArray<ConnectionRoute>,
165 route: ConnectionRoute,
166): ConnectionRoute | undefined {
167 const key = routeAddressKey(route);
168 return key === null ? undefined : routes.find((existing) => routeAddressKey(existing) === key);
169}
170
171function routeAddressKey(route: ConnectionRoute): string | null {
172 const profile = Option.getOrNull(route.profile);
173 switch (profile?._tag) {
174 case "BearerConnectionProfile":
175 return `bearer:${profile.httpBaseUrl.replace(/\/+$/, "")}`;
176 case "SshConnectionProfile":
177 return `ssh:${sshTargetKey(profile.target)}`;
178 default:
179 return null;
180 }
181}
182
183/** Short user-facing route description: "LAN", "Tailscale", "VPN", "T3 Connect", a URL, or an SSH host. */
184export function connectionRouteLabel(route: ConnectionRoute): string {
185 switch (connectionRouteKind(route)) {
186 case "relay":
187 return "T3 Connect";
188 case "loopback":
189 return "This device";
190 case "lan":
191 return "LAN";
192 case "tailnet":
193 return "Tailscale";
194 case "vpn":
195 return "VPN";
196 case "ssh": {
197 const profile = Option.getOrNull(route.profile);
198 return profile?._tag === "SshConnectionProfile"
199 ? `SSH ${profile.target.username ? `${profile.target.username}@` : ""}${profile.target.hostname}`
200 : "SSH";
201 }
202 case "public":
203 return routeHostname(route) ?? "Remote link";
204 }
205}
206
207/** The address shown under a route, or null for T3 Connect. */
208export function connectionRouteAddress(route: ConnectionRoute): string | null {
209 if (route.target._tag === "SshConnectionTarget") {
210 const profile = Option.getOrNull(route.profile);
211 return profile?._tag === "SshConnectionProfile" ? profile.target.alias : null;
212 }
213 return routeHttpBaseUrl(route);
214}
215
216/** Whether the environment can be reached through T3 Connect. */
217export function hasRelayRoute(
218 entry: Pick<ConnectionCatalogEntry, "target" | "alternateRoutes">,
219): boolean {
220 return [entry.target, ...(entry.alternateRoutes ?? []).map((route) => route.target)].some(
221 (target) => target._tag === "RelayConnectionTarget",
222 );
223}
224
225/**
226 * The routes after the server reports where it listens. Each newly reported
227 * address becomes a learned route that authenticates the same way as the route
228 * in use: the paired token for a bearer route, the T3 Connect credential for
229 * relay. A learned route the server still reports keeps its place, so the
230 * user's order holds; one it no longer reports is dropped, so a changed LAN
231 * address replaces the old one. Routes the user saved are never removed or
232 * moved, and an address already saved is not learned twice. Any direct route
233 * the server reports is marked as Tailscale while the server finds it on its
234 * Tailscale interface, so a route paired by its numeric Tailscale address keeps
235 * that label.
236 */
237export function mergeLearnedRoutes(input: {
238 readonly entry: ConnectionCatalogEntry;
239 readonly activeRoute: ConnectionRoute;
240 readonly reported: ReadonlyArray<ReportedEndpoint>;
241 /** Plain HTTP routes are unusable from an HTTPS page (mixed content). */
242 readonly allowInsecure: boolean;
243}): ReadonlyArray<ConnectionRoute> | null {
244 const { entry } = input;
245 const active = input.activeRoute.target;
246 const saved = connectionRoutes(entry);
247 // The primary and SSH routes have no credential a learned route could reuse.
248 if (active._tag !== "RelayConnectionTarget" && active._tag !== "BearerConnectionTarget") {
249 return null;
250 }
251 // A route learned over another learned route inherits what that one uses:
252 // the T3 Connect credential, or the paired token it borrows.
253 const activeProfile = Option.getOrNull(input.activeRoute.profile);
254 const authorization =
255 active._tag === "RelayConnectionTarget" ||
256 (activeProfile?._tag === "BearerConnectionProfile" &&
257 activeProfile.authorization === "t3-connect")
258 ? ("t3-connect" as const)
259 : undefined;
260 const sharedCredential =
261 authorization === undefined && active._tag === "BearerConnectionTarget"
262 ? credentialConnectionId(active.connectionId)
263 : undefined;
264
265 // Usable reported environment bases.
266 const reported = new Map<string, URL>();
267 const tailscaleOrigins = new Set<string>();
268 for (const endpoint of input.reported) {
269 let url: URL;
270 try {
271 url = new URL(normalizeHttpBaseUrl(endpoint.httpBaseUrl));
272 } catch {
273 continue;
274 }
275 if (url.protocol !== "http:" && url.protocol !== "https:") continue;
276 if (url.protocol === "http:" && !input.allowInsecure) continue;
277 // A loopback address names whichever device opens it, never the server.
278 if (isLocalLoopbackHost(url.hostname)) continue;
279 const address = url.href.replace(/\/+$/, "");
280 reported.set(address, url);
281 if (endpoint.kind === "tailnet") tailscaleOrigins.add(address);
282 }
283 const normalized = (url: string) => url.replace(/\/+$/, "");
284 const known = new Set(
285 saved.flatMap((route) => {
286 const url = routeHttpBaseUrl(route);
287 return url === null || isLearned(route) ? [] : [normalized(url)];
288 }),
289 );
290 const kept = saved.flatMap((route): ReadonlyArray<ConnectionRoute> => {
291 const url = routeHttpBaseUrl(route);
292 if (!isLearned(route)) {
293 return [
294 url === null || !reported.has(normalized(url))
295 ? route
296 : withNetwork(route, tailscaleOrigins.has(normalized(url))),
297 ];
298 }
299 if (url === null || !reported.has(normalized(url)) || known.has(normalized(url))) return [];
300 known.add(normalized(url));
301 return [withNetwork(route, tailscaleOrigins.has(normalized(url)))];
302 });
303 let next: ReadonlyArray<ConnectionRoute> = kept;
304 for (const url of reported.values()) {
305 const httpBaseUrl = url.href;
306 const address = httpBaseUrl.replace(/\/+$/, "");
307 if (known.has(address)) continue;
308 const connectionId = learnedConnectionId(
309 entry.target.environmentId,
310 address,
311 sharedCredential,
312 );
313 next = insertRoute(next, {
314 target: new BearerConnectionTarget({
315 environmentId: entry.target.environmentId,
316 label: entry.target.label,
317 connectionId,
318 }),
319 profile: Option.some(
320 new BearerConnectionProfile({
321 connectionId,
322 environmentId: entry.target.environmentId,
323 label: entry.target.label,
324 httpBaseUrl,
325 wsBaseUrl: deriveWsBaseUrl(httpBaseUrl),
326 learned: true,
327 ...(authorization === undefined ? {} : { authorization }),
328 ...(tailscaleOrigins.has(address) ? { network: "tailscale" as const } : {}),
329 }),
330 ),
331 });
332 }
333 // Compare addresses too: a scheme or port change keeps no id stable.
334 const signature = (routes: ReadonlyArray<ConnectionRoute>) =>
335 routes
336 .map(
337 (route) =>
338 `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""} ${connectionRouteKind(route)}`,
339 )
340 .join("\n");
341 return signature(saved) === signature(next) ? null : next;
342}
343
344/** A direct address the server reports, as `ServerDirectEndpoint`. */
345export interface ReportedEndpoint {
346 readonly httpBaseUrl: string;
347 readonly kind?: string;
348}
349
350/** The direct route with its Tailscale mark set or cleared; the same route when unchanged. */
351function withNetwork(route: ConnectionRoute, tailscale: boolean): ConnectionRoute {
352 const profile = Option.getOrNull(route.profile);
353 if (profile?._tag !== "BearerConnectionProfile") return route;
354 if ((profile.network === "tailscale") === tailscale) return route;
355 return {
356 target: route.target,
357 profile: Option.some(
358 new BearerConnectionProfile({
359 connectionId: profile.connectionId,
360 environmentId: profile.environmentId,
361 label: profile.label,
362 httpBaseUrl: profile.httpBaseUrl,
363 wsBaseUrl: profile.wsBaseUrl,
364 ...(profile.learned === undefined ? {} : { learned: profile.learned }),
365 ...(profile.authorization === undefined ? {} : { authorization: profile.authorization }),
366 ...(tailscale ? { network: "tailscale" as const } : {}),
367 }),
368 ),
369 };
370}
371
372/**
373 * A learned bearer route borrows the credential of the route it was learned
374 * from, so its id points back at that credential's owner.
375 */
376function learnedConnectionId(
377 environmentId: string,
378 origin: string,
379 sharedCredential: string | undefined,
380): string {
381 return sharedCredential === undefined
382 ? `learned:${environmentId}:${origin}`
383 : `learned:${environmentId}:${origin}@${sharedCredential}`;
384}
385
386/** The connection id whose stored credential a bearer route uses. */
387export function credentialConnectionId(connectionId: string): string {
388 // The borrowed id follows the first "@"; neither an environment id nor an
389 // origin contains one.
390 const at = connectionId.indexOf("@");
391 return connectionId.startsWith("learned:") && at !== -1
392 ? connectionId.slice(at + 1)
393 : connectionId;
394}
395
396export function isLearned(route: ConnectionRoute): boolean {
397 const profile = Option.getOrNull(route.profile);
398 return profile?._tag === "BearerConnectionProfile" && profile.learned === true;
399}
400
401/**
402 * Routes left after the user removes one. A learned route borrows the
403 * credential of the route it was learned over, so it cannot outlive that
404 * route: removing T3 Connect also removes routes learned through it, and
405 * removing a paired address removes routes that borrow its token.
406 */
407export function routesAfterRemoving(
408 routes: ReadonlyArray<ConnectionRoute>,
409 removedId: string,
410): ReadonlyArray<ConnectionRoute> {
411 const removed = routes.find((route) => connectionRouteId(route.target) === removedId);
412 if (removed === undefined) return routes;
413 return routes.filter((route) => {
414 if (route === removed) return false;
415 if (!isLearned(route)) return true;
416 const profile = Option.getOrNull(route.profile);
417 if (profile?._tag === "BearerConnectionProfile" && profile.authorization === "t3-connect") {
418 return removed.target._tag !== "RelayConnectionTarget";
419 }
420 return credentialConnectionId(connectionRouteId(route.target)) !== removedId;
421 });
422}
423
424/**
425 * Whether removing T3 Connect leaves the environment no route, so signing out
426 * removes it entirely. Routes learned through T3 Connect go with it.
427 */
428export function removedWithRelay(entry: ConnectionCatalogEntry): boolean {
429 return routesAfterRemoving(connectionRoutes(entry), RELAY_ROUTE_ID).length === 0;
430}
431
432/** One SSH target, as desktop keys its tunnels: alias, host, user, and port. */
433export function sshTargetKey(target: DesktopSshEnvironmentTarget): string {
434 return JSON.stringify([target.alias, target.hostname, target.username, target.port]);
435}