Repository navigation
Description
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
- Use a checkout of the stated commit with its pinned dependencies installed and Node capable of running TypeScript directly.
- Run the script below. It invokes the production
isLocalSshDeviceHostandremoteSshDeviceHostsfunctions for normal loopback, expanded IPv6 loopback, mapped loopback, and a documentation-only nonlocal address. - Observe that the mapped-loopback row has
local:falseandsavedHosts: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
- fix(server): skip device hosts that resolve to the local machine #11698 — introduced the same-machine device-host filter and explains why self-targets should use the existing local source.
- fix(shared): IPv4-mapped loopback addresses count as this device, not LAN #28975 — concerns mapped-loopback classification for saved environment routes in
packages/shared/src/hostClassification.ts; its changed files do not include the independent device-host predicate reported here.
Activity