mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix(remote): a proxied host is reachability-unknown; scope remote: SSE per session
Review round 2 on #439. 1. The bare TCP probe connects to host:port, which a host behind a jump host or SOCKS proxy does not answer even while ssh works. Acting on that verdict drew a permanent banner over a healthy session, replaced a real "needs tmux" error with "not reachable" in quick-start, and - with a wake target - buffered every HTTP input for the life of the session, since the readiness poll could never succeed. `WakeableRemote` now carries `jumpHost`/`socksProxy`/`extraSshOptions`, and `isProbeable()` turns such a host into reachability-UNKNOWN: input is delivered, `checkReachable` / `checkHostReachable` answer `null` (never `false`), `ensureHostAwake` returns `'unprobeable'` (handled like `'no-target'`), the quick-start gate fires on `=== false` only, and `GET …/reachability` reports `reachable: null, probeable: false` so the banner has nothing to key on. A wake target can still be fired for it, blind: no readiness poll, no reattach, no toast - the response says only whether the packet went out. 2. `'remote:'` joins the session-scoped SSE prefixes. The create/attach wake has no session yet, so the registry names the requesting user (`ensureHostAwake({ requestedBy })` -> `username` in the payload) and `deriveSseHint` routes on it; with neither it fails closed to admins. Single-user mode is unaffected. Smaller, from the same review: - A flush write that fails now drops the remaining buffer (logged) instead of retaining it: the wake still resolved and marked the host reachable, so the retained chunk waited for the NEXT wake and was replayed hours later, after everything typed since. Same policy as the oversized paste. - The banner polls on tab activation (a user action) and on its 30 s timer only for a host with a wake target; a timer connecting to a host Codeman cannot wake is the traffic invariant #2 rejects keepalives for. A proxied host is never polled. - `probeRemoteHostReachable`, `runRemoteWakeCommand` and the default UDP socket refuse under VITEST, as remote-files.ts does. The guard caught a leak on the spot: `createDefaultRemoteWakeDeps({ probe })` overrode the probe but still polled readiness with the real one, so the shutdown test had been connecting to a production address. The poll now uses the injected probe. - docs/remote-sessions.md is additions only again (the reformatting is gone); the architecture-invariants overlap resolved itself in the merge. Live, against a throwaway instance with a non-routable ghost host: proxied -> no probe, no wake, the genuine ssh error after 10 s; direct (control) -> probe, magic packet, "did not come back" after the 40 s budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdGP4jUTjc9J2RYYykDrCG
This commit is contained in:
co-authored by
Claude Opus 5
parent
e271a65e79
commit
1040f6c489
@@ -26,8 +26,12 @@ function fakeElement(): El {
|
||||
return { hidden: false, textContent: '', disabled: false, classList: { add() {}, remove() {} } };
|
||||
}
|
||||
|
||||
const PROXIED_ID = 'remote-session-proxied';
|
||||
const NOWOL_ID = 'remote-session-nowol';
|
||||
|
||||
/** Load `host-wake-ui.js` with the minimal DOM it touches, and return a wired app. */
|
||||
function loadWakeApp() {
|
||||
const fetches: string[] = [];
|
||||
const elements = new Map<string, El>([
|
||||
['hostWakeBanner', fakeElement()],
|
||||
['hostWakeBannerText', fakeElement()],
|
||||
@@ -40,7 +44,10 @@ function loadWakeApp() {
|
||||
console,
|
||||
setInterval: () => 1,
|
||||
clearInterval: () => {},
|
||||
fetch: () => Promise.resolve({ json: () => Promise.resolve({ success: false }) }),
|
||||
fetch: (url: string) => {
|
||||
fetches.push(url);
|
||||
return Promise.resolve({ json: () => Promise.resolve({ success: false }) });
|
||||
},
|
||||
document: {
|
||||
visibilityState: 'visible',
|
||||
getElementById: (id: string) => elements.get(id) ?? null,
|
||||
@@ -58,9 +65,27 @@ function loadWakeApp() {
|
||||
REMOTE_ID,
|
||||
{ remote: { hostId: 'hufflepuff', host: '192.168.50.137', label: 'Hufflepuff', wakeMac: '04:d9:f5:80:c6:58' } },
|
||||
],
|
||||
[
|
||||
PROXIED_ID,
|
||||
{
|
||||
remote: {
|
||||
hostId: 'bastioned',
|
||||
host: '10.20.0.5',
|
||||
label: 'Behind bastion',
|
||||
jumpHost: 'bastion',
|
||||
wakeMac: '04:d9:f5:80:c6:58',
|
||||
},
|
||||
},
|
||||
],
|
||||
[NOWOL_ID, { remote: { hostId: 'plain', host: '10.0.0.9', label: 'Plain' } }],
|
||||
[LOCAL_ID, {}],
|
||||
]);
|
||||
return { app, banner: elements.get('hostWakeBanner') as El, text: elements.get('hostWakeBannerText') as El };
|
||||
return {
|
||||
app,
|
||||
fetches,
|
||||
banner: elements.get('hostWakeBanner') as El,
|
||||
text: elements.get('hostWakeBannerText') as El,
|
||||
};
|
||||
}
|
||||
|
||||
describe('host wake banner visibility', () => {
|
||||
@@ -120,3 +145,40 @@ describe('host wake banner visibility', () => {
|
||||
expect(banner.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('host wake banner polling', () => {
|
||||
// Each poll is a TCP connect to the host from the server. The timer is the one
|
||||
// trigger that is not a user action, so it must not fire for a host Codeman could
|
||||
// not wake anyway (it cannot wake it, but it can keep an activity-based suspend timer
|
||||
// from firing), and a proxied host is never polled: the probe cannot reach it.
|
||||
const tick = (app: Record<string, unknown>, periodic: boolean) =>
|
||||
(app._hostWakeTick as (o: { periodic: boolean }) => void)({ periodic });
|
||||
|
||||
it('polls a wake-configured host on activation and on the timer', () => {
|
||||
const { app, fetches } = loadWakeApp();
|
||||
app.activeSessionId = REMOTE_ID;
|
||||
tick(app, false);
|
||||
tick(app, true);
|
||||
tick(app, true);
|
||||
expect(fetches).toHaveLength(3);
|
||||
expect(fetches[0]).toContain(`/api/sessions/${REMOTE_ID}/reachability`);
|
||||
});
|
||||
|
||||
it('polls a host without a wake target once on activation, never on the timer', () => {
|
||||
const { app, fetches } = loadWakeApp();
|
||||
app.activeSessionId = NOWOL_ID;
|
||||
tick(app, false);
|
||||
tick(app, true);
|
||||
tick(app, true);
|
||||
expect(fetches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never polls a host behind a jump host or SOCKS proxy', () => {
|
||||
const { app, fetches } = loadWakeApp();
|
||||
app.activeSessionId = PROXIED_ID;
|
||||
tick(app, false);
|
||||
tick(app, true);
|
||||
expect(fetches).toHaveLength(0);
|
||||
expect((app._hostWake as { probeable: boolean }).probeable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user