Skip to content

[Bug]: OpenSSH bound only to the tailnet is omitted from remote editor targets #34739

Closed

Description

@coygeek

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

  1. Use the checkout and dependencies below. Save the test in Relevant output or evidence as apps/server/src/environment/ssh-target-repro.test.ts.
  2. From apps/server, run npx -y pnpm@12.14.0 exec vp test run src/environment/ssh-target-repro.test.ts --reporter=verbose --silent=false.
  3. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions