Open History to see the last commit
1import {
2 isLocalLoopbackHost,
3 isPrivateNetworkHost,
4 isSharedAddressSpaceHost,
5 isTailnetHost,
6} from "@t3tools/shared/hostClassification";
7import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
8import * as Option from "effect/Option";
9
10import {
11 BearerConnectionProfile,
12 type ConnectionCatalogEntry,
13 type ConnectionRoute,
14} from "./catalog.ts";
15import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts";
16
17export type ConnectionRouteKind =
18 | "relay"
19 | "loopback"
20 | "lan"
21 | "tailnet"
22 | "vpn"
23 | "public"
24 | "ssh";
25
26export const RELAY_ROUTE_ID = "relay";
27
28export function connectionRouteId(target: ConnectionTarget): string {
29 switch (target._tag) {
30 case "PrimaryConnectionTarget":
31 return "primary";
32 case "RelayConnectionTarget":
33 return RELAY_ROUTE_ID;
34 case "BearerConnectionTarget":
35 case "SshConnectionTarget":
36 return target.connectionId;
37 }
38}
39
40export function connectionRoutes(entry: ConnectionCatalogEntry): ReadonlyArray<ConnectionRoute> {
41 return [{ target: entry.target, profile: entry.profile }, ...(entry.alternateRoutes ?? [])];
42}
43
44export function entryWithRoutes(
45 entry: ConnectionCatalogEntry,
46 routes: ReadonlyArray<ConnectionRoute>,
47): ConnectionCatalogEntry {
48 const [first, ...rest] = routes;
49 if (first === undefined) {
50 throw new Error("A saved environment needs at least one route.");
51 }
52 const { alternateRoutes: _previous, ...base } = entry;
53 return {
54 ...base,
55 target: first.target,
56 profile: first.profile,
57 ...(rest.length === 0 ? {} : { alternateRoutes: rest }),
58 };
59}
60
61export function routeEntry(
62 entry: ConnectionCatalogEntry,
63 route: ConnectionRoute,
64): ConnectionCatalogEntry {
65 return entryWithRoutes(entry, [route]);
66}
67
68export function routeHttpBaseUrl(route: ConnectionRoute): string | null {
69 if (route.target._tag === "PrimaryConnectionTarget") return route.target.httpBaseUrl;
70 const profile = Option.getOrNull(route.profile);
71 return profile?._tag === "BearerConnectionProfile" ? profile.httpBaseUrl : null;
72}
73
74function routeHostname(route: ConnectionRoute): string | null {
75 const httpBaseUrl = routeHttpBaseUrl(route);
76 if (httpBaseUrl === null) return null;
77 try {
78 return new URL(httpBaseUrl).hostname;
79 } catch {
80 return null;
81 }
82}
83
84function directAddressKind(hostname: string, tailscale = false) {
85 if (isLocalLoopbackHost(hostname)) return "loopback";
86 if (tailscale || isTailnetHost(hostname)) return "tailnet";
87 if (isSharedAddressSpaceHost(hostname)) return "vpn";
88 return isPrivateNetworkHost(hostname) ? "lan" : "public";
89}
90
91export function connectionRouteKind(route: ConnectionRoute): ConnectionRouteKind {
92 switch (route.target._tag) {
93 case "RelayConnectionTarget":
94 return "relay";
95 case "SshConnectionTarget":
96 return "ssh";
97 case "PrimaryConnectionTarget":
98 case "BearerConnectionTarget": {
99 const hostname = routeHostname(route);
100 if (hostname === null) return "public";
101 const profile = Option.getOrNull(route.profile);
102 return directAddressKind(
103 hostname,
104 profile?._tag === "BearerConnectionProfile" && profile.network === "tailscale",
105 );
106 }
107 }
108}
109
110/**
111 * Whether the route's address reaches the same machine on every network: TLS
112 * proves who answers, and loopback cannot move. Any other plain http address,
113 * a tailnet one too once Tailscale is down, can belong to another machine after
114 * a network change, so an answer from it is not kept.
115 */
116export function routeKeepsItsHost(route: ConnectionRoute): boolean {
117 if (routeHttpBaseUrl(route)?.startsWith("https:") === true) return true;
118 return connectionRouteKind(route) === "loopback";
119}
120
121/** A loopback or LAN host answers within a few seconds or is not there. */
122export function isNearbyAddress(httpBaseUrl: string): boolean {
123 try {
124 const kind = directAddressKind(new URL(httpBaseUrl).hostname);
125 return kind === "loopback" || kind === "lan";
126 } catch {
127 return false;
128 }
129}
130
131const ROUTE_KIND_RANK: Record<ConnectionRouteKind, number> = {
132 loopback: 0,
133 lan: 1,
134 tailnet: 2,
135 vpn: 2,
136 public: 3,
137 ssh: 4,
138 relay: 5,
139};
140
141export function insertRoute(
142 routes: ReadonlyArray<ConnectionRoute>,
143 route: ConnectionRoute,
144 { aheadOfSameKind = false }: { readonly aheadOfSameKind?: boolean } = {},
145): ReadonlyArray<ConnectionRoute> {
146 const kind = connectionRouteKind(route);
147 const rank = ROUTE_KIND_RANK[kind];
148 const index = routes.findIndex((existing) => {
149 const existingKind = connectionRouteKind(existing);
150 return ROUTE_KIND_RANK[existingKind] > rank || (aheadOfSameKind && existingKind === kind);
151 });
152 return index === -1
153 ? [...routes, route]
154 : [...routes.slice(0, index), route, ...routes.slice(index)];
155}
156
157export function upsertRoute(
158 routes: ReadonlyArray<ConnectionRoute>,
159 route: ConnectionRoute,
160): ReadonlyArray<ConnectionRoute> {
161 const id = connectionRouteId(route.target);
162 return routes.some((existing) => connectionRouteId(existing.target) === id)
163 ? routes.map((existing) => (connectionRouteId(existing.target) === id ? route : existing))
164 : // A route paired at a new address, such as localhost beside a saved
165 // 127.0.0.1, goes first among its kind so its fresh credential connects.
166 insertRoute(routes, route, { aheadOfSameKind: true });
167}
168
169export function findRouteToSameAddress(
170 routes: ReadonlyArray<ConnectionRoute>,
171 route: ConnectionRoute,
172): ConnectionRoute | undefined {
173 const key = routeAddressKey(route);
174 return key === null ? undefined : routes.find((existing) => routeAddressKey(existing) === key);
175}
176
177function routeAddressKey(route: ConnectionRoute): string | null {
178 const profile = Option.getOrNull(route.profile);
179 switch (profile?._tag) {
180 case "BearerConnectionProfile":
181 return `bearer:${profile.httpBaseUrl.replace(/\/+$/, "")}`;
182 case "SshConnectionProfile":
183 return `ssh:${sshTargetKey(profile.target)}`;
184 default:
185 return null;
186 }
187}
188
189export function connectionRouteLabel(route: ConnectionRoute): string {
190 switch (connectionRouteKind(route)) {
191 case "relay":
192 return "T3 Connect";
193 case "loopback":
194 return "This device";
195 case "lan":
196 return "LAN";
197 case "tailnet":
198 return "Tailscale";
199 case "vpn":
200 return "VPN";
201 case "ssh": {
202 const profile = Option.getOrNull(route.profile);
203 return profile?._tag === "SshConnectionProfile"
204 ? `SSH ${profile.target.username ? `${profile.target.username}@` : ""}${profile.target.hostname}`
205 : "SSH";
206 }
207 case "public":
208 return routeHostname(route) ?? "Remote link";
209 }
210}
211
212export function connectionRouteAddress(route: ConnectionRoute): string | null {
213 if (route.target._tag === "SshConnectionTarget") {
214 const profile = Option.getOrNull(route.profile);
215 return profile?._tag === "SshConnectionProfile" ? profile.target.alias : null;
216 }
217 return routeHttpBaseUrl(route);
218}
219
220/** Learned routes alone must not make relay environments manually retryable. */
221export const hasSavedBearerRoute = (entry: ConnectionCatalogEntry) =>
222 connectionRoutes(entry).some((r) => !isLearned(r) && r.target._tag === "BearerConnectionTarget");
223
224export function hasRelayRoute(
225 entry: Pick<ConnectionCatalogEntry, "target" | "alternateRoutes">,
226): boolean {
227 return [entry.target, ...(entry.alternateRoutes ?? []).map((route) => route.target)].some(
228 (target) => target._tag === "RelayConnectionTarget",
229 );
230}
231
232export function mergeLearnedRoutes(input: {
233 readonly entry: ConnectionCatalogEntry;
234 readonly activeRoute: ConnectionRoute;
235 readonly reported: ReadonlyArray<ReportedEndpoint>;
236 /** Plain HTTP routes are unusable from an HTTPS page (mixed content). */
237 readonly allowInsecure: boolean;
238}): ReadonlyArray<ConnectionRoute> | null {
239 const { entry } = input;
240 const active = input.activeRoute.target;
241 const saved = connectionRoutes(entry);
242 if (active._tag !== "RelayConnectionTarget" && active._tag !== "BearerConnectionTarget") {
243 return null;
244 }
245 const activeProfile = Option.getOrNull(input.activeRoute.profile);
246 const authorization =
247 active._tag === "RelayConnectionTarget" ||
248 (activeProfile?._tag === "BearerConnectionProfile" &&
249 activeProfile.authorization === "t3-connect")
250 ? ("t3-connect" as const)
251 : undefined;
252 const sharedCredential =
253 authorization === undefined && active._tag === "BearerConnectionTarget"
254 ? credentialConnectionId(active.connectionId)
255 : undefined;
256 const reported = new Map<string, URL>();
257 const tailscaleOrigins = new Set<string>();
258 for (const endpoint of input.reported) {
259 let url: URL;
260 try {
261 url = new URL(endpoint.httpBaseUrl);
262 } catch {
263 continue;
264 }
265 if (url.protocol !== "http:" && url.protocol !== "https:") continue;
266 if (url.protocol === "http:" && !input.allowInsecure) continue;
267 // A loopback address names whichever device opens it, never the server.
268 if (isLocalLoopbackHost(url.hostname)) continue;
269 reported.set(url.origin, url);
270 if (endpoint.kind === "tailnet") tailscaleOrigins.add(url.origin);
271 }
272 const normalized = (url: string) => url.replace(/\/+$/, "");
273 const known = new Set(
274 saved.flatMap((route) => {
275 const url = routeHttpBaseUrl(route);
276 return url === null || isLearned(route) ? [] : [normalized(url)];
277 }),
278 );
279 const kept = saved.flatMap((route): ReadonlyArray<ConnectionRoute> => {
280 const url = routeHttpBaseUrl(route);
281 if (!isLearned(route)) {
282 return [
283 url === null || !reported.has(normalized(url))
284 ? route
285 : withNetwork(route, tailscaleOrigins.has(normalized(url))),
286 ];
287 }
288 if (url === null || !reported.has(normalized(url)) || known.has(normalized(url))) return [];
289 if (sendsTokenInClear(saved, route, tailscaleOrigins.has(normalized(url)))) return [];
290 known.add(normalized(url));
291 return [withNetwork(route, tailscaleOrigins.has(normalized(url)))];
292 });
293 let next: ReadonlyArray<ConnectionRoute> = kept;
294 for (const url of reported.values()) {
295 if (known.has(url.origin)) continue;
296 if (
297 url.protocol === "http:" &&
298 sharedCredential !== undefined &&
299 !tailscaleOrigins.has(url.origin) &&
300 !tokenAllowsPlainHttp(saved, sharedCredential)
301 ) {
302 continue;
303 }
304 const httpBaseUrl = `${url.origin}/`;
305 const connectionId = learnedConnectionId(
306 entry.target.environmentId,
307 url.origin,
308 sharedCredential,
309 );
310 next = insertRoute(next, {
311 target: new BearerConnectionTarget({
312 environmentId: entry.target.environmentId,
313 label: entry.target.label,
314 connectionId,
315 }),
316 profile: Option.some(
317 new BearerConnectionProfile({
318 connectionId,
319 environmentId: entry.target.environmentId,
320 label: entry.target.label,
321 httpBaseUrl,
322 wsBaseUrl: `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}/`,
323 learned: true,
324 ...(authorization === undefined ? {} : { authorization }),
325 ...(tailscaleOrigins.has(url.origin) ? { network: "tailscale" as const } : {}),
326 }),
327 ),
328 });
329 }
330 const signature = (routes: ReadonlyArray<ConnectionRoute>) =>
331 routes
332 .map(
333 (route) =>
334 `${connectionRouteId(route.target)} ${routeHttpBaseUrl(route) ?? ""} ${connectionRouteKind(route)}`,
335 )
336 .join("\n");
337 return signature(saved) === signature(next) ? null : next;
338}
339
340// A paired token goes to a plain HTTP address outside the tailnet only when
341// it was paired that way, so a token paired over HTTPS, loopback, or the
342// tailnet keeps to the transports it was issued for.
343function tokenAllowsPlainHttp(
344 routes: ReadonlyArray<ConnectionRoute>,
345 credentialId: string,
346): boolean {
347 const credentialRoute = routes.find((route) => connectionRouteId(route.target) === credentialId);
348 return (
349 credentialRoute !== undefined &&
350 routeHttpBaseUrl(credentialRoute)?.startsWith("http:") === true &&
351 (connectionRouteKind(credentialRoute) === "lan" ||
352 connectionRouteKind(credentialRoute) === "public")
353 );
354}
355
356/**
357 * Whether connecting over `route` would send a paired token over plain HTTP
358 * that it wasn't paired over. Checked against the token the route borrows,
359 * so a route learned before this rule existed is refused too.
360 */
361export function sendsTokenInClear(
362 routes: ReadonlyArray<ConnectionRoute>,
363 route: ConnectionRoute,
364 tailscale?: boolean,
365): boolean {
366 const profile = Option.getOrNull(route.profile);
367 if (profile?._tag !== "BearerConnectionProfile" || profile.authorization === "t3-connect") {
368 return false;
369 }
370 if ((tailscale ?? profile.network === "tailscale") || !profile.httpBaseUrl.startsWith("http:")) {
371 return false;
372 }
373 const credentialId = credentialConnectionId(connectionRouteId(route.target));
374 return (
375 credentialId !== connectionRouteId(route.target) && !tokenAllowsPlainHttp(routes, credentialId)
376 );
377}
378
379/** Routes a connect may use: none sends a paired token over plain HTTP it wasn't paired over. */
380export function usableRoutes(entry: ConnectionCatalogEntry): ReadonlyArray<ConnectionRoute> {
381 const routes = connectionRoutes(entry);
382 return routes.filter((route) => !sendsTokenInClear(routes, route));
383}
384
385export interface ReportedEndpoint {
386 readonly httpBaseUrl: string;
387 readonly kind?: string;
388}
389
390function withNetwork(route: ConnectionRoute, tailscale: boolean): ConnectionRoute {
391 const profile = Option.getOrNull(route.profile);
392 if (profile?._tag !== "BearerConnectionProfile") return route;
393 if ((profile.network === "tailscale") === tailscale) return route;
394 return {
395 target: route.target,
396 profile: Option.some(
397 new BearerConnectionProfile({
398 connectionId: profile.connectionId,
399 environmentId: profile.environmentId,
400 label: profile.label,
401 httpBaseUrl: profile.httpBaseUrl,
402 wsBaseUrl: profile.wsBaseUrl,
403 ...(profile.learned === undefined ? {} : { learned: profile.learned }),
404 ...(profile.authorization === undefined ? {} : { authorization: profile.authorization }),
405 ...(tailscale ? { network: "tailscale" as const } : {}),
406 }),
407 ),
408 };
409}
410
411function learnedConnectionId(
412 environmentId: string,
413 origin: string,
414 sharedCredential: string | undefined,
415): string {
416 return sharedCredential === undefined
417 ? `learned:${environmentId}:${origin}`
418 : `learned:${environmentId}:${origin}@${sharedCredential}`;
419}
420
421export function credentialConnectionId(connectionId: string): string {
422 const at = connectionId.indexOf("@");
423 return connectionId.startsWith("learned:") && at !== -1
424 ? connectionId.slice(at + 1)
425 : connectionId;
426}
427
428export function isLearned(route: ConnectionRoute): boolean {
429 const profile = Option.getOrNull(route.profile);
430 return profile?._tag === "BearerConnectionProfile" && profile.learned === true;
431}
432
433/**
434 * A learned route borrows the credential of the route it was learned over, so
435 * it cannot outlive that route: removing T3 Connect also removes routes learned
436 * through it, and removing a paired address removes routes that borrow its
437 * token.
438 */
439export function routesAfterRemoving(
440 routes: ReadonlyArray<ConnectionRoute>,
441 removedId: string,
442): ReadonlyArray<ConnectionRoute> {
443 const removed = routes.find((route) => connectionRouteId(route.target) === removedId);
444 if (removed === undefined) return routes;
445 return routes.filter((route) => {
446 if (route === removed) return false;
447 if (!isLearned(route)) return true;
448 const profile = Option.getOrNull(route.profile);
449 if (profile?._tag === "BearerConnectionProfile" && profile.authorization === "t3-connect") {
450 return removed.target._tag !== "RelayConnectionTarget";
451 }
452 return credentialConnectionId(connectionRouteId(route.target)) !== removedId;
453 });
454}
455
456/**
457 * A dragged route order shows until the saved routes catch up with it. It is
458 * stale, and returns null, once the saved order matches it or the saved routes
459 * are no longer the same set: kept any longer, it would hide a route added
460 * later whenever the count happened to match again.
461 */
462export function pendingRouteOrder(
463 pending: ReadonlyArray<string> | null,
464 savedIds: ReadonlyArray<string>,
465): ReadonlyArray<string> | null {
466 if (pending === null || pending.length !== savedIds.length) return null;
467 const saved = new Set(savedIds);
468 if (!pending.every((id) => saved.has(id))) return null;
469 return pending.some((id, index) => id !== savedIds[index]) ? pending : null;
470}
471
472export function removedWithRelay(entry: ConnectionCatalogEntry): boolean {
473 return routesAfterRemoving(connectionRoutes(entry), RELAY_ROUTE_ID).length === 0;
474}
475
476export function sshTargetKey(target: DesktopSshEnvironmentTarget): string {
477 return JSON.stringify([target.alias, target.hostname, target.username, target.port]);
478}