Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
- Use the checkout and dependencies below. Save the test in Relevant output or evidence as
apps/server/src/environment/ssh-target-repro.test.ts. - From
apps/server, runnpx -y pnpm@12.14.0 exec vp test run src/environment/ssh-target-repro.test.ts --reporter=verbose --silent=false. - Observe
SSH destinations probed [ '127.0.0.1', '::1' ] advertised []. The fixture's listener predicate accepts the MagicDNS host and example tailnet IPs on port 22, but production discovery never asks for them.
The reduced probe was run once and reproduced the empty target list. The Tailscale command spawner supplies hand-written status/prefs JSON using the real Tailscale field shapes; the NetService fixture models a tailnet-only conventional SSH listener. No real tailscaled, sshd, external SSH connection, editor process, or UI was exercised.
Expected behavior
A valid conventional SSH listener on the host's tailnet interface should be eligible as a remote editor destination even when SSH is unavailable on loopback. OpenSSH explicitly supports a local-address-specific ListenAddress. T3's remote-open resolver already supports using the MagicDNS host for conventional SSH when a loopback probe succeeds. Closed #11052 establishes that absence of a loopback listener alone must not mean that no SSH route can work; the current Tailscale SSH exception does not cover conventional OpenSSH listening only on its tailnet address.
Restoration check
For the probe's reachable tailnet SSH destination, remote-open discovery supplies a usable tailnet target. The corresponding remote URL client can offer its editor action. Cases with neither conventional SSH nor Tailscale SSH must continue to produce no target. Verify that a real conventional sshd restricted to the tailnet address can be opened from a client on that tailnet without enabling Tailscale SSH or widening sshd's bind.
Actual behavior
Observed with real RemoteOpenTargets.make and controlled production-interface dependencies: when SSH port 22 is reachable on the modeled Tailscale host but closed on both 127.0.0.1 and ::1, Tailscale status is Running with a MagicDNS name, and Tailscale SSH is off (RunSSH:false, WantRunning:true), resolveTargets() returns []. It only probes the two loopback hosts. Source-only downstream trace: a remote URL connection without a saved SSH profile receives no advertised target, so Open in editor becomes unavailable despite the conventional SSH destination being reachable in this modeled configuration.
Impact
Minor bug or occasional failure
Impact: P2 - Open in editor is disabled for a valid tailnet-only OpenSSH configuration unless an explicit desktop SSH profile supplies the destination.
This concerns standard-port conventional SSH bound only to a tailnet interface, with Tailscale SSH disabled and a remote URL connection lacking an SSH profile. It does not claim that every port-22 listener is a working authenticated SSH service. NOT_ENOUGH_INFO: an end-to-end editor launch against a real sshd and tailnet was not run; that host/client setup would verify the UI consequence. The source supports an explicit SSH-profile alias as an alternative, but that workaround was not exercised here.
Version or commit
main at c0c7405b04148f7fa3de5352d0e0aa149806c040; apps/server/package.json reports 0.1.9.
Environment
Linux 7.2.15-1-omarchy, x86_64; Node v26.13.1; pinned pnpm 12.14.0; Vite Plus test runner v5.0.5. Dependencies came from the checkout lockfile.
Logs or stack traces
Evidence provenance: observed production-module probe with synthetic Tailscale command results and listener availability. Local verification: reproduced. Reproduction completeness: complete for discovery; source-only for the resulting client state. All names, usernames, and IPs below are synthetic fixtures. The JSON fields follow Tailscale's ipn/ipnstate.Status and ipn.Prefs definitions; a real CLI was not invoked.
import { it as effectIt } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as NetService from "@t3tools/shared/Net";
import * as RemoteOpenTargets from "./RemoteOpenTargets.ts";
import { expect } from "vite-plus/test";
const ipv6 = "fd7a:115c:a1e0::1";
// Handwritten status/prefs fixtures use Tailscale's ipnstate.Status/ipn.Prefs JSON fields.
const spawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(command => {
const args = ChildProcess.isStandardCommand(command) ? command.args : [];
const stdout = JSON.stringify(args[0] === "debug" ? {RunSSH:false, WantRunning:true} : {BackendState:"Running", Self:{DNSName:"box.example.ts.net.", TailscaleIPs:["100.64.0.1",ipv6]}});
return Effect.succeed(ChildProcessSpawner.makeHandle({pid:ChildProcessSpawner.ProcessId(1),exitCode:Effect.succeed(ChildProcessSpawner.ExitCode(0)),isRunning:Effect.succeed(false),kill:()=>Effect.void,unref:Effect.succeed(Effect.void),stdin:Sink.drain,stdout:Stream.make(new TextEncoder().encode(stdout)),stderr:Stream.empty,all:Stream.empty,getInputFd:()=>Sink.drain,getOutputFd:()=>Stream.empty}));
}));
effectIt.effect("observes no SSH editor target for tailnet-only OpenSSH", () => Effect.gen(function*() {
const probed: string[] = [];
const hasListenerOnHost = (_port:number,host:string) => {probed.push(host); return Effect.succeed(host === "box.example.ts.net" || host === "100.64.0.1" || host === ipv6);};
const net = NetService.NetService.of({canListenOnHost:()=>Effect.succeed(true),isPortAvailableOnLoopback:()=>Effect.succeed(true),hasListenerOnHost,reserveLoopbackPort:()=>Effect.succeed(40000),findAvailablePort:port=>Effect.succeed(port)});
const service = yield* RemoteOpenTargets.make.pipe(Effect.provide(Layer.mergeAll(spawner,Layer.succeed(NetService.NetService,net),Layer.succeed(HostProcess.Platform,"linux"),Layer.succeed(HostProcess.Hostname,"box"),Layer.succeed(HostProcess.Username,"fixture-user"))));
const targets = yield* service.resolveTargets();
expect(targets).toEqual([]);
expect(probed).toEqual(["127.0.0.1","::1"]);
expect(yield* net.hasListenerOnHost(22,"box.example.ts.net")).toBe(true);
console.log("SSH destinations probed", probed.slice(0,2), "advertised", targets);
}).pipe(Effect.scoped));Observed output:
SSH destinations probed [ '127.0.0.1', '::1' ] advertised []
Test Files 2 passed (2)
Tests 2 passed (2)
Counts include the independent IPv6-endpoint probe run in the same invocation. The SSH test also asserts that its listener fixture reports availability for the MagicDNS SSH destination. Web remote-open selection returns remote-unavailable when neither a saved SSH alias nor an advertised target exists. The picker labels that state No SSH route and disables remote opening.
Interface
UI: Open in editor for a remote environment; API: ServerConfig.remoteOpenTargets.
Earliest failure stage
Core processing
Analysis
Source: discovery treats a loopback port-22 connection as the sole evidence for conventional SSH. When both loopbacks fail, only readTailscaleSshEnabled can produce a target, so a conventional sshd bound to a specific nonloopback interface is excluded.
References and duplicate search
searched open and closed issues for sshd interface, SSH ListenAddress, tailnet OpenSSH, and remoteOpenTargets. Closed #11052 covers Tailscale SSH without a localhost listener; its RunSSH branch is present, but conventional OpenSSH bound only to the tailnet still fails. Open #28602 concerns selection among advertised targets, not failure to advertise a valid SSH destination.
Workaround
No workaround was exercised in this investigation. See Impact for the documented alternatives and verification limits.
Activity