mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(remote): host-unreachable banner, manual wake, and native MAC wake-on-LAN
The reactive wake (typing into a session whose host slept) left the state invisible: nothing told the user the machine was asleep, and with no wake target configured there was nothing to do about it. Adds: - RemoteHost.wakeMac (comma-separated) - Codeman builds and broadcasts the magic packet itself (UDP port 9), so the common case needs no external script. The existing wakeCommand stays as the explicit override. - GET /api/sessions/:id/reachability - probes (throttled, cached, and it never wakes) and reports HOW the host can be woken, or that nothing is configured. - POST /api/sessions/:id/wake - wakes, waits, reattaches the pane and flushes buffered input; 400 with a routable message when no target is configured. - The amber host-unreachable banner + its 'Wake' / 'Configure WoL' action, and a small config dialog that saves via PUT /api/remote-hosts/:id. - RemoteWakeDeps.resolveRemote: host config is re-resolved for LIVE sessions (throttled + cached), so saving the dialog takes effect without a restart.
This commit is contained in:
@@ -115,6 +115,15 @@ describe('remote-hosts domain', () => {
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/sh$(id)' }).success).toBe(false);
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/`id`' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts one or more MAC addresses and rejects anything else', () => {
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04:d9:f5:80:c6:58' }).success).toBe(true);
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04-d9-f5-80-c6-58, 1C:61:B4:20:58:EB' }).success).toBe(
|
||||
true
|
||||
);
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04:d9:f5:80:c6' }).success).toBe(false);
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeMac: '04:d9:f5:80:c6:58; rm -rf /' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rehydrateRemoteHostFields', () => {
|
||||
@@ -152,6 +161,15 @@ describe('remote-hosts domain', () => {
|
||||
expect(rehydrateRemoteHostFields(remote, hosts())?.wakeCommand).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refreshes a MAC that only exists in the host config', () => {
|
||||
const withMac = new Map(
|
||||
hosts()
|
||||
.entries()
|
||||
.map(([id, host]) => [id, { ...host, wakeMac: '04:d9:f5:80:c6:58' }] as const)
|
||||
);
|
||||
expect(rehydrateRemoteHostFields(persisted, withMac)?.wakeMac).toBe('04:d9:f5:80:c6:58');
|
||||
});
|
||||
|
||||
it('leaves the block untouched when the host is gone or the session is local', () => {
|
||||
expect(rehydrateRemoteHostFields(persisted, new Map())).toBe(persisted);
|
||||
expect(rehydrateRemoteHostFields(undefined, hosts('/x'))).toBeUndefined();
|
||||
|
||||
+108
-4
@@ -19,7 +19,11 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
RemoteWakeRegistry,
|
||||
appendBoundedPending,
|
||||
buildMagicPacket,
|
||||
decideRemoteInputAction,
|
||||
parseMacList,
|
||||
resolveWakeTarget,
|
||||
wakeConfigured,
|
||||
REMOTE_WAKE_PENDING_MAX_BYTES,
|
||||
type RemoteWakeDeps,
|
||||
type WakeableRemote,
|
||||
@@ -29,10 +33,10 @@ import {
|
||||
// ========== Pure decisions ==========
|
||||
|
||||
describe('decideRemoteInputAction', () => {
|
||||
const base = { hasWakeCommand: true, waking: false, probeAgeMs: 0, lastReachable: undefined as boolean | undefined };
|
||||
const base = { hasWakeTarget: true, waking: false, probeAgeMs: 0, lastReachable: undefined as boolean | undefined };
|
||||
|
||||
it('delivers unchanged when the host has no wake command (feature off)', () => {
|
||||
expect(decideRemoteInputAction({ ...base, hasWakeCommand: false, probeAgeMs: Number.MAX_SAFE_INTEGER })).toBe(
|
||||
expect(decideRemoteInputAction({ ...base, hasWakeTarget: false, probeAgeMs: Number.MAX_SAFE_INTEGER })).toBe(
|
||||
'deliver'
|
||||
);
|
||||
});
|
||||
@@ -77,6 +81,59 @@ describe('appendBoundedPending', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MAC parsing + magic packet', () => {
|
||||
it('parses one or more MACs with either separator', () => {
|
||||
expect(parseMacList('04:d9:f5:80:c6:58')).toEqual([[4, 217, 245, 128, 198, 88]]);
|
||||
expect(parseMacList('04-d9-f5-80-c6-58, 1c:61:b4:20:58:eb')).toEqual([
|
||||
[4, 217, 245, 128, 198, 88],
|
||||
[28, 97, 180, 32, 88, 235],
|
||||
]);
|
||||
});
|
||||
|
||||
it('is all-or-nothing so a typo cannot half-arm a host', () => {
|
||||
expect(parseMacList('04:d9:f5:80:c6')).toBeNull();
|
||||
expect(parseMacList('04:d9:f5:80:c6:58, nonsense')).toBeNull();
|
||||
expect(parseMacList('')).toBeNull();
|
||||
expect(
|
||||
parseMacList('04:d9:f5:80:c6:58,1c:61:b4:20:58:eb,aa:bb:cc:dd:ee:ff,11:22:33:44:55:66,99:88:77:66:55:44')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('builds the documented magic packet byte-for-byte', () => {
|
||||
// 6 x 0xFF then the MAC repeated 16 times — a packet off by one byte simply never
|
||||
// wakes anything, so the shape is pinned rather than described.
|
||||
const mac = [4, 217, 245, 128, 198, 88];
|
||||
const packet = buildMagicPacket(mac);
|
||||
expect(packet.length).toBe(6 + 16 * 6);
|
||||
expect([...packet.subarray(0, 6)]).toEqual([255, 255, 255, 255, 255, 255]);
|
||||
for (let repeat = 0; repeat < 16; repeat++) {
|
||||
expect([...packet.subarray(6 + repeat * 6, 12 + repeat * 6)]).toEqual(mac);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves the wake target with the command as the explicit override', () => {
|
||||
const mac = '04:d9:f5:80:c6:58';
|
||||
expect(resolveWakeTarget(undefined)).toBeNull();
|
||||
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1' })).toBeNull();
|
||||
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: mac })).toEqual({
|
||||
kind: 'mac',
|
||||
macs: [[4, 217, 245, 128, 198, 88]],
|
||||
});
|
||||
expect(
|
||||
resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: mac, wakeCommand: '/bin/wake' })
|
||||
).toEqual({ kind: 'command', command: '/bin/wake' });
|
||||
// A malformed MAC (hand-written config) must not arm a broken wake.
|
||||
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: 'nope' })).toBeNull();
|
||||
});
|
||||
|
||||
it('reports which wake path the UI should offer', () => {
|
||||
expect(wakeConfigured(undefined)).toBe('none');
|
||||
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x' })).toBe('none');
|
||||
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x', wakeMac: '04:d9:f5:80:c6:58' })).toBe('mac');
|
||||
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x', wakeCommand: '/bin/wake' })).toBe('command');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Registry ==========
|
||||
|
||||
const remote: WakeableRemote = {
|
||||
@@ -98,7 +155,9 @@ interface Harness {
|
||||
events: string[];
|
||||
}
|
||||
|
||||
function harness(opts: { remote?: WakeableRemote; writesFail?: boolean } = {}): Harness {
|
||||
function harness(
|
||||
opts: { remote?: WakeableRemote; writesFail?: boolean; resolveRemote?: RemoteWakeDeps['resolveRemote'] } = {}
|
||||
): Harness {
|
||||
const probe = vi.fn(async () => false);
|
||||
const wake = vi.fn(async () => true);
|
||||
const waitUntilReady = vi.fn(async () => true);
|
||||
@@ -115,6 +174,7 @@ function harness(opts: { remote?: WakeableRemote; writesFail?: boolean } = {}):
|
||||
noteReconnected,
|
||||
broadcast: (event) => events.push(event),
|
||||
log: () => {},
|
||||
...(opts.resolveRemote ? { resolveRemote: opts.resolveRemote } : {}),
|
||||
};
|
||||
|
||||
const session: WakeableSession = {
|
||||
@@ -185,7 +245,7 @@ describe('RemoteWakeRegistry', () => {
|
||||
releaseWake?.();
|
||||
await h.registry.wake(h.session);
|
||||
|
||||
expect(h.wake).toHaveBeenCalledWith('/home/joe/bin/whuff');
|
||||
expect(h.wake).toHaveBeenCalledWith({ kind: 'command', command: '/home/joe/bin/whuff' });
|
||||
expect(h.reattachRemote).toHaveBeenCalledTimes(1);
|
||||
expect(h.noteReconnected).toHaveBeenCalledWith('sess-1', true);
|
||||
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['hal', 'lo']);
|
||||
@@ -263,6 +323,50 @@ describe('RemoteWakeRegistry', () => {
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||
});
|
||||
|
||||
it('wakes a MAC-configured host by magic packet, with no external command', async () => {
|
||||
const h = harness({
|
||||
remote: { hostId: 'h', label: 'H', host: '10.0.0.9', wakeMac: '04:d9:f5:80:c6:58' },
|
||||
});
|
||||
h.probe.mockResolvedValue(false);
|
||||
await expect(h.registry.handleInput(h.session, 'hi')).resolves.toBe('buffered');
|
||||
await h.registry.wake(h.session);
|
||||
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
|
||||
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['hi']);
|
||||
});
|
||||
|
||||
it('resolves host config for a session that predates it, so a saved MAC works live', async () => {
|
||||
// The persisted `remote` snapshot is taken at launch: without this the banner's
|
||||
// config dialog would only take effect after restarting the session.
|
||||
const resolveRemote = vi.fn(async () => ({
|
||||
hostId: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
wakeMac: '04:d9:f5:80:c6:58',
|
||||
}));
|
||||
const h = harness({
|
||||
remote: { hostId: 'hufflepuff', label: 'Hufflepuff', host: '192.168.50.137' },
|
||||
resolveRemote,
|
||||
});
|
||||
h.probe.mockResolvedValue(false);
|
||||
|
||||
expect(await h.registry.hasWakeTarget(h.session)).toBe(true);
|
||||
await expect(h.registry.handleInput(h.session, 'a')).resolves.toBe('buffered');
|
||||
await h.registry.wake(h.session);
|
||||
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
|
||||
expect(resolveRemote).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Cached: the next keystroke must not re-read the host config.
|
||||
await h.registry.hasWakeTarget(h.session);
|
||||
expect(resolveRemote).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not consult the resolver when the session already has a wake target', async () => {
|
||||
const resolveRemote = vi.fn(async () => undefined);
|
||||
const h = harness({ resolveRemote });
|
||||
expect(await h.registry.hasWakeTarget(h.session)).toBe(true);
|
||||
expect(resolveRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops buffered input with the session', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('POST /api/sessions/:id/input — wake-on-LAN', () => {
|
||||
expect(res.json()).toEqual({});
|
||||
// Nothing reached the pane: writing now would be swallowed by the stalled ssh.
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
expect(h.wake).toHaveBeenCalledWith('/home/joe/bin/whuff');
|
||||
expect(h.wake).toHaveBeenCalledWith({ kind: 'command', command: '/home/joe/bin/whuff' });
|
||||
expect(h.registry.isWaking(SESSION_ID)).toBe(true);
|
||||
|
||||
h.releaseWake();
|
||||
@@ -143,3 +143,64 @@ describe('POST /api/sessions/:id/input — wake-on-LAN', () => {
|
||||
expect(session.writeBuffer).toEqual(['hallo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/reachability', () => {
|
||||
const get = (app: FastifyInstance, url: string) => app.inject({ method: 'GET', url });
|
||||
|
||||
it('reports the probe result and how the host can be woken', async () => {
|
||||
const up = await harness({ hostUp: true });
|
||||
const upBody = (await get(up.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
|
||||
expect(upBody.data.reachable).toBe(true);
|
||||
expect(upBody.data.wakeConfigured).toBe('command');
|
||||
expect(upBody.data.label).toBe('Hufflepuff');
|
||||
|
||||
const down = await harness({ hostUp: false });
|
||||
const downBody = (await get(down.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
|
||||
expect(downBody.data.reachable).toBe(false);
|
||||
// A reachability check is a QUESTION, never an action: the host stays asleep.
|
||||
expect(down.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says nothing can wake a host without a configured target', async () => {
|
||||
const { wakeCommand, ...withoutWake } = remoteSession;
|
||||
const h = await harness({ remote: withoutWake as SessionRemote, hostUp: false });
|
||||
const body = (await get(h.app, `/api/sessions/${SESSION_ID}/reachability`)).json();
|
||||
expect(body.data.reachable).toBe(false);
|
||||
expect(body.data.wakeConfigured).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/wake', () => {
|
||||
const wake = (app: FastifyInstance) => app.inject({ method: 'POST', url: `/api/sessions/${SESSION_ID}/wake` });
|
||||
|
||||
it('wakes the host, reattaches the pane and reports both', async () => {
|
||||
const h = await harness({ hostUp: false });
|
||||
const session = h.ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
const body = (await wake(h.app)).json();
|
||||
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.woke).toBe(true);
|
||||
expect(body.data.reachable).toBe(true);
|
||||
expect(session.reattachRemote).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers with an error the UI can route to the config dialog', async () => {
|
||||
const { wakeCommand, ...withoutWake } = remoteSession;
|
||||
const h = await harness({ remote: withoutWake as SessionRemote, hostUp: false });
|
||||
|
||||
const res = await wake(h.app);
|
||||
const body = res.json();
|
||||
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toMatch(/No wake-on-LAN target/);
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not send a wake when the host answers, but still settles the session', async () => {
|
||||
const h = await harness({ hostUp: true });
|
||||
const body = (await wake(h.app)).json();
|
||||
expect(body.data.woke).toBe(true);
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user