diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 3e66d5f707a0d..562321055f862 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -53,17 +53,28 @@ const layerSpawner = (input: { readonly status: CommandResult; readonly prefs: C }), ); -const layerNet = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => +const layerNet = (input: { + readonly ipv4: boolean; + readonly ipv6: boolean; + readonly tailnet?: boolean; +}) => Layer.succeed(NetService.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), - hasListenerOnHost: (_port, host) => Effect.succeed(host === "::1" ? input.ipv6 : input.ipv4), + hasListenerOnHost: (_port, host) => + Effect.succeed( + host === "127.0.0.1" + ? input.ipv4 + : host === "::1" + ? input.ipv6 + : (host === "100.64.1.2" || host === "bb-1.tail1234.ts.net") && input.tailnet === true, + ), reserveLoopbackPort: () => Effect.succeed(40_000), findAvailablePort: (preferred) => Effect.succeed(preferred), }); const resolveTargets = (input: { - readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean }; + readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean; readonly tailnet?: boolean }; readonly tailscale: { readonly status: CommandResult; readonly prefs: CommandResult }; readonly hostname: string; readonly username?: string | null; @@ -125,6 +136,20 @@ describe("RemoteOpenTargets", () => { }), ); + it.effect("advertises the tailnet name when sshd listens only on the tailnet address", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: false, ipv6: false, tailnet: true }, + tailscale: TAILSCALE_UP, + hostname: "bb-1", + username: "t3-user", + }); + expect(targets).toEqual([ + { kind: "tailscale", host: "bb-1.tail1234.ts.net", username: "t3-user" }, + ]); + }), + ); + it.effect("ignores a stored RunSSH after `tailscale down`", () => Effect.gen(function* () { const targets = yield* resolveTargets({ diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index fff8a8168437b..d6837560790fd 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -33,23 +33,29 @@ export const make = Effect.gen(function* () { ); const username = yield* HostProcess.Username; - const magicDnsName = yield* readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), + const tailscaleStatus = yield* readTailscaleStatus.pipe( Effect.orElseSucceed(() => null), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); + const magicDnsName = tailscaleStatus?.magicDnsName ?? null; const targets: Array = []; if (!sshdListening) { - // Tailscale SSH needs no local sshd; tailscaled alone does not prove an SSH route. - if ( - magicDnsName !== null && + // sshd may listen only on the tailnet address (ListenAddress), and Tailscale SSH + // needs no local sshd; tailscaled alone does not prove an SSH route. + if (magicDnsName === null) return targets; + const tailnetSshdListening = yield* Effect.forEach( + tailscaleStatus?.tailnetIpv4Addresses ?? [], + (address) => net.hasListenerOnHost(SSH_PORT, address), + ).pipe(Effect.map((listening) => listening.includes(true))); + const sshRoute = + tailnetSshdListening || (yield* readTailscaleSshEnabled.pipe( Effect.orElseSucceed(() => false), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - )) - ) { + )); + if (sshRoute) { targets.push({ kind: "tailscale", host: magicDnsName,