1// @effect-diagnostics nodeBuiltinImport:off -- replace the native OS lookup and sync its ESM bindings.
2import * as NodeModule from "node:module";
3import { it } from "@effect/vitest";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import * as NetService from "@t3tools/shared/Net";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import * as ChildProcess from "effect/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12import { afterEach, describe, expect, vi } from "vite-plus/test";
13
14import * as RemoteOpenTargets from "./RemoteOpenTargets.ts";
15
16afterEach(() => {
17 vi.restoreAllMocks();
18 NodeModule.syncBuiltinESMExports();
19});
20
21const encoder = new TextEncoder();
22
23const TAILSCALE_STATUS_JSON = JSON.stringify({
24 Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },
25});
26
27interface CommandResult {
28 readonly exitCode: number;
29 readonly stdout: string;
30}
31
32const layerSpawner = (input: { readonly status: CommandResult; readonly prefs: CommandResult }) =>
33 Layer.succeed(
34 ChildProcessSpawner.ChildProcessSpawner,
35 ChildProcessSpawner.make((command) => {
36 const args = ChildProcess.isStandardCommand(command) ? command.args : [];
37 const result = args[0] === "debug" ? input.prefs : input.status;
38 return Effect.succeed(
39 ChildProcessSpawner.makeHandle({
40 pid: ChildProcessSpawner.ProcessId(1),
41 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)),
42 isRunning: Effect.succeed(false),
43 kill: () => Effect.void,
44 unref: Effect.succeed(Effect.void),
45 stdin: Sink.drain,
46 stdout: Stream.make(encoder.encode(result.stdout)),
47 stderr: Stream.empty,
48 all: Stream.empty,
49 getInputFd: () => Sink.drain,
50 getOutputFd: () => Stream.empty,
51 }),
52 );
53 }),
54 );
55
56const layerNet = (input: {
57 readonly ipv4: boolean;
58 readonly ipv6: boolean;
59 readonly tailnet?: boolean;
60}) =>
61 Layer.succeed(NetService.NetService, {
62 canListenOnHost: () => Effect.succeed(true),
63 isPortAvailableOnLoopback: () => Effect.succeed(true),
64 hasListenerOnHost: (_port, host) =>
65 Effect.succeed(
66 host === "127.0.0.1"
67 ? input.ipv4
68 : host === "::1"
69 ? input.ipv6
70 : (host === "100.64.1.2" || host === "bb-1.tail1234.ts.net") && input.tailnet === true,
71 ),
72 reserveLoopbackPort: () => Effect.succeed(40_000),
73 findAvailablePort: (preferred) => Effect.succeed(preferred),
74 });
75
76const resolveTargets = (input: {
77 readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean; readonly tailnet?: boolean };
78 readonly tailscale: { readonly status: CommandResult; readonly prefs: CommandResult };
79 readonly hostname: string;
80 readonly username?: string | null;
81}) => {
82 const resolve = Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) =>
83 service.resolveTargets(),
84 ).pipe(
85 Effect.provideService(HostProcess.Hostname, input.hostname),
86 Effect.provide(
87 RemoteOpenTargets.layer.pipe(
88 Layer.provide(Layer.mergeAll(layerNet(input.sshd), layerSpawner(input.tailscale))),
89 ),
90 ),
91 );
92
93 return input.username === undefined
94 ? resolve
95 : resolve.pipe(Effect.provideService(HostProcess.Username, input.username));
96};
97
98const prefsJson = (prefs: { readonly RunSSH: boolean; readonly WantRunning: boolean }) => ({
99 exitCode: 0,
100 stdout: JSON.stringify(
101 { ControlURL: "https://controlplane.tailscale.com", ...prefs },
102 null,
103 "\t",
104 ),
105});
106
107const STATUS_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON };
108const TAILSCALE_UP = { status: STATUS_UP, prefs: prefsJson({ RunSSH: false, WantRunning: true }) };
109const TAILSCALE_SSH = { status: STATUS_UP, prefs: prefsJson({ RunSSH: true, WantRunning: true }) };
110const TAILSCALE_DOWN = { status: { exitCode: 1, stdout: "" }, prefs: { exitCode: 1, stdout: "" } };
111
112describe("RemoteOpenTargets", () => {
113 it.effect("advertises nothing when no sshd accepts and Tailscale SSH is off", () =>
114 Effect.gen(function* () {
115 const targets = yield* resolveTargets({
116 sshd: { ipv4: false, ipv6: false },
117 tailscale: TAILSCALE_UP,
118 hostname: "bb-1",
119 username: "t3-user",
120 });
121 expect(targets).toEqual([]);
122 }),
123 );
124
125 it.effect("advertises only the tailnet name when Tailscale SSH serves without sshd", () =>
126 Effect.gen(function* () {
127 const targets = yield* resolveTargets({
128 sshd: { ipv4: false, ipv6: false },
129 tailscale: TAILSCALE_SSH,
130 hostname: "bb-1",
131 username: "t3-user",
132 });
133 expect(targets).toEqual([
134 { kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" },
135 ]);
136 }),
137 );
138
139 it.effect("advertises the tailnet name when sshd listens only on the tailnet address", () =>
140 Effect.gen(function* () {
141 const targets = yield* resolveTargets({
142 sshd: { ipv4: false, ipv6: false, tailnet: true },
143 tailscale: TAILSCALE_UP,
144 hostname: "bb-1",
145 username: "t3-user",
146 });
147 expect(targets).toEqual([
148 { kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" },
149 ]);
150 }),
151 );
152
153 it.effect("ignores a stored RunSSH after `tailscale down`", () =>
154 Effect.gen(function* () {
155 const targets = yield* resolveTargets({
156 sshd: { ipv4: false, ipv6: false },
157 tailscale: { status: STATUS_UP, prefs: prefsJson({ RunSSH: true, WantRunning: false }) },
158 hostname: "bb-1",
159 username: null,
160 });
161 expect(targets).toEqual([]);
162 }),
163 );
164
165 it.effect("drops the tailnet name after `tailscale down` while sshd listens", () =>
166 Effect.gen(function* () {
167 const targets = yield* resolveTargets({
168 sshd: { ipv4: true, ipv6: false },
169 tailscale: {
170 // After `tailscale down`, status still exits 0 and keeps Self from the last netmap.
171 status: {
172 exitCode: 0,
173 stdout: JSON.stringify({
174 BackendState: "Stopped",
175 Self: {
176 DNSName: "bb-1.tail1234.ts.net.",
177 TailscaleIPs: ["100.64.1.2"],
178 Online: false,
179 },
180 }),
181 },
182 prefs: prefsJson({ RunSSH: false, WantRunning: false }),
183 },
184 hostname: "bb-1",
185 username: "t3-user",
186 });
187 expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local", username: "t3-user" }]);
188 }),
189 );
190
191 it.effect("accepts an sshd bound to IPv6 loopback only", () =>
192 Effect.gen(function* () {
193 const targets = yield* resolveTargets({
194 sshd: { ipv4: false, ipv6: true },
195 tailscale: TAILSCALE_DOWN,
196 hostname: "bb-1",
197 username: "t3-user",
198 });
199 expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local", username: "t3-user" }]);
200 }),
201 );
202});