196 lines
15 KiB
JavaScript
196 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// Mac side of the Igneum relay (relay/ in this repo, https://relay.igneum.network).
|
|
// node tools/relay.mjs the feed, newest first (last 50)
|
|
// node tools/relay.mjs list [N] [--machine X] more of the feed (100 a call at most)
|
|
// node tools/relay.mjs read <id> print an item; its file is downloaded to --out (default $TMPDIR/igneum-relay)
|
|
// node tools/relay.mjs drop "<text>" | <file> post a note or a file from the Mac [--to PC1] [--title "..."] [--body "..." with a file]
|
|
// node tools/relay.mjs task <machine> "title" [file] [--body "..."] a task for a person or a Claude session on that PC
|
|
// node tools/relay.mjs run <machine> "title" <script.ps1|.sh> [--elevated] [--reboot-continue] [--reboot]
|
|
// a script the igneum-agent runs: signed with ~/.config/igneum/relay-run-key
|
|
// (Ed25519, checked by the relay) and tagged with the machine's secret
|
|
// (~/.config/igneum/relay-machines/<machine>, checked by the agent); X23
|
|
// node tools/relay.mjs start-app <machine> the agent there starts the installed Igneum Miner and reports whether an
|
|
// engine answered (MF-11); signed and tagged like run, body = relay/playbooks/start-app.ps1
|
|
// node tools/relay.mjs keygen make the run key pair once; prints the public key for RELAY_RUN_PUB
|
|
// node tools/relay.mjs secret <machine> make that machine's secret, bind it on the relay, then make-clients.sh --machine
|
|
// node tools/relay.mjs watch [--since <id>] poll every 10 s and print new items (results included)
|
|
// node tools/relay.mjs inbox <machine> [--ack] what that machine has not read yet (--ack marks it read, a POST)
|
|
// node tools/relay.mjs machines | role <name> <miner|prover|bench|mac|phone> | name <hostname> <name>
|
|
// node tools/relay.mjs ack <id> | done <id> | rm <id> | url
|
|
// Reads ~/.config/igneum/relay-token (sent as the x-relay-token header, never in a URL: X24), relay-run-key,
|
|
// relay-machines/<name>, dl-token (only to refuse a body that carries it: X26) and relay-url (optional, default
|
|
// https://relay.igneum.network). Zero dependencies.
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, chmodSync } from 'node:fs';
|
|
import { homedir, tmpdir, hostname } from 'node:os';
|
|
import { basename, join, resolve, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { keygen as edKeygen, signRun, runCanon, machineTag, newNonce, newSecret, secretHash } from '../relay/lib/guard.mjs';
|
|
|
|
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
|
|
const CFG = join(homedir(), '.config', 'igneum');
|
|
const cfg = n => { try { return readFileSync(join(CFG, n), 'utf8').trim(); } catch { return ''; } };
|
|
const TOKEN = cfg('relay-token'); const DL = cfg('dl-token');
|
|
const BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(/\/$/, '');
|
|
if (!TOKEN) { console.error('no ~/.config/igneum/relay-token'); process.exit(1); }
|
|
const API = `${BASE}/api/relay?fn=`; // the function itself; the token travels in the header
|
|
const WEB = `${BASE}/r/${TOKEN}`; // the phone's page: the one place the token stays in the path
|
|
const SHOWN = `${BASE}/r/<token>`; // printed in place of WEB everywhere but `url` (round 4, X24: the token in every terminal)
|
|
const HEADERS = { 'x-relay-token': TOKEN };
|
|
|
|
const argv = process.argv.slice(2);
|
|
const flags = {}; const pos = [];
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--') && !['elevated', 'reboot-continue', 'reboot', 'ack', 'all', 'rotate'].includes(k)) { flags[k] = next; i++; } else flags[k] = true; }
|
|
else pos.push(a);
|
|
}
|
|
const cmd = pos[0] && !/^\d+$/.test(pos[0]) ? pos[0] : (pos[0] ? 'read' : 'list');
|
|
if (cmd === 'read' && /^\d+$/.test(pos[0])) pos.unshift('read');
|
|
|
|
async function api(fn, { q, body } = {}) {
|
|
const r = await fetch(API + fn + (q ? '&' + new URLSearchParams(q) : ''), body === undefined ? { headers: HEADERS }
|
|
: { method: 'POST', headers: { 'Content-Type': 'application/json', ...HEADERS }, body: JSON.stringify(body) });
|
|
const j = await r.json().catch(() => ({ ok: false, error: `http ${r.status}` }));
|
|
if (!r.ok || j.ok === false) throw new Error(j.error || `http ${r.status}`);
|
|
return j;
|
|
}
|
|
async function uploadFile(path) {
|
|
const buf = readFileSync(path); const name = basename(path);
|
|
const t = await api('upload', { body: { name, size: buf.length } });
|
|
if (buf.length > t.max) throw new Error(`${name} is ${buf.length} bytes, over the ${t.max} byte cap`);
|
|
const r = await fetch(t.put_url, { method: 'PUT', body: buf, headers: { authorization: `Bearer ${t.token}`, 'x-api-version': t.api_version, 'x-add-random-suffix': '1', 'x-content-type': 'application/octet-stream' } });
|
|
const j = await r.json().catch(() => ({}));
|
|
if (!r.ok || !j.url) throw new Error(`blob upload failed: ${r.status} ${JSON.stringify(j).slice(0, 200)}`);
|
|
return { file_name: name, file_url: j.url, size: buf.length };
|
|
}
|
|
const fmtSize = n => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
const when = ts => new Date(ts).toISOString().replace('T', ' ').slice(5, 16);
|
|
function line(it) {
|
|
const route = it.to === 'all' ? it.from : `${it.from} > ${it.to}`;
|
|
const st = it.done ? ' done' : it.read && (it.kind === 'task' || it.kind === 'run') ? ' read' : (it.kind === 'task' || it.kind === 'run') ? ' NEW' : '';
|
|
const first = (it.body || '').split('\n').find(l => l.trim()) || '';
|
|
const file = it.has_file ? ` [${it.file_name} ${fmtSize(it.size)}]` : '';
|
|
return `${('#' + it.id).padStart(5)} ${when(it.ts)} ${it.kind.padEnd(6)} ${route.padEnd(12)} ${it.title ? it.title + (first ? ': ' : '') : ''}${first.slice(0, 90)}${file}${st}`;
|
|
}
|
|
function printItem(it) {
|
|
console.log(`===== #${it.id} ${it.kind} from ${it.from} to ${it.to} at ${it.ts}${it.title ? ` | ${it.title}` : ''} =====`);
|
|
const fl = Object.entries(it.flags || {}).filter(([, v]) => v !== false).map(([k, v]) => v === true ? k : `${k}=${v}`).join(' ');
|
|
if (fl || it.task_id) console.log(`[${[fl, it.task_id ? `task #${it.task_id}` : '', it.done ? 'done' : it.read ? 'read' : ''].filter(Boolean).join(' | ')}]`);
|
|
if (it.body) process.stdout.write(it.body.endsWith('\n') ? it.body : it.body + '\n');
|
|
}
|
|
const outDir = () => { const d = resolve(flags.out || process.env.RELAY_DOWNLOAD_DIR || join(tmpdir(), 'igneum-relay')); mkdirSync(d, { recursive: true }); return d; };
|
|
async function download(it) {
|
|
const r = await fetch(`${API}file&id=${it.id}`, { headers: HEADERS, redirect: 'follow' });
|
|
if (!r.ok) throw new Error(`download http ${r.status}`);
|
|
const buf = Buffer.from(await r.arrayBuffer());
|
|
const p = join(outDir(), `${it.id}-${it.file_name || 'file'}`);
|
|
writeFileSync(p, buf); return p;
|
|
}
|
|
// X26: the body is posted as written. The downloads base reaches the script as $env:RELAY_DL_BASE (RELAY_DL_BASE in
|
|
// bash), a value the agent holds; a body that still says __DL_BASE__ or carries the dl token is refused here.
|
|
function playbook(path) {
|
|
const s = readFileSync(path, 'utf8');
|
|
if (/__DL_BASE__/.test(s)) throw new Error(`${path} uses __DL_BASE__; write $env:RELAY_DL_BASE (PowerShell) or $RELAY_DL_BASE (bash) instead, the agent fills it in`);
|
|
if (DL && s.includes(DL)) throw new Error(`${path} carries the dl token; it must never be in a task body`);
|
|
return s;
|
|
}
|
|
const RUN_KEY = join(CFG, 'relay-run-key');
|
|
const machineSecretFile = m => join(CFG, 'relay-machines', m);
|
|
// a run task: nonce, the machine's HMAC tag, the Ed25519 signature (relay/lib/guard.mjs, the same code the relay runs)
|
|
function signRunTask(o) {
|
|
const seed = cfg('relay-run-key');
|
|
if (!/^[0-9a-f]{64}$/.test(seed)) throw new Error(`no run key at ${RUN_KEY}: node tools/relay.mjs keygen (then set RELAY_RUN_PUB on the relay project)`);
|
|
let secret = '';
|
|
try { secret = readFileSync(machineSecretFile(o.to), 'utf8').trim(); } catch {}
|
|
if (!/^[0-9a-f]{64}$/.test(secret)) throw new Error(`no machine secret for ${o.to}: node tools/relay.mjs secret ${o.to} first, then relay/clients/make-clients.sh --machine ${o.to}`);
|
|
o.flags.nonce = newNonce();
|
|
o.flags.mac = machineTag(secret, runCanon({ to: o.to, nonce: o.flags.nonce, body: o.body, flags: o.flags }));
|
|
o.flags.sig = signRun(runCanon({ to: o.to, nonce: o.flags.nonce, body: o.body, flags: o.flags }), seed);
|
|
return o;
|
|
}
|
|
|
|
try {
|
|
if (cmd === 'url') { console.log(WEB); }
|
|
else if (cmd === 'list') {
|
|
const n = Number(pos[1]) || 50; const q = { limit: n }; if (flags.machine) q.machine = flags.machine;
|
|
const j = await api('feed', { q });
|
|
const waiting = j.machines.filter(m => m.unread).map(m => `${m.name} ${m.unread}`).join(', ');
|
|
console.log(`${SHOWN}\nmachines: ${j.machines.map(m => `${m.name}${m.role ? '/' + m.role : ''}${m.named === false ? ' (unnamed, hostname ' + m.hostname + ')' : ''}${m.last_seen ? ' seen ' + when(m.last_seen) : ''}`).join(' | ')}${waiting ? `\nwaiting: ${waiting}` : ''}`);
|
|
if (!j.items.length) console.log('no items yet');
|
|
for (const it of j.items) console.log(line(it));
|
|
}
|
|
else if (cmd === 'read') {
|
|
const it = (await api('item', { q: { id: pos[1] } })).item; printItem(it);
|
|
if (it.has_file) console.log(`file: ${await download(it)}`);
|
|
}
|
|
else if (cmd === 'drop') {
|
|
const what = pos[1]; if (!what) throw new Error('drop "<text>" or drop <file>');
|
|
const o = { from: flags.from || 'Mac', to: flags.to || 'all', title: flags.title || '' };
|
|
if (existsSync(what) && statSync(what).isFile()) Object.assign(o, await uploadFile(what), { kind: 'file', body: typeof flags.body === 'string' ? flags.body : '' }); else { o.body = what; o.kind = flags.kind || 'text'; }
|
|
const r = await api('drop', { body: o }); console.log(`sent #${r.id} (${r.kind})`);
|
|
}
|
|
else if (cmd === 'task' || cmd === 'run') {
|
|
const [, to, title, file] = pos;
|
|
if (!to || !title) throw new Error(`${cmd} <machine> "title" ${cmd === 'run' ? '<script>' : '[file]'}`);
|
|
const o = { from: flags.from || 'Mac', to, title, kind: cmd, body: flags.body || '', flags: {} };
|
|
if (cmd === 'run') {
|
|
if (!file || !existsSync(file)) throw new Error('run needs a script file (.ps1 for Windows, .sh for the Mac)');
|
|
o.body = playbook(file); o.flags = { elevated: !!flags.elevated, reboot_continue: !!flags['reboot-continue'], reboot: !!flags.reboot || !!flags['reboot-continue'], shell: file.endsWith('.sh') ? 'bash' : 'powershell', script: basename(file) };
|
|
signRunTask(o);
|
|
} else if (file) { if (!existsSync(file)) throw new Error(`no such file ${file}`); Object.assign(o, await uploadFile(file)); }
|
|
const r = await api('task', { body: o }); console.log(`queued #${r.id} ${cmd} for ${to}: ${title}`);
|
|
}
|
|
else if (cmd === 'start-app') {
|
|
const to = pos[1]; if (!to) throw new Error('start-app <machine>');
|
|
const body = playbook(join(dirname(fileURLToPath(import.meta.url)), '..', 'relay', 'playbooks', 'start-app.ps1'));
|
|
const o = { from: flags.from || 'Mac', to, title: flags.title || 'start the installed Igneum Miner', kind: 'start-app', body, flags: { elevated: false, reboot_continue: false, reboot: false, shell: 'powershell', script: 'start-app.ps1' } };
|
|
signRunTask(o);
|
|
const r = await api('task', { body: o }); console.log(`queued #${r.id} start-app for ${to}: the agent starts the installed app and reports in about a minute (node tools/relay.mjs watch)`);
|
|
}
|
|
else if (cmd === 'keygen') {
|
|
if (existsSync(RUN_KEY)) throw new Error(`${RUN_KEY} exists; not overwriting a signing key`);
|
|
const { seed, pub } = edKeygen();
|
|
mkdirSync(CFG, { recursive: true });
|
|
writeFileSync(RUN_KEY, seed + '\n', { mode: 0o600 }); chmodSync(RUN_KEY, 0o600);
|
|
writeFileSync(RUN_KEY + '.pub', pub + '\n', { mode: 0o644 });
|
|
console.log(`run key written: ${RUN_KEY} (0600) and ${RUN_KEY}.pub\npublic key ${pub}\nnext: set RELAY_RUN_PUB to that value on the Vercel project igneum-relay (relay/README.md, "Rotation"); until then every run task is refused`);
|
|
}
|
|
else if (cmd === 'secret') {
|
|
const m = pos[1]; if (!m || !/^[\w.-]{1,80}$/.test(m)) throw new Error('secret <machine>');
|
|
const f = machineSecretFile(m);
|
|
if (existsSync(f) && !flags.rotate) throw new Error(`${f} exists; pass --rotate to replace it (the old zip on that PC stops being accepted)`);
|
|
const secret = newSecret();
|
|
mkdirSync(dirname(f), { recursive: true, mode: 0o700 });
|
|
writeFileSync(f, secret + '\n', { mode: 0o600 }); chmodSync(f, 0o600);
|
|
await api('secret', { body: { name: m, secret_hash: secretHash(secret) } });
|
|
console.log(`${m}: secret written to ${f} (0600) and its sha256 bound on the relay\nnext: relay/clients/make-clients.sh --machine ${m}, carry the zip to ${m} by hand (never through the downloads host), start igneum-agent.bat there`);
|
|
}
|
|
else if (cmd === 'inbox') {
|
|
const machine = pos[1]; if (!machine) throw new Error('inbox <machine>');
|
|
const j = flags.ack ? await api('inbox', { body: { machine, kind: 'all', ack: true } }) : await api('inbox', { q: { machine } });
|
|
if (!j.items.length) console.log(`nothing waiting for ${machine}`); for (const it of j.items) printItem(it);
|
|
}
|
|
else if (cmd === 'machines') { for (const m of (await api('machines')).machines) console.log(`${m.name.padEnd(10)} ${(m.role || '-').padEnd(8)} ${(m.hostname || '-').padEnd(18)} ${m.named === false ? 'UNNAMED ' : ''}${m.last_seen ? 'seen ' + when(m.last_seen) : 'never seen'}${m.info && m.info.gpus ? ' gpu ' + [].concat(m.info.gpus).join(', ') : ''}${m.info && m.info.wsl ? ' wsl ' + m.info.wsl : ''}`); }
|
|
else if (cmd === 'role') { const r = await api('role', { body: { name: pos[1], role: pos[2] } }); console.log(`${r.name} is now ${r.role}`); }
|
|
else if (cmd === 'name') { const r = await api('name', { body: { hostname: pos[1], name: pos[2] } }); console.log(`${r.hostname} is now ${r.name}`); }
|
|
else if (cmd === 'ack') { await api('ack', { body: { ids: [Number(pos[1])] } }); console.log(`#${pos[1]} read`); }
|
|
else if (cmd === 'done') { await api('done', { body: { id: Number(pos[1]) } }); console.log(`#${pos[1]} done`); }
|
|
else if (cmd === 'rm') { await api('delete', { body: { id: Number(pos[1]) } }); console.log(`#${pos[1]} deleted`); }
|
|
else if (cmd === 'watch') {
|
|
let since = Number(flags.since) || (await api('feed', { q: { limit: 1 } })).items[0]?.id || 0;
|
|
console.log(`watching ${SHOWN} from #${since} (every 10 s, Ctrl+C to stop)`);
|
|
for (;;) {
|
|
try {
|
|
const j = await api('feed', { q: { since } });
|
|
for (const it of j.items.reverse()) {
|
|
console.log(line(it));
|
|
if (it.kind === 'result' || it.kind === 'text') { const tail = (it.body || '').split('\n').slice(-12).join('\n'); if (tail.trim() && it.body.split('\n').length > 1) console.log(tail.replace(/^/gm, ' ')); }
|
|
if (it.has_file && (flags.download || it.kind === 'result')) { try { console.log(` file: ${await download(it)}`); } catch (e) { console.log(` file download failed: ${e.message}`); } }
|
|
since = Math.max(since, it.id);
|
|
}
|
|
} catch (e) { console.log(`poll failed: ${e.message}`); }
|
|
await new Promise(r => setTimeout(r, 10000));
|
|
}
|
|
}
|
|
else throw new Error(`unknown command ${cmd}`);
|
|
} catch (e) { console.error(e.message); process.exit(1); }
|