1import { type RemoteOpenTarget } from "@t3tools/contracts";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as NetService from "@t3tools/shared/Net";
4import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale";
5import * as Context from "effect/Context";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
9
10import { makeStaleWhileRevalidate } from "./staleWhileRevalidate.ts";
11
12const SSH_PORT = 22;
13const TARGETS_CACHE_TTL = "60 seconds";
14
15export class RemoteOpenTargets extends Context.Service<
16 RemoteOpenTargets,
17 {
18 readonly resolveTargets: () => Effect.Effect<ReadonlyArray<RemoteOpenTarget>>;
19 }
20>()("t3/environment/RemoteOpenTargets") {}
21
22/** @public */
23export const make = Effect.gen(function* () {
24 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
25 const net = yield* NetService.NetService;
26
27 const discoverTargets = Effect.gen(function* () {
28 // Check both loopback families: sshd can be bound IPv6-only.
29 const sshdListening = yield* Effect.zipWith(
30 net.hasListenerOnHost(SSH_PORT, "127.0.0.1"),
31 net.hasListenerOnHost(SSH_PORT, "::1"),
32 (ipv4, ipv6) => ipv4 || ipv6,
33 );
34 const username = yield* HostProcess.Username;
35
36 const tailscaleStatus = yield* readTailscaleStatus.pipe(
37 Effect.orElseSucceed(() => null),
38 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
39 );
40 const magicDnsName =
41 tailscaleStatus?.running === false ? null : (tailscaleStatus?.magicDnsName ?? null);
42
43 const targets: Array<RemoteOpenTarget> = [];
44
45 if (!sshdListening) {
46 // sshd may listen only on the tailnet address (ListenAddress), and Tailscale SSH
47 // needs no local sshd; tailscaled alone does not prove an SSH route.
48 if (magicDnsName === null) return targets;
49 const tailnetSshdListening = yield* Effect.forEach(
50 tailscaleStatus?.tailnetIpv4Addresses ?? [],
51 (address) => net.hasListenerOnHost(SSH_PORT, address),
52 ).pipe(Effect.map((listening) => listening.includes(true)));
53 const sshRoute =
54 tailnetSshdListening ||
55 (yield* readTailscaleSshEnabled.pipe(
56 Effect.orElseSucceed(() => false),
57 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
58 ));
59 if (sshRoute) {
60 targets.push({
61 kind: "tailscale",
62 host: magicDnsName,
63 ...(username === null ? {} : { username }),
64 });
65 }
66 return targets;
67 }
68
69 if (magicDnsName !== null) {
70 targets.push({
71 kind: "tailscale",
72 host: magicDnsName,
73 ...(username === null ? {} : { username }),
74 });
75 }
76
77 // hostname() may be an FQDN; mDNS always uses <first-label>.local.
78 const hostname = yield* HostProcess.Hostname;
79 const shortHostname = hostname.split(".")[0]?.trim();
80 if (shortHostname !== undefined && shortHostname.length > 0) {
81 targets.push({
82 kind: "mdns",
83 host: `${shortHostname}.local`,
84 ...(username === null ? {} : { username }),
85 });
86 }
87
88 return targets;
89 });
90
91 // Advertised on every client connect; probing sshd and tailscaled is slow
92 // on a loaded host, so serve the last result while it refreshes.
93 const resolveTargets = yield* makeStaleWhileRevalidate(discoverTargets, TARGETS_CACHE_TTL);
94
95 return RemoteOpenTargets.of({
96 resolveTargets: Effect.fn("RemoteOpenTargets.resolveTargets")(function () {
97 return resolveTargets;
98 }),
99 });
100});
101
102export const layer = Layer.effect(RemoteOpenTargets, make);