Skip to content

[Bug]: IPv4-mapped loopback SSH device targets are saved as remote hosts #35762

Closed

Description

@coygeek

Summary

Adding this machine as an SSH device host through an IPv4-mapped loopback address bypasses the self-host filter. T3 keeps it as a remote device source and takes the SSH probe path instead of using the existing local source. A local reproduction using the production filter and real OpenSSH configuration output showed that ordinary loopback addresses are removed while the mapped equivalent is retained.

Before submitting

  • I searched existing issues and did not find an exact duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Use a checkout of the stated commit with its pinned dependencies installed and Node capable of running TypeScript directly.
  2. Run the script below. It invokes the production isLocalSshDeviceHost and remoteSshDeviceHosts functions for normal loopback, expanded IPv6 loopback, mapped loopback, and a documentation-only nonlocal address.
  3. Observe that the mapped-loopback row has local:false and savedHosts:1, even though the mapped-address connection reaches a listener bound to IPv4 loopback.

The final isolated reproduction failed deterministically in two runs. An earlier runtime attempt with Bun could not import the repository's node:sea dependency; it supplied no behavioral evidence. The final script uses Node. Its OpenSSH subprocess wrapper only adds -F /dev/null and refuses commands without -G, so the test reads no personal SSH configuration and opens no SSH connection.

Save the following as repro-local-ipv6.ts outside the checkout and run node repro-local-ipv6.ts /path/to/checkout. It needs the checkout dependencies, Node with native TypeScript support, Git, and /usr/bin/ssh on a POSIX system with mapped-IPv6 socket support. It creates isolated HOME, TMPDIR and T3 state directories plus an ephemeral local TCP listener, removes its temporary files, and leaves the checkout unchanged. Exit 1 means the defect was reproduced, 0 means all tested local forms are filtered correctly, and 2 means setup or a control failed.

Reproduction script: repro-local-ipv6.ts
import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { createServer, connect } from 'node:net';
const checkout = resolve(process.argv[2] ?? '.');
const requireFromRepo = createRequire(join(checkout, 'apps/server/package.json'));
const scratch = mkdtempSync(join(tmpdir(), 't3-ssh-self-'));
let status = 2;
try {
  const version = spawnSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], {encoding:'utf8'});
  console.log('checkout:', version.stdout.trim());
  console.log('Node:', process.version);
  console.log(spawnSync('git', ['--version'], {encoding:'utf8'}).stdout.trim());
  console.log(spawnSync('/usr/bin/ssh', ['-V'], {encoding:'utf8'}).stderr.trim());
  for (const name of ['home', 'tmp', 'state']) mkdirSync(join(scratch, name));
  process.env.HOME = join(scratch, 'home');
  process.env.TMPDIR = join(scratch, 'tmp');
  process.env.T3CODE_HOME = join(scratch, 'state');
  const Effect = await import(requireFromRepo.resolve('effect/Effect'));
  const NodeServices = await import(requireFromRepo.resolve('@effect/platform-node/NodeServices'));
  const { ChildProcess, ChildProcessSpawner } = await import(requireFromRepo.resolve('effect/process'));
  const { isLocalSshDeviceHost, remoteSshDeviceHosts } = await import(join(checkout, 'apps/server/src/device/localSshDeviceHost.ts'));
  const makeHost = (target: string) => ({kind:'ssh' as const,id:'fixture',label:'Fixture',target});
  const server = createServer(socket => socket.end('same-local-endpoint'));
  await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
  try {
    const address = server.address();
    if (!address || typeof address === 'string') throw new Error('No local test port');
    const marker = await new Promise<string>((resolve, reject) => {
      const socket = connect({host:'::ffff:127.0.0.1', port:address.port});
      let data = '';
      socket.on('data', chunk => data += chunk);
      socket.on('end', () => resolve(data));
      socket.on('error', reject);
    });
    if (marker !== 'same-local-endpoint') throw new Error('Mapped address control failed');
    console.log('mapped address reached the IPv4 loopback listener: true');
  } finally { await new Promise<void>(resolve => server.close(() => resolve())); }
  const nativeSpawner = await Effect.runPromise(Effect.service(ChildProcessSpawner.ChildProcessSpawner).pipe(Effect.provide(NodeServices.layer)));
  const isolatedSpawner = ChildProcessSpawner.make(command => {
    if (command._tag !== 'StandardCommand' || !command.args.includes('-G')) return Effect.die('Network forbidden');
    return nativeSpawner.spawn(ChildProcess.make('/usr/bin/ssh', ['-F', '/dev/null', ...command.args], command.options));
  });
  const isolated = effect => effect.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, isolatedSpawner), Effect.provide(NodeServices.layer));
  const rows = [];
  for (const target of ['::1', '127.0.0.1', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1', '192.0.2.1']) {
    const local = await Effect.runPromise(isolated(isLocalSshDeviceHost(makeHost(target))));
    const kept = await Effect.runPromise(isolated(remoteSshDeviceHosts([makeHost(target)])));
    rows.push({target, local, savedHosts:kept.length});
  }
  rows.forEach(row => console.log(JSON.stringify(row)));
  const control = rows[0].local && rows[1].local && !rows[4].local;
  const fails = !rows[2].local || !rows[3].local;
  status = !control ? 2 : fails ? 1 : 0;
} catch(error) { console.error(String(error)); }
finally {
  rmSync(scratch, {recursive:true,force:true});
  console.log(status === 1 ? 'RESULT: defect reproduced' : status === 0 ? 'RESULT: expected behavior' : 'RESULT: inconclusive');
  process.exitCode = status;
}

Expected behavior

A direct port-22 SSH destination that names this machine must be skipped as a remote device source and use the existing local-host result. The editor explicitly promises that same-machine hosts are skipped in apps/web/src/components/settings/DeviceHostEditor.tsx#L105-L112; the filter's contract is to drop SSH hosts resolving to this machine in apps/server/src/device/localSshDeviceHost.ts#L66-L76. The mapped-address equivalence is supported by the actual loopback TCP control and RFC 4291 section 2.5.5.2.

Actual behavior

Reproduced at the service boundary: isLocalSshDeviceHost returns false for an unproxied port-22 target ::ffff:127.0.0.1, and remoteSshDeviceHosts retains it. The same functions return true and remove 127.0.0.1 and ::1. A separate ephemeral TCP listener on IPv4 loopback received a connection made through the mapped address, confirming that it names the same local endpoint in this environment. Source inspection establishes the supported entry point: the device-host editor accepts an SSH target, the settings RPC applies this filter before saving, and device probing calls SSH whenever the predicate returns false. No GUI session or remote SSH connection was run.

Impact

Minor bug or occasional failure

Impact: P2 - A valid same-machine SSH target is retained as a separate remote source and enters unnecessary SSH probing and host setup instead of the existing local source. The affected input is a mapped IPv4 address without a proxy or nonstandard port.

The retained filter result and local TCP equivalence were directly observed; the settings-save and probe consequences follow the cited source paths. Duplicate device display and a real SSH bootstrap were not exercised. A verified workaround is to use the ordinary 127.0.0.1 or ::1 spelling, which the same run removes. Other operating systems were not run.

Version or commit

main @ 0bb6eaa546c91915efbd3f092088c694f3949709 (the inspected checkout); the latest published release was v0.1.9, but the packaged release was not run.

Environment

Node v26.12.1; Git 2.56.0; OpenSSH_10.5p1 with LibreSSL 3.3.6; Darwin 27.2.0, arm64. Dependencies are the checkout's lockfile versions, including Effect 4.1.3 and @effect/platform-node 4.1.2.

Interface: UI/API: Device host editor and server.updateSettings; production device-host filtering functions reproduced directly.

Earliest failure stage: Setup or configuration.

Logs or stack traces

Evidence provenance: observed (direct module reproductions), with static source tracing for the UI/RPC call paths. Local verification: reproduced. Reproduction completeness: complete at the tested service boundary. Missing fact: packaged-release and end-to-end GUI behavior were not run; other platforms were not tested.

Expected source: the cited existing T3 behavior contract. Failure source: the exact script and output below, with the cited production call paths.

Observed output from the final isolated script, with no personal data:

checkout: 0bb6eaa546c91915efbd3f092088c694f3949709
Node: v26.12.1
git version 2.56.0
OpenSSH_10.5p1, LibreSSL 3.3.6
mapped address reached the IPv4 loopback listener: true
{"target":"::1","local":true,"savedHosts":0}
{"target":"127.0.0.1","local":true,"savedHosts":0}
{"target":"0:0:0:0:0:0:0:1","local":true,"savedHosts":0}
{"target":"::ffff:127.0.0.1","local":false,"savedHosts":1}
{"target":"192.0.2.1","local":false,"savedHosts":1}
RESULT: defect reproduced

The literal-address comparison recognizes only interface-address string equality, ::1, or a 127. prefix in apps/server/src/device/localSshDeviceHost.ts#L45-L63. The settings RPC saves the filtered value in apps/server/src/ws.ts#L2655-L2666; the device probe skips SSH only for a positive local result in apps/server/src/device/DeviceService.ts#L1390-L1396; reconciliation repeats the same filter in apps/server/src/device/DeviceService.ts#L1461-L1466.

Restoration check

The script must return local:true and savedHosts:0 for the mapped-loopback row while continuing to retain the nonlocal control. The settings-save and Test connection paths must treat that target like the ordinary-loopback controls.

Workaround

Use 127.0.0.1 or ::1 as the same-machine SSH target; both are removed by the production filter in the control run.

References

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