| 1 | /** |
| 2 | * RemoteOpenTargets - resolves the SSH hostnames this environment advertises |
| 3 | * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`). |
| 4 | * |
| 5 | * The server can only check itself: sshd listening locally, tailscaled |
| 6 | * reporting a MagicDNS name and whether Tailscale SSH is on, and the machine |
| 7 | * hostname for mDNS. Whether a given name resolves from the viewer's machine |
| 8 | * is inherently client-side. |
| 9 | * Targets are ordered most-reachable first (tailnet name works from anywhere |
| 10 | * on the tailnet; `<hostname>.local` only on the same LAN). |
| 11 | */ |
| 12 | import { type RemoteOpenTarget } from "@t3tools/contracts"; |
| 13 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 14 | import * as NetService from "@t3tools/shared/Net"; |
| 15 | import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale"; |
| 16 | import * as Context from "effect/Context"; |
| 17 | import * as Effect from "effect/Effect"; |
| 18 | import * as Layer from "effect/Layer"; |
| 19 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 20 | |
| 21 | const SSH_PORT = 22; |
| 22 | |
| 23 | export class RemoteOpenTargets extends Context.Service< |
| 24 | RemoteOpenTargets, |
| 25 | { |
| 26 | readonly resolveTargets: () => Effect.Effect<ReadonlyArray<RemoteOpenTarget>>; |
| 27 | } |
| 28 | >()("t3/environment/RemoteOpenTargets") {} |
| 29 | |
| 30 | /** @public Service construction is part of the canonical Effect module API. */ |
| 31 | export const make = Effect.gen(function* () { |
| 32 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 33 | const net = yield* NetService.NetService; |
| 34 | |
| 35 | const resolveTargets = Effect.gen(function* () { |
| 36 | // Check both loopback families: sshd can be bound IPv6-only. |
| 37 | const sshdListening = yield* Effect.zipWith( |
| 38 | net.hasListenerOnHost(SSH_PORT, "127.0.0.1"), |
| 39 | net.hasListenerOnHost(SSH_PORT, "::1"), |
| 40 | (ipv4, ipv6) => ipv4 || ipv6, |
| 41 | ); |
| 42 | |
| 43 | // Tailscale absent or down is the common case, not an error. |
| 44 | const magicDnsName = yield* readTailscaleStatus.pipe( |
| 45 | Effect.map((status) => status.magicDnsName), |
| 46 | Effect.orElseSucceed(() => null), |
| 47 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 48 | ); |
| 49 | |
| 50 | const targets: Array<RemoteOpenTarget> = []; |
| 51 | |
| 52 | if (!sshdListening) { |
| 53 | // Tailscale SSH answers on the tailnet name with no local sshd, so only |
| 54 | // that name can work. A running tailscaled alone is not an SSH route. |
| 55 | // With neither, advertise nothing so clients render a clear "no SSH |
| 56 | // route" state instead of links that hang. |
| 57 | if ( |
| 58 | magicDnsName !== null && |
| 59 | (yield* readTailscaleSshEnabled.pipe( |
| 60 | Effect.orElseSucceed(() => false), |
| 61 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 62 | )) |
| 63 | ) { |
| 64 | targets.push({ kind: "tailscale", host: magicDnsName }); |
| 65 | } |
| 66 | return targets; |
| 67 | } |
| 68 | |
| 69 | if (magicDnsName !== null) { |
| 70 | targets.push({ kind: "tailscale", host: magicDnsName }); |
| 71 | } |
| 72 | |
| 73 | // os.hostname() may already be an FQDN (macOS often reports |
| 74 | // "Name.local"); mDNS names are always `<first-label>.local`. |
| 75 | const hostname = yield* HostProcess.Hostname; |
| 76 | const shortHostname = hostname.split(".")[0]?.trim(); |
| 77 | if (shortHostname !== undefined && shortHostname.length > 0) { |
| 78 | targets.push({ kind: "mdns", host: `${shortHostname}.local` }); |
| 79 | } |
| 80 | |
| 81 | return targets; |
| 82 | }); |
| 83 | |
| 84 | return RemoteOpenTargets.of({ resolveTargets: () => resolveTargets }); |
| 85 | }); |
| 86 | |
| 87 | export const layer = Layer.effect(RemoteOpenTargets, make); |