1import { it } from "@effect/vitest";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as NetService from "@t3tools/shared/Net";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";
8import * as ChildProcess from "effect/process/ChildProcess";
9import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
10import { describe, expect } from "vite-plus/test";
11
12import * as RemoteOpenTargets from "./RemoteOpenTargets.ts";
13
14const encoder = new TextEncoder();
15
16const TAILSCALE_STATUS_JSON = JSON.stringify({
17 Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] },
18});
19
20interface CommandResult {
21 readonly exitCode: number;
22 readonly stdout: string;
23}
24
25/** Spawner answering `tailscale status --json` and `tailscale debug prefs`. */
26const layerSpawner = (input: { readonly status: CommandResult; readonly prefs: CommandResult }) =>
27 Layer.succeed(
28 ChildProcessSpawner.ChildProcessSpawner,
29 ChildProcessSpawner.make((command) => {
30 const args = ChildProcess.isStandardCommand(command) ? command.args : [];
31 const result = args[0] === "debug" ? input.prefs : input.status;
32 return Effect.succeed(
33 ChildProcessSpawner.makeHandle({
34 pid: ChildProcessSpawner.ProcessId(1),
35 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)),
36 isRunning: Effect.succeed(false),
37 kill: () => Effect.void,
38 unref: Effect.succeed(Effect.void),
39 stdin: Sink.drain,
40 stdout: Stream.make(encoder.encode(result.stdout)),
41 stderr: Stream.empty,
42 all: Stream.empty,
43 getInputFd: () => Sink.drain,
44 getOutputFd: () => Stream.empty,
45 }),
46 );
47 }),
48 );
49
50const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) =>
51 Layer.succeed(NetService.NetService, {
52 canListenOnHost: () => Effect.succeed(true),
53 isPortAvailableOnLoopback: () => Effect.succeed(true),
54 hasListenerOnHost: (_port, host) => Effect.succeed(host === "::1" ? input.ipv6 : input.ipv4),
55 reserveLoopbackPort: () => Effect.succeed(40_000),
56 findAvailablePort: (preferred) => Effect.succeed(preferred),
57 });
58
59const resolveTargets = (input: {
60 readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean };
61 readonly tailscale: { readonly status: CommandResult; readonly prefs: CommandResult };
62 readonly hostname: string;
63}) =>
64 Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe(
65 Effect.provideService(HostProcess.Hostname, input.hostname),
66 Effect.provide(
67 RemoteOpenTargets.layer.pipe(
68 Layer.provide(Layer.mergeAll(layerNet(input.sshd), layerSpawner(input.tailscale))),
69 ),
70 ),
71 );
72
73// `tailscale debug prefs` output, trimmed. `tailscale up --ssh` sets RunSSH.
74const prefsJson = (prefs: { readonly RunSSH: boolean; readonly WantRunning: boolean }) => ({
75 exitCode: 0,
76 stdout: JSON.stringify(
77 { ControlURL: "https://controlplane.tailscale.com", ...prefs },
78 null,
79 "\t",
80 ),
81});
82
83const STATUS_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON };
84const TAILSCALE_UP = { status: STATUS_UP, prefs: prefsJson({ RunSSH: false, WantRunning: true }) };
85const TAILSCALE_SSH = { status: STATUS_UP, prefs: prefsJson({ RunSSH: true, WantRunning: true }) };
86const TAILSCALE_DOWN = { status: { exitCode: 1, stdout: "" }, prefs: { exitCode: 1, stdout: "" } };
87
88describe("RemoteOpenTargets", () => {
89 it.effect("advertises nothing when no sshd accepts and Tailscale SSH is off", () =>
90 Effect.gen(function* () {
91 const targets = yield* resolveTargets({
92 sshd: { ipv4: false, ipv6: false },
93 tailscale: TAILSCALE_UP,
94 hostname: "bb-1",
95 });
96 expect(targets).toEqual([]);
97 }),
98 );
99
100 it.effect("advertises only the tailnet name when Tailscale SSH serves without sshd", () =>
101 Effect.gen(function* () {
102 const targets = yield* resolveTargets({
103 sshd: { ipv4: false, ipv6: false },
104 tailscale: TAILSCALE_SSH,
105 hostname: "bb-1",
106 });
107 expect(targets).toEqual([{ kind: "tailscale", host: "bb-1.tail1234.ts.net" }]);
108 }),
109 );
110
111 it.effect("ignores a stored RunSSH after `tailscale down`", () =>
112 Effect.gen(function* () {
113 const targets = yield* resolveTargets({
114 sshd: { ipv4: false, ipv6: false },
115 tailscale: { status: STATUS_UP, prefs: prefsJson({ RunSSH: true, WantRunning: false }) },
116 hostname: "bb-1",
117 });
118 expect(targets).toEqual([]);
119 }),
120 );
121
122 it.effect("orders the tailnet name before the mDNS name", () =>
123 Effect.gen(function* () {
124 const targets = yield* resolveTargets({
125 sshd: { ipv4: true, ipv6: true },
126 tailscale: TAILSCALE_UP,
127 hostname: "bb-1",
128 });
129 expect(targets).toEqual([
130 { kind: "tailscale", host: "bb-1.tail1234.ts.net" },
131 { kind: "mdns", host: "bb-1.local" },
132 ]);
133 }),
134 );
135
136 it.effect("accepts an sshd bound to IPv6 loopback only", () =>
137 Effect.gen(function* () {
138 const targets = yield* resolveTargets({
139 sshd: { ipv4: false, ipv6: true },
140 tailscale: TAILSCALE_DOWN,
141 hostname: "bb-1",
142 });
143 expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]);
144 }),
145 );
146
147 it.effect("falls back to mDNS alone when tailscale is unavailable", () =>
148 Effect.gen(function* () {
149 const targets = yield* resolveTargets({
150 sshd: { ipv4: true, ipv6: false },
151 tailscale: TAILSCALE_DOWN,
152 hostname: "bb-1",
153 });
154 expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]);
155 }),
156 );
157
158 it.effect("shortens an FQDN hostname to its first label for mDNS", () =>
159 Effect.gen(function* () {
160 const targets = yield* resolveTargets({
161 sshd: { ipv4: true, ipv6: true },
162 tailscale: TAILSCALE_DOWN,
163 hostname: "bb-1.example.com",
164 });
165 expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]);
166 }),
167 );
168});