Relay: text, files and runnable tasks between the Mac, the PCs and the phone (relay.igneum.network)
New Vercel project igneum-relay from relay/: one function (api/relay.mjs) over Neon tables relay_items and relay_machines, files in Vercel Blob store igneum-relay (50 MB client uploads, 4 MB through the function), phone-first web page at /r/<token>/ with the site tokens. Mac CLI tools/relay.mjs (feed, read, drop, task, run, watch, inbox, machines, role, name). Windows clients send.bat/send.ps1 and the igneum-agent (registers hostname, role, GPUs, WSL, nvcc; runs queued PowerShell scripts, posts results, reboot-continue via scheduled task + RunOnce), bash twins send.sh and agent.sh (verified live), playbooks for WSL setup, prover setup, prove-block, miner v4, one-click placeholder. make-clients.sh bakes the secrets into a zip; the repo copies hold placeholders. Screenshots under docs/design/relay. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
6f78579d08
commit
12683c73cd
29 changed files with 1598 additions and 0 deletions
BIN
docs/design/relay/desktop-1280-expanded.png
Normal file
BIN
docs/design/relay/desktop-1280-expanded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 333 KiB |
BIN
docs/design/relay/desktop-1280.png
Normal file
BIN
docs/design/relay/desktop-1280.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
BIN
docs/design/relay/phone-375-expanded.png
Normal file
BIN
docs/design/relay/phone-375-expanded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
BIN
docs/design/relay/phone-375.png
Normal file
BIN
docs/design/relay/phone-375.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
3
relay/.gitignore
vendored
Normal file
3
relay/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.vercel
|
||||
.env*
|
||||
node_modules
|
||||
55
relay/README.md
Normal file
55
relay/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Igneum relay
|
||||
|
||||
Text, files and tasks between the project lead's devices without Gmail: the Mac, PC1, PC2 and the phone post to one feed and read from it. Vercel project `igneum-relay`, served at https://relay.igneum.network. Built 4 October 2026.
|
||||
|
||||
## The secret is the path
|
||||
|
||||
The web page lives at `/r/<token>/` and every API call sits under `/r/<token>/api/<fn>`. The token is 20 base32 characters generated once and stored at `~/.config/igneum/relay-token` on the Mac (and as `RELAY_TOKEN` in the project). Anyone with the link can read and post, so the link stays with the project lead. Scripts may present the log intake key in `x-igneum-key` instead (`RELAY_KEY`, the same value as `~/.config/igneum/log-intake-key`). There is no other login. Blob file URLs carry a random segment and a random suffix; they are not listed anywhere.
|
||||
|
||||
## What is stored where
|
||||
|
||||
| Thing | Where | Limit |
|
||||
|---|---|---|
|
||||
| Items (text, title, who, kind, flags, read and done marks) | Neon table `relay_items` (database `igneum`) | body 1 MB |
|
||||
| Machines (name, hostname, role, GPU and WSL facts, last seen) | Neon table `relay_machines` | |
|
||||
| Files | Vercel Blob store `igneum-relay` (public URLs with random path and suffix, London) | 50 MB per file through a client token; 4 MB when pushed through the function |
|
||||
| The token and key | `~/.config/igneum/relay-token`, `~/.config/igneum/log-intake-key`; project env | never in the repo |
|
||||
|
||||
Kinds: `text` (a note), `file`, `task` (for a person or a Claude session on a PC), `run` (a script the agent executes), `result` (what a task produced, linked by `task_id`). Roles: `miner`, `prover`, `bench`, `mac`, `phone`.
|
||||
|
||||
## API (all under `/r/<token>/api/`)
|
||||
|
||||
| Call | Does |
|
||||
|---|---|
|
||||
| `GET feed?since=&before=&machine=&limit=` | items newest first (200 by default) plus every machine with its unread count |
|
||||
| `GET item?id=` | one item with its full body |
|
||||
| `GET file?id=[&download=1]` | 302 to the file |
|
||||
| `GET inbox?machine=PC1&kind=run|task&ack=1` | unread, not done tasks for that machine; `ack=1` marks them read |
|
||||
| `GET machines` | names, roles, hostnames, last seen |
|
||||
| `POST drop` | JSON `{from,to,kind,title,body,file_name,file_url,size,task_id,flags}`; or raw bytes with `Content-Type: application/octet-stream` and `x-file-name` (4 MB cap) |
|
||||
| `POST task` | same fields; `kind` `task` or `run`; `run` needs one named machine and flags `{elevated, reboot_continue}` |
|
||||
| `POST upload` | `{name,size}` returns a one-hour Blob client token and `put_url`; PUT the bytes there, then `drop` with the returned `url` |
|
||||
| `POST ack {ids}` `POST done {id,exit_code}` `POST delete {id}` | marks |
|
||||
| `POST register {hostname,info}` | a machine checks in; returns its name, role and whether it is named |
|
||||
| `POST name {hostname,name}` `POST role {name,role}` | naming and roles, from the Mac |
|
||||
|
||||
## Mac
|
||||
|
||||
`node tools/relay.mjs` (feed), `read <id>`, `drop "<text>"|<file>`, `task PC2 "title" [file]`, `run PC2 "title" script.ps1 [--elevated] [--reboot-continue]`, `watch`, `inbox PC1`, `machines`, `role PC2 prover`, `name DESKTOP-XYZ PC2`, `ack|done|rm <id>`, `url`. Playbooks live in `relay/playbooks/`; `run` fills `__DL_BASE__` in from `~/.config/igneum/dl-token`.
|
||||
|
||||
## PCs
|
||||
|
||||
`relay/clients/make-clients.sh` bakes the URL, key and token into copies of the clients and writes `~/Desktop/igneum-relay-clients.zip`. Unzip anywhere on the PC. `send.bat` for people and Claude sessions (see `CLAUDE-PC.md`), `igneum-agent.bat` for the automatic runner: double-click once, leave it open. It registers the PC (hostname, GPUs, WSL, nvcc), polls every 20 s, runs each `run` task in order, posts a `result` (exit code, last 64 KB inline, full log as a file when longer) and marks it done. A script that prints `RELAY-REBOOT` triggers `shutdown /r /t 10`; with `reboot_continue` the agent re-arms (scheduled task at logon with highest privileges, RunOnce as a fallback) and re-runs the task after the restart with `RELAY_PASS` incremented. The PC must sign in by itself for that to be unattended.
|
||||
|
||||
An unknown hostname that registers appears in the feed with a "name this machine" box, or `node tools/relay.mjs name <hostname> PC2`. PC1 is DESKTOP-KMCV30N.
|
||||
|
||||
## Deploy
|
||||
|
||||
```
|
||||
cd relay && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes --scope igneum
|
||||
```
|
||||
Env on the project: `DATABASE_URL`, `RELAY_KEY`, `RELAY_TOKEN`, `BLOB_READ_WRITE_TOKEN` (added by `vercel blob create-store`). DNS: `relay` CNAME `cname.vercel-dns.com` in the deSEC zone.
|
||||
|
||||
## Untested until a PC runs it (4 Oct 2026)
|
||||
|
||||
`send.ps1`, `igneum-agent.ps1` and the five PowerShell playbooks were written and syntax-reviewed on the Mac (no `pwsh` here). The bash twin `agent.sh` and `send.sh` ran end to end against the live relay. Expect a first-run fix on Windows: `Start-Process -Wait` exit codes through the wrapper, `wsl --install --no-launch` on pass 2, the RunOnce path after a reboot.
|
||||
196
relay/api/relay.mjs
Normal file
196
relay/api/relay.mjs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// Igneum relay: one function, dispatched on ?fn=. Reached through the rewrite /r/<token>/api/<fn>.
|
||||
// Auth: the token in the path (or x-relay-token), or the intake key in x-igneum-key. Nothing else.
|
||||
// GET feed ?since=<id> | ?before=<id> | ?machine=X | ?limit=N items newest first + machines + unread counts
|
||||
// GET item ?id= one item with its full body
|
||||
// GET file ?id= 302 to the Blob URL (or the inline bytes)
|
||||
// GET inbox ?machine=PC1&kind=run|task|all&ack=1 unread tasks for a machine; ack marks them read
|
||||
// GET machines every machine with role, hostname, last_seen
|
||||
// POST drop JSON {from,to,kind,title,body,file_name,file_url,size,task_id,flags} or raw octet-stream (x-file-name, x-from)
|
||||
// POST task JSON {to,title,body,file_name,file_url,size,kind:'task'|'run',flags:{elevated,reboot_continue},from}
|
||||
// POST upload JSON {name,size} -> {token, put_url, api_version} client token for a direct PUT to Vercel Blob (50 MB)
|
||||
// POST ack JSON {ids:[...]} mark read
|
||||
// POST done JSON {id, exit_code} mark done (runner finished)
|
||||
// POST register JSON {hostname, info, role?} machine checks in; returns its name and role
|
||||
// POST name JSON {hostname, name} name an unknown machine
|
||||
// POST role JSON {name, role} set a machine's role
|
||||
// POST delete JSON {id}
|
||||
import { neon, authed, readJson, readRaw, str, safeName, storeBuffer, clientUploadToken, ITEM_COLS, rowOut, iso, touch, KINDS, ROLES, MAX_INLINE, MAX_BODY } from '../lib/relay.mjs';
|
||||
|
||||
const machineOut = m => ({ ...m, last_seen: iso(m.last_seen) });
|
||||
|
||||
const json = (res, status, obj) => { res.status(status).setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(obj)); };
|
||||
|
||||
async function insertItem(sql, o) {
|
||||
let kind = KINDS.has(o.kind) ? o.kind : (o.file_url || o.file_b64 ? 'file' : 'text');
|
||||
if (kind === 'text' && o.file_url) kind = 'file';
|
||||
const flags = o.flags && typeof o.flags === 'object' ? o.flags : {};
|
||||
const rows = await sql(
|
||||
`INSERT INTO relay_items (from_machine, to_machine, kind, title, body, file_name, file_url, file_b64, size, flags, task_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11) RETURNING id, ts`,
|
||||
[str(o.from, 80) || 'unknown', str(o.to, 80) || 'all', kind, str(o.title, 300), str(o.body, MAX_BODY),
|
||||
o.file_name ? safeName(o.file_name) : null, o.file_url ? str(o.file_url, 1000) : null, o.file_b64 || null,
|
||||
Number(o.size) || 0, JSON.stringify(flags), o.task_id ? Number(o.task_id) : null]);
|
||||
await touch(sql, o.from);
|
||||
return { id: Number(rows[0].id), ts: rows[0].ts, kind };
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
const via = authed(req);
|
||||
if (!via) return json(res, 401, { ok: false, error: 'no token' });
|
||||
const fn = String(req.query.fn || '');
|
||||
const q = req.query;
|
||||
let sql;
|
||||
try { sql = neon(); } catch (e) { return json(res, 500, { ok: false, error: e.message }); }
|
||||
try {
|
||||
if (req.method === 'GET') {
|
||||
if (fn === 'feed') {
|
||||
const limit = Math.min(500, Math.max(1, Number(q.limit) || 200));
|
||||
const where = []; const params = [];
|
||||
if (q.since) { params.push(Number(q.since)); where.push(`id > $${params.length}`); }
|
||||
if (q.before) { params.push(Number(q.before)); where.push(`id < $${params.length}`); }
|
||||
if (q.machine) { params.push(str(q.machine, 80)); where.push(`(from_machine = $${params.length} OR to_machine = $${params.length})`); }
|
||||
const items = await sql(`SELECT ${ITEM_COLS} FROM relay_items ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY id DESC LIMIT ${limit}`, params);
|
||||
const machines = await sql(`SELECT m.name, m.hostname, m.role, m.named, m.info, m.last_seen,
|
||||
(SELECT count(*) FROM relay_items i WHERE NOT i.read AND i.kind IN ('task','run') AND (i.to_machine = m.name OR (i.to_machine = 'all' AND i.kind = 'task')))::int AS unread
|
||||
FROM relay_machines m ORDER BY m.last_seen DESC NULLS LAST, m.name`);
|
||||
return json(res, 200, { ok: true, items: items.map(rowOut), machines: machines.map(machineOut), now: new Date().toISOString() });
|
||||
}
|
||||
if (fn === 'machines') {
|
||||
const machines = await sql(`SELECT name, hostname, role, named, info, last_seen FROM relay_machines ORDER BY name`);
|
||||
return json(res, 200, { ok: true, machines: machines.map(machineOut) });
|
||||
}
|
||||
if (fn === 'item') {
|
||||
const rows = await sql(`SELECT ${ITEM_COLS} FROM relay_items WHERE id = $1`, [Number(q.id)]);
|
||||
if (!rows.length) return json(res, 404, { ok: false, error: 'no such item' });
|
||||
return json(res, 200, { ok: true, item: rowOut(rows[0]) });
|
||||
}
|
||||
if (fn === 'file') {
|
||||
const rows = await sql(`SELECT file_name, file_url, file_b64 FROM relay_items WHERE id = $1`, [Number(q.id)]);
|
||||
if (!rows.length || (!rows[0].file_url && !rows[0].file_b64)) return json(res, 404, { ok: false, error: 'no file' });
|
||||
if (rows[0].file_url) { res.statusCode = 302; res.setHeader('Location', rows[0].file_url + (q.download ? '?download=1' : '')); return res.end(); }
|
||||
const buf = Buffer.from(rows[0].file_b64, 'base64');
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${safeName(rows[0].file_name)}"`);
|
||||
return res.status(200).end(buf);
|
||||
}
|
||||
if (fn === 'inbox') {
|
||||
const machine = str(q.machine, 80);
|
||||
if (!machine) return json(res, 400, { ok: false, error: 'machine required' });
|
||||
const kind = q.kind === 'run' ? ['run'] : q.kind === 'task' ? ['task'] : ['task', 'run'];
|
||||
// run items only ever go to one named machine; task items may be addressed to all
|
||||
const rows = await sql(`SELECT ${ITEM_COLS} FROM relay_items
|
||||
WHERE NOT read AND NOT done AND kind = ANY($2) AND (to_machine = $1 OR (to_machine = 'all' AND kind = 'task'))
|
||||
ORDER BY id ASC LIMIT 50`, [machine, kind]);
|
||||
if (q.ack && rows.length) await sql(`UPDATE relay_items SET read = true, read_at = now() WHERE id = ANY($1)`, [rows.map(r => Number(r.id))]);
|
||||
await touch(sql, machine);
|
||||
return json(res, 200, { ok: true, machine, items: rows.map(rowOut) });
|
||||
}
|
||||
return json(res, 404, { ok: false, error: `unknown fn ${fn}` });
|
||||
}
|
||||
|
||||
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method' });
|
||||
const ct = String(req.headers['content-type'] || '');
|
||||
|
||||
if (fn === 'drop' && !ct.includes('json')) {
|
||||
// raw bytes through the function: curl --data-binary @file -H 'Content-Type: application/octet-stream' -H 'x-file-name: a.zip'
|
||||
const buf = await readRaw(req);
|
||||
if (!buf.length) return json(res, 400, { ok: false, error: 'empty body' });
|
||||
if (buf.length > MAX_INLINE) return json(res, 413, { ok: false, error: `raw upload over ${MAX_INLINE} bytes; use fn=upload for a Blob client token` });
|
||||
const name = safeName(req.headers['x-file-name'] || 'file.bin');
|
||||
const stored = await storeBuffer(name, buf, ct || 'application/octet-stream');
|
||||
const r = await insertItem(sql, { from: req.headers['x-from'] || q.from, to: req.headers['x-to'] || q.to, kind: 'file',
|
||||
title: str(req.headers['x-title'] || q.title || name, 300), body: '', file_name: name, file_url: stored.url, size: buf.length,
|
||||
task_id: req.headers['x-task-id'] || q.task_id });
|
||||
return json(res, 200, { ok: true, ...r, file_url: stored.url });
|
||||
}
|
||||
|
||||
let body;
|
||||
try { body = await readJson(req); } catch { return json(res, 400, { ok: false, error: 'bad json' }); }
|
||||
|
||||
if (fn === 'drop' || fn === 'task') {
|
||||
const o = { ...body };
|
||||
if (fn === 'task') { o.kind = o.kind === 'run' ? 'run' : 'task'; if (!o.to) return json(res, 400, { ok: false, error: 'to required' }); }
|
||||
if (o.kind === 'run' && (!o.to || o.to === 'all')) return json(res, 400, { ok: false, error: 'a run task needs one named machine' });
|
||||
if (o.file_b64 && !o.file_url) {
|
||||
const buf = Buffer.from(String(o.file_b64), 'base64');
|
||||
if (buf.length > MAX_INLINE) return json(res, 413, { ok: false, error: 'inline file over 4 MB; use fn=upload' });
|
||||
const stored = await storeBuffer(o.file_name || 'file.bin', buf, o.content_type);
|
||||
o.file_url = stored.url; o.size = buf.length; delete o.file_b64;
|
||||
}
|
||||
if (!o.body && !o.file_url && !o.title) return json(res, 400, { ok: false, error: 'nothing to send' });
|
||||
if (o.file_url && !/^https:\/\/[a-z0-9.-]+\.public\.blob\.vercel-storage\.com\//i.test(o.file_url)) return json(res, 400, { ok: false, error: 'file_url must be a Vercel Blob URL from fn=upload' });
|
||||
const r = await insertItem(sql, o);
|
||||
return json(res, 200, { ok: true, ...r });
|
||||
}
|
||||
if (fn === 'upload') {
|
||||
const name = safeName(body.name || 'file.bin');
|
||||
const t = await clientUploadToken(name, Number(body.size) || 0);
|
||||
return json(res, 200, { ok: true, name, ...t });
|
||||
}
|
||||
if (fn === 'ack') {
|
||||
const ids = (Array.isArray(body.ids) ? body.ids : [body.id]).map(Number).filter(Boolean);
|
||||
if (!ids.length) return json(res, 400, { ok: false, error: 'ids required' });
|
||||
await sql(`UPDATE relay_items SET read = true, read_at = now() WHERE id = ANY($1)`, [ids]);
|
||||
return json(res, 200, { ok: true, ids });
|
||||
}
|
||||
if (fn === 'done') {
|
||||
const id = Number(body.id); if (!id) return json(res, 400, { ok: false, error: 'id required' });
|
||||
const extra = body.exit_code === undefined ? {} : { exit_code: Number(body.exit_code) };
|
||||
await sql(`UPDATE relay_items SET done = true, done_at = now(), read = true, read_at = COALESCE(read_at, now()), flags = flags || $2::jsonb WHERE id = $1`, [id, JSON.stringify(extra)]);
|
||||
return json(res, 200, { ok: true, id });
|
||||
}
|
||||
if (fn === 'delete') {
|
||||
const id = Number(body.id); if (!id) return json(res, 400, { ok: false, error: 'id required' });
|
||||
await sql(`DELETE FROM relay_items WHERE id = $1`, [id]);
|
||||
return json(res, 200, { ok: true, id });
|
||||
}
|
||||
if (fn === 'register') {
|
||||
const hostname = str(body.hostname, 120).trim();
|
||||
if (!hostname) return json(res, 400, { ok: false, error: 'hostname required' });
|
||||
const info = body.info && typeof body.info === 'object' ? body.info : {};
|
||||
let rows = await sql(`SELECT name, role, named FROM relay_machines WHERE hostname = $1`, [hostname]);
|
||||
if (!rows.length) {
|
||||
// first contact from this hostname: it shows up under its own hostname until the Mac names it
|
||||
rows = await sql(`INSERT INTO relay_machines (name, hostname, role, named, info, last_seen) VALUES ($1, $1, $2, false, $3::jsonb, now())
|
||||
ON CONFLICT (name) DO UPDATE SET hostname = EXCLUDED.hostname, last_seen = now(), info = EXCLUDED.info RETURNING name, role, named`,
|
||||
[hostname, ROLES.has(body.role) ? body.role : '', JSON.stringify(info)]);
|
||||
} else {
|
||||
await sql(`UPDATE relay_machines SET last_seen = now(), info = $2::jsonb WHERE hostname = $1`, [hostname, JSON.stringify(info)]);
|
||||
}
|
||||
return json(res, 200, { ok: true, name: rows[0].name, role: rows[0].role || '', named: !!rows[0].named, hostname });
|
||||
}
|
||||
if (fn === 'name') {
|
||||
const hostname = str(body.hostname, 120).trim(); const name = str(body.name, 80).trim();
|
||||
if (!hostname || !name) return json(res, 400, { ok: false, error: 'hostname and name required' });
|
||||
const target = await sql(`SELECT name, hostname FROM relay_machines WHERE name = $1`, [name]);
|
||||
const old = await sql(`SELECT name FROM relay_machines WHERE hostname = $1`, [hostname]);
|
||||
if (target.length && target[0].hostname && target[0].hostname !== hostname) return json(res, 409, { ok: false, error: `${name} is already ${target[0].hostname}` });
|
||||
if (target.length) {
|
||||
// a pre-seeded name (PC2 with no hostname yet): attach the hostname, drop the placeholder row, move its items
|
||||
if (old.length && old[0].name !== name) {
|
||||
await sql(`DELETE FROM relay_machines WHERE hostname = $1 AND name <> $2`, [hostname, name]);
|
||||
await sql(`UPDATE relay_items SET from_machine = $2 WHERE from_machine = $1`, [old[0].name, name]);
|
||||
await sql(`UPDATE relay_items SET to_machine = $2 WHERE to_machine = $1`, [old[0].name, name]);
|
||||
}
|
||||
await sql(`UPDATE relay_machines SET hostname = $1, named = true, last_seen = COALESCE(last_seen, now()) WHERE name = $2`, [hostname, name]);
|
||||
} else if (old.length) {
|
||||
await sql(`UPDATE relay_machines SET name = $2, named = true WHERE hostname = $1`, [hostname, name]);
|
||||
await sql(`UPDATE relay_items SET from_machine = $2 WHERE from_machine = $1`, [old[0].name, name]);
|
||||
await sql(`UPDATE relay_items SET to_machine = $2 WHERE to_machine = $1`, [old[0].name, name]);
|
||||
} else {
|
||||
await sql(`INSERT INTO relay_machines (name, hostname, role, named) VALUES ($2, $1, '', true)`, [hostname, name]);
|
||||
}
|
||||
return json(res, 200, { ok: true, hostname, name });
|
||||
}
|
||||
if (fn === 'role') {
|
||||
const name = str(body.name, 80).trim(); const role = str(body.role, 20).trim();
|
||||
if (!name || !ROLES.has(role)) return json(res, 400, { ok: false, error: `role must be one of ${[...ROLES].filter(Boolean).join(', ')}` });
|
||||
await sql(`INSERT INTO relay_machines (name, role, named) VALUES ($1, $2, true) ON CONFLICT (name) DO UPDATE SET role = EXCLUDED.role`, [name, role]);
|
||||
return json(res, 200, { ok: true, name, role });
|
||||
}
|
||||
return json(res, 404, { ok: false, error: `unknown fn ${fn}` });
|
||||
} catch (e) {
|
||||
return json(res, 500, { ok: false, error: String(e.message || e) });
|
||||
}
|
||||
}
|
||||
31
relay/clients/CLAUDE-PC.md
Normal file
31
relay/clients/CLAUDE-PC.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Igneum relay: standing instruction for the Claude Code session on this PC
|
||||
|
||||
the project lead's Mac and this PC talk through the Igneum relay (relay.igneum.network). You never need Gmail, a USB stick or the project lead to carry text or files. The relay clients live next to this file (the folder you unzipped `igneum-relay-clients.zip` into). Everything below runs in `cmd` or PowerShell from that folder.
|
||||
|
||||
## Four commands
|
||||
|
||||
| You want to | Type |
|
||||
|---|---|
|
||||
| See what the Mac has queued for you (and mark it read) | `send.bat inbox` |
|
||||
| Report back (what you did, what broke, the numbers) | `send.bat result "<text>"` |
|
||||
| Send a file (log, screenshot, zip, up to 50 MB) | `send.bat <path>` |
|
||||
| Fetch an item or its file by id | `send.bat get <id>` |
|
||||
|
||||
Also: `send.bat "<text>"` posts a plain note, `send.bat peek` reads without marking, `send.bat done <id>` closes a task. Add `-TaskId <id>` to a result so it links to the task it answers, `-Title "..."` for a headline, `-To Mac` to address one machine.
|
||||
|
||||
## The loop you follow
|
||||
|
||||
1. At the start of every session, and whenever the project lead says "check the relay": run `send.bat inbox`. Each item is a task from the Mac: a title, a body, sometimes a file. Do what it says.
|
||||
2. When a task is done or blocked: `send.bat result "<what happened, exit codes, paths, the numbers>" -TaskId <id>`. Keep it factual. Attach logs with `send.bat <logfile> -TaskId <id>` rather than pasting hundreds of lines.
|
||||
3. Close it: `send.bat done <id>`.
|
||||
4. Nothing waiting? Say so and carry on with what the project lead asked for locally.
|
||||
|
||||
## The agent
|
||||
|
||||
`igneum-agent.bat` is a separate, automatic runner. the project lead double-clicks it once (it asks for administrator rights once) and leaves the window open. From then on the Mac queues scripts (`run` tasks) that the agent executes by itself, posts the output as `result` items and reboots the PC when a script asks for it. Do not stop that window. Do not run `run` tasks yourself; `send.bat inbox` only shows the `task` items meant for you. If the agent window is closed, tell the project lead and ask him to start `igneum-agent.bat` again.
|
||||
|
||||
## Rules
|
||||
|
||||
- The URL, key and token baked into `send.ps1` and `igneum-agent.ps1` are the secret. Never paste them into chat, a commit, a screenshot or another machine.
|
||||
- Never edit `send.ps1` or `igneum-agent.ps1`. If they break, report it with `send.bat result` and the project lead ships a new zip.
|
||||
- Copy law applies to results too: short sentences, numbers in tables, no em dashes.
|
||||
81
relay/clients/agent.sh
Executable file
81
relay/clients/agent.sh
Executable file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env bash
|
||||
# Igneum relay agent for the Mac (or Linux/WSL): the bash twin of igneum-agent.ps1 for `run` tasks whose body is a bash script.
|
||||
# agent.sh register this machine and poll every 20 s; run each `run` task, post a result, mark it done
|
||||
# agent.sh once one pass (used by the tests)
|
||||
# Env: RELAY_MACHINE overrides the name (default: what the relay returns for this hostname, else `hostname -s`).
|
||||
# State: ~/.local/state/igneum-relay (state.json, tasks/, logs/). Needs curl, python3 (jq optional).
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
RELAY_URL='__RELAY_URL__'; RELAY_KEY='__RELAY_KEY__'; RELAY_TOKEN='__RELAY_TOKEN__'
|
||||
BASE="$RELAY_URL/r/$RELAY_TOKEN/api"
|
||||
STATE="${XDG_STATE_HOME:-$HOME/.local/state}/igneum-relay"; mkdir -p "$STATE/tasks" "$STATE/logs"
|
||||
POLL="${RELAY_POLL:-20}"; TAIL=65536
|
||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
get() { curl -sS --max-time 60 "$BASE/$1" -H "x-igneum-key: $RELAY_KEY"; }
|
||||
post() { curl -sS --max-time 120 -X POST "$BASE/$1" -H 'Content-Type: application/json' -H "x-igneum-key: $RELAY_KEY" --data-binary "$2"; }
|
||||
py() { python3 -c "$@"; }
|
||||
register() {
|
||||
local info
|
||||
info="$(py 'import json,platform,shutil,subprocess,os
|
||||
gpus=[]
|
||||
try:
|
||||
out=subprocess.run(["system_profiler","SPDisplaysDataType"],capture_output=True,text=True,timeout=20).stdout
|
||||
gpus=[l.split(":",1)[1].strip() for l in out.splitlines() if "Chipset Model" in l]
|
||||
except Exception: pass
|
||||
if not gpus and shutil.which("nvidia-smi"):
|
||||
try: gpus=[l.strip() for l in subprocess.run(["nvidia-smi","--query-gpu=name","--format=csv,noheader"],capture_output=True,text=True,timeout=10).stdout.splitlines() if l.strip()]
|
||||
except Exception: pass
|
||||
print(json.dumps({"hostname":platform.node().split(".")[0],"info":{"os":platform.platform(),"user":os.environ.get("USER",""),"gpus":gpus,"nvcc":bool(shutil.which("nvcc")),"agent":"agent.sh v1","dir":os.getcwd()}}))')"
|
||||
local r; r="$(post register "$info")" || { log "register failed"; return 1; }
|
||||
MACHINE="${RELAY_MACHINE:-$(printf '%s' "$r" | py 'import json,sys; print(json.load(sys.stdin)["name"])')}"
|
||||
ROLE="$(printf '%s' "$r" | py 'import json,sys; print(json.load(sys.stdin).get("role",""))')"
|
||||
printf '%s\n' "$MACHINE" > "$STATE/machine.txt"
|
||||
log "registered as $MACHINE (role ${ROLE:-unset})"
|
||||
}
|
||||
post_result() { # id title code log note
|
||||
local id="$1" title="$2" code="$3" logf="$4" note="${5:-}" body
|
||||
body="$(tail -c "$TAIL" "$logf" 2>/dev/null | py 'import sys,json; print(json.dumps(sys.stdin.read()))')"
|
||||
local t; t="$(printf '%s' "$title: exit $code${note:+ ($note)}" | py 'import sys,json; print(json.dumps(sys.stdin.read()))')"
|
||||
post drop "{\"kind\":\"result\",\"from\":$(printf '%s' "$MACHINE" | py 'import sys,json; print(json.dumps(sys.stdin.read()))'),\"to\":\"all\",\"task_id\":$id,\"title\":$t,\"body\":$body,\"flags\":{\"exit_code\":$code,\"pass\":${RELAY_PASS:-1}}}" >/dev/null && log "result posted for #$id"
|
||||
if [ "$(stat -f%z "$logf" 2>/dev/null || stat -c%s "$logf")" -gt "$TAIL" ]; then RELAY_MACHINE="$MACHINE" RELAY_TITLE="$title full log" bash "$HERE/send.sh" "$logf" >/dev/null || true; fi
|
||||
}
|
||||
run_task() { # json-of-one-item pass
|
||||
local it="$1" pass="${2:-1}" id title body elevated rc
|
||||
id="$(printf '%s' "$it" | py 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
||||
title="$(printf '%s' "$it" | py 'import json,sys; print(json.load(sys.stdin)["title"])')"
|
||||
elevated="$(printf '%s' "$it" | py 'import json,sys; print("1" if json.load(sys.stdin)["flags"].get("elevated") else "")')"
|
||||
local rebootc; rebootc="$(printf '%s' "$it" | py 'import json,sys; print("1" if json.load(sys.stdin)["flags"].get("reboot_continue") else "")')"
|
||||
printf '%s' "$it" | py 'import json,sys; sys.stdout.write(json.load(sys.stdin)["body"])' > "$STATE/tasks/task-$id.sh"
|
||||
local logf="$STATE/logs/task-$id-pass$pass-$(date +%Y%m%d-%H%M%S).log"
|
||||
log "task #$id '$title' pass $pass${elevated:+ elevated}${rebootc:+ reboot_continue}"
|
||||
if [ -n "$elevated" ]; then
|
||||
RELAY_PASS="$pass" RELAY_TASK_ID="$id" RELAY_MACHINE="$MACHINE" RELAY_SEND="$HERE/send.sh" sudo -n -E bash "$STATE/tasks/task-$id.sh" > >(tee -a "$logf") 2>&1; rc=$?
|
||||
else
|
||||
RELAY_PASS="$pass" RELAY_TASK_ID="$id" RELAY_MACHINE="$MACHINE" RELAY_SEND="$HERE/send.sh" bash "$STATE/tasks/task-$id.sh" > >(tee -a "$logf") 2>&1; rc=$?
|
||||
fi
|
||||
wait 2>/dev/null; sleep 0.2
|
||||
echo "__RELAY_EXIT__=$rc" >> "$logf"
|
||||
if grep -q 'RELAY-REBOOT' "$logf"; then
|
||||
if [ -n "$rebootc" ]; then
|
||||
post_result "$id" "$title" "$rc" "$logf" "rebooting, resumes as pass $((pass+1))"
|
||||
printf '{"pending":%s,"pass":%s}\n' "$id" "$((pass+1))" > "$STATE/state.json"
|
||||
log "task asked for a reboot; re-run agent.sh after the restart to resume (no auto-restart on the Mac)"; return 0
|
||||
fi
|
||||
fi
|
||||
post_result "$id" "$title" "$rc" "$logf"
|
||||
post done "{\"id\":$id,\"exit_code\":$rc}" >/dev/null
|
||||
}
|
||||
one_pass() {
|
||||
if [ -f "$STATE/state.json" ]; then
|
||||
local pend pass; pend="$(py 'import json; d=json.load(open("'"$STATE/state.json"'")); print(d["pending"])')"; pass="$(py 'import json; d=json.load(open("'"$STATE/state.json"'")); print(d["pass"])')"
|
||||
rm -f "$STATE/state.json"; run_task "$(get "item?id=$pend" | py 'import json,sys; print(json.dumps(json.load(sys.stdin)["item"]))')" "$pass"
|
||||
fi
|
||||
local j; j="$(get "inbox?machine=$MACHINE&kind=run&ack=1")" || { log "poll failed"; return 1; }
|
||||
local n; n="$(printf '%s' "$j" | py 'import json,sys; print(len(json.load(sys.stdin)["items"]))')"
|
||||
local i=0; while [ "$i" -lt "$n" ]; do run_task "$(printf '%s' "$j" | py 'import json,sys; print(json.dumps(json.load(sys.stdin)["items"]['"$i"']))')" 1; i=$((i+1)); done
|
||||
[ "$n" = 0 ] && printf '\r[%s] idle as %s ' "$(date +%H:%M:%S)" "$MACHINE"
|
||||
return 0
|
||||
}
|
||||
register || exit 1
|
||||
if [ "${1:-}" = "once" ]; then one_pass; exit $?; fi
|
||||
while true; do one_pass; sleep "$POLL"; done
|
||||
16
relay/clients/igneum-agent.bat
Normal file
16
relay/clients/igneum-agent.bat
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
@echo off
|
||||
rem Igneum relay agent. Double-click once and leave the window open; the Mac queues everything else.
|
||||
rem Runs elevated (one UAC click) so tasks that need administrator never prompt again. Restarts itself if the agent ever exits.
|
||||
title igneum-agent
|
||||
net session >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo Asking for administrator rights once, so queued tasks can install things without prompts...
|
||||
powershell -NoProfile -Command "Start-Process cmd.exe -Verb RunAs -ArgumentList '/k','\"\"%~f0\"\"'"
|
||||
exit /b 0
|
||||
)
|
||||
:loop
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0igneum-agent.ps1"
|
||||
echo.
|
||||
echo igneum-agent stopped with code %ERRORLEVEL%. Restarting in 15 s (Ctrl+C to quit).
|
||||
timeout /t 15 >nul
|
||||
goto loop
|
||||
173
relay/clients/igneum-agent.ps1
Normal file
173
relay/clients/igneum-agent.ps1
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# Igneum relay agent for Windows (PowerShell 5.1 or later). Started by igneum-agent.bat, which keeps it alive.
|
||||
# What it does: registers this PC on the relay (hostname, role from the relay, GPUs, WSL state, nvcc), then every 20 s
|
||||
# fetches the `run` tasks queued for it on the Mac, runs each one in order (a PowerShell script per task), captures
|
||||
# the output and posts a `result` item (exit code, last 64 KB inline, the full log as a file) and marks the task done.
|
||||
# Flags per task: elevated (needs administrator; the agent itself runs elevated, so there is no prompt),
|
||||
# reboot_continue (a task that prints RELAY-REBOOT is re-run after the restart with RELAY_PASS incremented).
|
||||
# State lives in %LOCALAPPDATA%\igneum-relay (state.json, tasks\, logs\). Nothing else is written outside the task's own doing.
|
||||
# The URL, key and token are written in by make-clients.sh. The repo copy holds placeholders.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$RelayUrl = '__RELAY_URL__'
|
||||
$RelayKey = '__RELAY_KEY__'
|
||||
$RelayToken = '__RELAY_TOKEN__'
|
||||
$Base = "$RelayUrl/r/$RelayToken/api"
|
||||
$Headers = @{ 'x-igneum-key' = $RelayKey }
|
||||
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$StateDir = Join-Path $env:LOCALAPPDATA 'igneum-relay'
|
||||
$TaskDir = Join-Path $StateDir 'tasks'
|
||||
$LogDir = Join-Path $StateDir 'logs'
|
||||
$StateFile = Join-Path $StateDir 'state.json'
|
||||
$PollSeconds = 20
|
||||
$TailBytes = 65536
|
||||
New-Item -ItemType Directory -Force -Path $StateDir, $TaskDir, $LogDir | Out-Null
|
||||
|
||||
function Log([string] $m) { Write-Host ("[" + (Get-Date -Format 'HH:mm:ss') + "] " + $m) }
|
||||
function Is-Admin { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) }
|
||||
function Api-Get([string] $Fn) { Invoke-RestMethod -Uri "$Base/$Fn" -Headers $Headers -TimeoutSec 60 }
|
||||
function Api-Post([string] $Fn, $Body) {
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json $Body -Depth 8 -Compress))
|
||||
Invoke-RestMethod -Method Post -Uri "$Base/$Fn" -Headers $Headers -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 120
|
||||
}
|
||||
function Read-State { if (Test-Path $StateFile) { try { return (Get-Content $StateFile -Raw | ConvertFrom-Json) } catch {} }; return $null }
|
||||
function Write-State($o) { if ($null -eq $o) { Remove-Item $StateFile -ErrorAction SilentlyContinue } else { ConvertTo-Json $o -Compress | Set-Content -Path $StateFile -Encoding ascii } }
|
||||
function Strip-Nulls([string] $s) { if ($null -eq $s) { return '' }; return ($s -replace "`0", '') }
|
||||
|
||||
# One agent per machine: a named mutex stops a second copy (the scheduled task and a double-click, for instance).
|
||||
$mutex = New-Object System.Threading.Mutex($false, 'Global\IgneumRelayAgent')
|
||||
if (-not $mutex.WaitOne(0)) { Log 'another igneum-agent is already running on this PC; this one exits'; Start-Sleep 5; exit 0 }
|
||||
|
||||
function Collect-Info {
|
||||
$info = @{ os = ''; user = $env:USERNAME; admin = (Is-Admin); gpus = @(); wsl = ''; nvcc = $false; agent = 'igneum-agent.ps1 v1'; dir = $Here }
|
||||
try { $info.os = (Get-CimInstance Win32_OperatingSystem).Caption + ' build ' + (Get-CimInstance Win32_OperatingSystem).BuildNumber } catch {}
|
||||
try {
|
||||
$nv = Get-Command nvidia-smi -ErrorAction SilentlyContinue
|
||||
if ($nv) { $info.gpus = @((& nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv,noheader 2>$null) | ForEach-Object { "$_".Trim() } | Where-Object { $_ }) }
|
||||
if (-not $info.gpus -or $info.gpus.Count -eq 0) { $info.gpus = @(Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }) }
|
||||
} catch {}
|
||||
try {
|
||||
$w = Strip-Nulls (((& wsl.exe --status 2>&1) | Out-String))
|
||||
$l = Strip-Nulls (((& wsl.exe --list --verbose 2>&1) | Out-String))
|
||||
$distros = @($l -split "`n" | Select-Object -Skip 1 | ForEach-Object { $_.Trim() } | Where-Object { $_ } | ForEach-Object { ($_ -replace '^\*\s*', '') -replace '\s+', ' ' })
|
||||
$info.wsl = $(if ($distros.Count) { $distros -join '; ' } else { ($w -split "`n" | Select-Object -First 1).Trim() })
|
||||
if (-not $info.wsl) { $info.wsl = 'none' }
|
||||
} catch { $info.wsl = 'none' }
|
||||
$info.nvcc = [bool](Get-Command nvcc -ErrorAction SilentlyContinue)
|
||||
return $info
|
||||
}
|
||||
|
||||
function Register-Machine {
|
||||
$info = Collect-Info
|
||||
$r = Api-Post 'register' @{ hostname = $env:COMPUTERNAME; info = $info }
|
||||
Set-Content -Path (Join-Path $StateDir 'machine.txt') -Value $r.name -Encoding ascii
|
||||
$script:Machine = $r.name; $script:Role = $r.role
|
||||
Log ("registered as " + $r.name + " (role " + ($(if ($r.role) { $r.role } else { 'unset' })) + ", named " + $r.named + "); gpus: " + ($info.gpus -join ', ') + "; wsl: " + $info.wsl + "; nvcc: " + $info.nvcc)
|
||||
if (-not $r.named) { Log "this PC is not named yet. On the Mac: node tools/relay.mjs name $env:COMPUTERNAME PC2" }
|
||||
}
|
||||
|
||||
function Arm-Restart {
|
||||
# Re-arm after a reboot: a logon scheduled task with highest privileges (no UAC prompt), plus RunOnce as a fallback.
|
||||
$bat = Join-Path $Here 'igneum-agent.bat'
|
||||
try {
|
||||
& schtasks.exe /Create /F /TN 'IgneumRelayAgent' /SC ONLOGON /RL HIGHEST /TR ("cmd /c start `"igneum-agent`" `"$bat`"") 2>&1 | Out-Null
|
||||
Log 'scheduled task IgneumRelayAgent set (runs at logon, highest privileges)'
|
||||
} catch { Log ("schtasks failed: " + $_.Exception.Message) }
|
||||
try {
|
||||
New-Item -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' -Force | Out-Null
|
||||
Set-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' -Name 'IgneumRelayAgent' -Value ("cmd /c start `"igneum-agent`" `"$bat`"")
|
||||
} catch { Log ("RunOnce failed: " + $_.Exception.Message) }
|
||||
}
|
||||
|
||||
function Post-Result($task, [int] $code, [string] $logPath, [string] $note) {
|
||||
$tail = ''
|
||||
if (Test-Path $logPath) {
|
||||
$bytes = [IO.File]::ReadAllBytes($logPath)
|
||||
$n = [Math]::Min($bytes.Length, $TailBytes)
|
||||
$tail = [Text.Encoding]::UTF8.GetString($bytes, $bytes.Length - $n, $n)
|
||||
}
|
||||
$o = @{ kind = 'result'; from = $script:Machine; to = 'all'; task_id = $task.id; body = $tail
|
||||
title = ($task.title + ": exit " + $code + $(if ($note) { " (" + $note + ")" } else { "" }))
|
||||
flags = @{ exit_code = $code; pass = [int]$env:RELAY_PASS; machine = $env:COMPUTERNAME } }
|
||||
if ((Test-Path $logPath) -and (Get-Item $logPath).Length -gt $TailBytes) {
|
||||
try { $f = & (Join-Path $Here 'send.ps1') -Machine $script:Machine -Kind 'file' -TaskId $task.id -Title ($task.title + ' full log') $logPath 2>&1 | Out-String; Log ("full log posted: " + $f.Trim()) } catch { Log ("log upload failed: " + $_.Exception.Message) }
|
||||
}
|
||||
try { $r = Api-Post 'drop' $o; Log ("result posted as #" + $r.id) } catch { Log ("result post failed: " + $_.Exception.Message) }
|
||||
}
|
||||
|
||||
function Run-Task($task, [int] $pass) {
|
||||
$id = $task.id
|
||||
$script = Join-Path $TaskDir ("task-" + $id + ".ps1")
|
||||
$wrap = Join-Path $TaskDir ("task-" + $id + ".wrap.ps1")
|
||||
$log = Join-Path $LogDir ("task-" + $id + "-pass" + $pass + "-" + (Get-Date -Format 'yyyyMMdd-HHmmss') + ".log")
|
||||
$elevated = [bool]$task.flags.elevated
|
||||
$rebootContinue = [bool]$task.flags.reboot_continue
|
||||
Log ("task #" + $id + " '" + $task.title + "' pass " + $pass + $(if ($elevated) { " elevated" } else { "" }) + $(if ($rebootContinue) { " reboot_continue" } else { "" }))
|
||||
$body = "$($task.body)" -replace "`r?`n", "`r`n"
|
||||
[IO.File]::WriteAllText($script, $body, (New-Object Text.UTF8Encoding $true))
|
||||
$env:RELAY_PASS = "$pass"; $env:RELAY_TASK_ID = "$id"; $env:RELAY_MACHINE = $script:Machine; $env:RELAY_ROLE = $script:Role
|
||||
$env:RELAY_SEND = (Join-Path $Here 'send.ps1'); $env:RELAY_HOME = $StateDir
|
||||
$wrapBody = @"
|
||||
`$ErrorActionPreference = 'Continue'
|
||||
`$env:RELAY_PASS = '$pass'; `$env:RELAY_TASK_ID = '$id'; `$env:RELAY_MACHINE = '$($script:Machine)'; `$env:RELAY_ROLE = '$($script:Role)'
|
||||
`$env:RELAY_SEND = '$(Join-Path $Here 'send.ps1')'; `$env:RELAY_HOME = '$StateDir'
|
||||
Start-Transcript -Path '$log' -Append | Out-Null
|
||||
`$code = 0
|
||||
try { & '$script'; `$code = `$LASTEXITCODE; if (`$null -eq `$code) { `$code = 0 } } catch { Write-Host ("TASK ERROR: " + `$_.Exception.Message); `$code = 1 }
|
||||
Stop-Transcript | Out-Null
|
||||
Add-Content -Path '$log' -Value ("__RELAY_EXIT__=" + `$code)
|
||||
exit `$code
|
||||
"@
|
||||
[IO.File]::WriteAllText($wrap, $wrapBody, (New-Object Text.UTF8Encoding $true))
|
||||
$args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$wrap`"")
|
||||
$code = 1
|
||||
try {
|
||||
if ($elevated -and -not (Is-Admin)) {
|
||||
Log 'task needs administrator and the agent is not elevated: asking (UAC prompt on this PC)'
|
||||
$p = Start-Process powershell.exe -ArgumentList $args -Verb RunAs -Wait -PassThru
|
||||
} else {
|
||||
$p = Start-Process powershell.exe -ArgumentList $args -NoNewWindow -Wait -PassThru
|
||||
}
|
||||
$code = $p.ExitCode
|
||||
} catch { Log ("could not start the task: " + $_.Exception.Message); Add-Content -Path $log -Value ("AGENT ERROR: " + $_.Exception.Message) }
|
||||
$text = ''; if (Test-Path $log) { $text = Get-Content $log -Raw }
|
||||
$reboot = $text -match 'RELAY-REBOOT'
|
||||
if ($reboot -and $rebootContinue) {
|
||||
Log ("task #" + $id + " asked for a reboot and continues after it (pass " + ($pass + 1) + ")")
|
||||
Post-Result $task $code $log ("rebooting, resumes as pass " + ($pass + 1))
|
||||
Write-State @{ pending = $id; pass = ($pass + 1); title = $task.title }
|
||||
Arm-Restart
|
||||
& shutdown.exe /r /t 10 /c "Igneum relay: task #$id continues after the restart"
|
||||
Log 'restart in 10 s; the agent exits now'
|
||||
exit 0
|
||||
}
|
||||
Post-Result $task $code $log $(if ($reboot) { 'rebooting' } else { '' })
|
||||
try { Api-Post 'done' @{ id = $id; exit_code = $code } | Out-Null } catch { Log ("done failed: " + $_.Exception.Message) }
|
||||
if ($reboot) {
|
||||
Log ("task #" + $id + " asked for a reboot")
|
||||
Arm-Restart
|
||||
& shutdown.exe /r /t 10 /c "Igneum relay: task #$id asked for a restart"
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
Log ("igneum relay agent on " + $env:COMPUTERNAME + " as " + $env:USERNAME + $(if (Is-Admin) { " (administrator)" } else { " (NOT administrator: elevated tasks will prompt)" }))
|
||||
Arm-Restart
|
||||
$registered = $false
|
||||
while ($true) {
|
||||
try {
|
||||
if (-not $registered) { Register-Machine; $registered = $true }
|
||||
$st = Read-State
|
||||
if ($st -and $st.pending) {
|
||||
$pending = [long]$st.pending; $pass = [int]$st.pass
|
||||
Write-State $null
|
||||
try { $it = (Api-Get ("item?id=" + $pending)).item; Run-Task $it $pass } catch { Log ("could not resume task #" + $pending + ": " + $_.Exception.Message) }
|
||||
}
|
||||
$j = Api-Get ("inbox?machine=" + [Uri]::EscapeDataString($script:Machine) + "&kind=run&ack=1")
|
||||
foreach ($t in @($j.items)) { Run-Task $t 1 }
|
||||
if (-not $j.items -or $j.items.Count -eq 0) { Write-Host -NoNewline ("`r[" + (Get-Date -Format 'HH:mm:ss') + "] idle as " + $script:Machine + ", next check in " + $PollSeconds + " s ") }
|
||||
} catch {
|
||||
Log ("loop error: " + $_.Exception.Message)
|
||||
$registered = $false
|
||||
}
|
||||
Start-Sleep -Seconds $PollSeconds
|
||||
}
|
||||
25
relay/clients/make-clients.sh
Executable file
25
relay/clients/make-clients.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
# Bakes the relay URL, key and token into copies of the clients and zips them to ~/Desktop/igneum-relay-clients.zip.
|
||||
# The files in the repo keep their placeholders; the zip is the secret-bearing artefact. Usage: make-clients.sh [outdir-for-zip]
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
CFG="$HOME/.config/igneum"
|
||||
URL="$(cat "$CFG/relay-url" 2>/dev/null | tr -d '\n' || true)"; URL="${URL:-https://relay.igneum.network}"
|
||||
KEY="$(tr -d '\n' < "$CFG/log-intake-key")"; TOKEN="$(tr -d '\n' < "$CFG/relay-token")"
|
||||
OUT="${1:-$HOME/Desktop}"; mkdir -p "$OUT"
|
||||
STAGE="$(mktemp -d)/igneum-relay-clients"; mkdir -p "$STAGE"
|
||||
for f in send.bat send.ps1 send.sh igneum-agent.bat igneum-agent.ps1 agent.sh CLAUDE-PC.md; do
|
||||
sed -e "s#__RELAY_URL__#$URL#g" -e "s#__RELAY_KEY__#$KEY#g" -e "s#__RELAY_TOKEN__#$TOKEN#g" "$HERE/$f" > "$STAGE/$f"
|
||||
done
|
||||
# Windows reads CRLF batch files most reliably; PowerShell is fine either way
|
||||
for f in send.bat igneum-agent.bat; do perl -pi -e 's/\r?\n/\r\n/' "$STAGE/$f"; done
|
||||
chmod +x "$STAGE/send.sh" "$STAGE/agent.sh"
|
||||
cat > "$STAGE/README.txt" <<TXT
|
||||
Igneum relay clients. Unzip anywhere. send.bat "<text>" | send.bat <file> | send.bat inbox | send.bat result "<text>" | send.bat get <id>
|
||||
igneum-agent.bat: double-click once, leave open. CLAUDE-PC.md: paste into the Claude Code session on this PC.
|
||||
These files contain the relay secret. Keep them off shared drives.
|
||||
TXT
|
||||
rm -f "$OUT/igneum-relay-clients.zip"
|
||||
(cd "$(dirname "$STAGE")" && zip -qr "$OUT/igneum-relay-clients.zip" igneum-relay-clients)
|
||||
echo "staged: $STAGE"
|
||||
echo "zip: $OUT/igneum-relay-clients.zip ($(du -h "$OUT/igneum-relay-clients.zip" | cut -f1))"
|
||||
5
relay/clients/send.bat
Normal file
5
relay/clients/send.bat
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
@echo off
|
||||
rem Igneum relay: send.bat "<text>" | send.bat <file> | send.bat inbox | send.bat result "<text>" | send.bat get <id> | send.bat done <id>
|
||||
rem Everything is in send.ps1 next to this file (the URL, key and token are baked in by the packager).
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0send.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
111
relay/clients/send.ps1
Normal file
111
relay/clients/send.ps1
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Igneum relay client (Windows PowerShell 5.1 or later). Driven by send.bat; the agent and the playbooks call it too.
|
||||
# send.ps1 "<text>" post a note to everyone
|
||||
# send.ps1 <file> post a file (up to 50 MB, straight to Vercel Blob)
|
||||
# send.ps1 result "<text>" [-TaskId N] [-File path] post a result (what a task produced)
|
||||
# send.ps1 inbox print the unread tasks for this machine and mark them read
|
||||
# send.ps1 peek print them without marking read
|
||||
# send.ps1 get <id> print an item; download its file into the current folder
|
||||
# send.ps1 done <id> mark a task done
|
||||
# -To PC1 / -Title "..." / -Machine NAME override the defaults (machine = this PC's name on the relay, else its hostname)
|
||||
# The URL, key and token below are written in by make-clients.sh. They are the secret; keep this file off shared drives.
|
||||
param(
|
||||
[Parameter(Position = 0)] [string] $A,
|
||||
[Parameter(Position = 1)] [string] $B,
|
||||
[string] $To = 'all',
|
||||
[string] $Title = '',
|
||||
[string] $Machine = '',
|
||||
[long] $TaskId = 0,
|
||||
[string] $File = '',
|
||||
[string] $Kind = ''
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$RelayUrl = '__RELAY_URL__'
|
||||
$RelayKey = '__RELAY_KEY__'
|
||||
$RelayToken = '__RELAY_TOKEN__'
|
||||
$Base = "$RelayUrl/r/$RelayToken/api"
|
||||
$Headers = @{ 'x-igneum-key' = $RelayKey }
|
||||
$StateDir = Join-Path $env:LOCALAPPDATA 'igneum-relay'
|
||||
|
||||
function Get-MachineName {
|
||||
if ($Machine) { return $Machine }
|
||||
if ($env:RELAY_MACHINE) { return $env:RELAY_MACHINE }
|
||||
$f = Join-Path $StateDir 'machine.txt'
|
||||
if (Test-Path $f) { $n = (Get-Content $f -Raw).Trim(); if ($n) { return $n } }
|
||||
return $env:COMPUTERNAME
|
||||
}
|
||||
function Invoke-Relay([string] $Method, [string] $Fn, $Body) {
|
||||
$uri = "$Base/$Fn"
|
||||
if ($Method -eq 'GET') { return Invoke-RestMethod -Uri $uri -Headers $Headers -TimeoutSec 60 }
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json $Body -Depth 8 -Compress))
|
||||
return Invoke-RestMethod -Method Post -Uri $uri -Headers $Headers -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 120
|
||||
}
|
||||
function Send-RelayFile([string] $Path) {
|
||||
$Path = (Resolve-Path $Path).Path
|
||||
$name = Split-Path $Path -Leaf
|
||||
$size = (Get-Item $Path).Length
|
||||
$t = Invoke-Relay 'POST' 'upload' @{ name = $name; size = $size }
|
||||
if ($size -gt $t.max) { throw "$name is $size bytes, over the $($t.max) byte cap" }
|
||||
$h = @{ 'authorization' = "Bearer $($t.token)"; 'x-api-version' = "$($t.api_version)"; 'x-add-random-suffix' = '1'; 'x-content-type' = 'application/octet-stream' }
|
||||
$r = Invoke-RestMethod -Method Put -Uri $t.put_url -Headers $h -ContentType 'application/octet-stream' -InFile $Path -TimeoutSec 600
|
||||
if (-not $r.url) { throw "blob upload failed: $($r | ConvertTo-Json -Compress)" }
|
||||
return @{ file_name = $name; file_url = $r.url; size = $size }
|
||||
}
|
||||
function Post-Item([hashtable] $o) {
|
||||
$o['from'] = Get-MachineName
|
||||
if (-not $o.ContainsKey('to')) { $o['to'] = $To }
|
||||
if ($Title -and -not $o['title']) { $o['title'] = $Title }
|
||||
if ($TaskId) { $o['task_id'] = $TaskId }
|
||||
if ($File) { $f = Send-RelayFile $File; foreach ($k in $f.Keys) { $o[$k] = $f[$k] } }
|
||||
$r = Invoke-Relay 'POST' 'drop' $o
|
||||
Write-Host ("sent #" + $r.id + " (" + $r.kind + ") from " + $o['from'])
|
||||
return $r
|
||||
}
|
||||
function Show-Item($it) {
|
||||
Write-Host ("===== #" + $it.id + " " + $it.kind + " from " + $it.from + " to " + $it.to + " at " + $it.ts + ($(if ($it.title) { " | " + $it.title } else { "" })) + " =====")
|
||||
if ($it.body) { Write-Output $it.body }
|
||||
if ($it.has_file) { Write-Host ("file: " + $it.file_name + " (" + $it.size + " bytes); send.bat get " + $it.id + " downloads it") }
|
||||
}
|
||||
|
||||
switch -Regex ($A) {
|
||||
'^(?i)inbox$' {
|
||||
$j = Invoke-RestMethod -Uri "$Base/inbox?machine=$([Uri]::EscapeDataString((Get-MachineName)))&ack=1" -Headers $Headers -TimeoutSec 60
|
||||
if (-not $j.items) { Write-Host ("nothing waiting for " + (Get-MachineName)); break }
|
||||
foreach ($it in $j.items) { Show-Item $it }
|
||||
break
|
||||
}
|
||||
'^(?i)peek$' {
|
||||
$j = Invoke-RestMethod -Uri "$Base/inbox?machine=$([Uri]::EscapeDataString((Get-MachineName)))" -Headers $Headers -TimeoutSec 60
|
||||
if (-not $j.items) { Write-Host ("nothing waiting for " + (Get-MachineName)); break }
|
||||
foreach ($it in $j.items) { Show-Item $it }
|
||||
break
|
||||
}
|
||||
'^(?i)get$' {
|
||||
if (-not $B) { throw 'get <id>' }
|
||||
$it = (Invoke-RestMethod -Uri "$Base/item?id=$B" -Headers $Headers -TimeoutSec 60).item
|
||||
Show-Item $it
|
||||
if ($it.has_file) {
|
||||
$out = Join-Path (Get-Location) ($it.id.ToString() + '-' + $it.file_name)
|
||||
Invoke-WebRequest -Uri "$Base/file?id=$($it.id)" -Headers $Headers -OutFile $out -UseBasicParsing -TimeoutSec 600
|
||||
Write-Host "downloaded: $out"
|
||||
}
|
||||
break
|
||||
}
|
||||
'^(?i)done$' { if (-not $B) { throw 'done <id>' }; Invoke-Relay 'POST' 'done' @{ id = [long]$B } | Out-Null; Write-Host "#$B done"; break }
|
||||
'^(?i)result$' {
|
||||
if (-not $B -and -not $File) { throw 'result "<text>" [-TaskId N] [-File path]' }
|
||||
Post-Item @{ kind = 'result'; body = "$B"; title = $Title } | Out-Null
|
||||
break
|
||||
}
|
||||
default {
|
||||
if (-not $A) { throw 'send.bat "<text>" | send.bat <file> | send.bat inbox | send.bat result "<text>" | send.bat get <id> | send.bat done <id>' }
|
||||
if (Test-Path -LiteralPath $A -PathType Leaf) {
|
||||
$f = Send-RelayFile $A
|
||||
$o = @{ kind = 'file'; body = "$B"; title = $(if ($Title) { $Title } else { $f.file_name }) }
|
||||
foreach ($k in $f.Keys) { $o[$k] = $f[$k] }
|
||||
Post-Item $o | Out-Null
|
||||
} else {
|
||||
Post-Item @{ kind = $(if ($Kind) { $Kind } else { 'text' }); body = "$A"; title = $Title } | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
48
relay/clients/send.sh
Executable file
48
relay/clients/send.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env bash
|
||||
# Igneum relay client for Mac/Linux/WSL. Needs curl and jq (python3 fallback for jq).
|
||||
# send.sh "<text>" note to everyone send.sh <file> file (up to 50 MB)
|
||||
# send.sh inbox unread tasks for this machine, marked read send.sh peek same, not marked
|
||||
# send.sh get <id> print an item, download its file here send.sh done <id>
|
||||
# send.sh result "<text>" [--task-id N] [--file path]
|
||||
# RELAY_TO=PC1 RELAY_TITLE="..." RELAY_MACHINE=Mac override the defaults.
|
||||
# The URL, key and token are written in by make-clients.sh (the repo copy holds placeholders).
|
||||
set -euo pipefail
|
||||
RELAY_URL='__RELAY_URL__'; RELAY_KEY='__RELAY_KEY__'; RELAY_TOKEN='__RELAY_TOKEN__'
|
||||
BASE="$RELAY_URL/r/$RELAY_TOKEN/api"
|
||||
STATE="${XDG_STATE_HOME:-$HOME/.local/state}/igneum-relay"
|
||||
machine() { if [ -n "${RELAY_MACHINE:-}" ]; then echo "$RELAY_MACHINE"; elif [ -f "$STATE/machine.txt" ]; then cat "$STATE/machine.txt"; else hostname -s; fi; }
|
||||
jqq() { if command -v jq >/dev/null; then jq "$@"; else python3 -c 'import json,sys; q=sys.argv[1]; d=json.load(sys.stdin); print(d[q.strip(".")])' "$@"; fi; }
|
||||
post() { curl -sS --max-time 120 -X POST "$BASE/$1" -H 'Content-Type: application/json' -H "x-igneum-key: $RELAY_KEY" --data-binary "$2"; }
|
||||
get() { curl -sS --max-time 60 "$BASE/$1" -H "x-igneum-key: $RELAY_KEY"; }
|
||||
jstr() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' ; }
|
||||
upload() {
|
||||
local path="$1" name size tok
|
||||
name="$(basename "$path")"; size=$(stat -f%z "$path" 2>/dev/null || stat -c%s "$path")
|
||||
tok="$(post upload "{\"name\":$(printf '%s' "$name" | jstr),\"size\":$size}")"
|
||||
local put url token ver
|
||||
put="$(printf '%s' "$tok" | jqq -r .put_url)"; token="$(printf '%s' "$tok" | jqq -r .token)"; ver="$(printf '%s' "$tok" | jqq -r .api_version)"
|
||||
url="$(curl -sS --max-time 600 -X PUT "$put" -H "authorization: Bearer $token" -H "x-api-version: $ver" -H 'x-add-random-suffix: 1' -H 'x-content-type: application/octet-stream' --data-binary "@$path" | jqq -r .url)"
|
||||
[ -n "$url" ] && [ "$url" != "null" ] || { echo "blob upload failed" >&2; return 1; }
|
||||
printf '"file_name":%s,"file_url":"%s","size":%s' "$(printf '%s' "$name" | jstr)" "$url" "$size"
|
||||
}
|
||||
show() { python3 -c '
|
||||
import json,sys; it=json.load(sys.stdin)
|
||||
for x in (it if isinstance(it,list) else [it]):
|
||||
print("===== #%s %s from %s to %s at %s%s =====" % (x["id"],x["kind"],x["from"],x["to"],x["ts"], (" | "+x["title"]) if x.get("title") else ""))
|
||||
if x.get("body"): print(x["body"])
|
||||
if x.get("has_file"): print("file: %s (%s bytes); send.sh get %s downloads it" % (x["file_name"],x["size"],x["id"]))'; }
|
||||
cmd="${1:-}"; M="$(machine)"; TO="${RELAY_TO:-all}"; TITLE="${RELAY_TITLE:-}"
|
||||
case "$cmd" in
|
||||
inbox) get "inbox?machine=$M&ack=1" | jqq .items | show ;;
|
||||
peek) get "inbox?machine=$M" | jqq .items | show ;;
|
||||
get) [ -n "${2:-}" ] || { echo 'get <id>' >&2; exit 1; }; j="$(get "item?id=$2")"; printf '%s' "$j" | jqq .item | show
|
||||
if [ "$(printf '%s' "$j" | jqq -r .item.has_file)" = "true" ]; then out="$2-$(printf '%s' "$j" | jqq -r .item.file_name)"; curl -sSL --max-time 600 "$BASE/file?id=$2" -H "x-igneum-key: $RELAY_KEY" -o "$out"; echo "downloaded: $out"; fi ;;
|
||||
done) [ -n "${2:-}" ] || { echo "done <id>" >&2; exit 1; }; post done "{\"id\":$2}" >/dev/null; echo "#$2 done" ;;
|
||||
result) shift; text="${1:-}"; shift || true; task=0; file=""
|
||||
while [ $# -gt 0 ]; do case "$1" in --task-id) task="$2"; shift 2;; --file) file="$2"; shift 2;; *) shift;; esac; done
|
||||
extra=""; [ -n "$file" ] && extra=",$(upload "$file")"
|
||||
post drop "{\"from\":$(printf '%s' "$M" | jstr),\"to\":\"$TO\",\"kind\":\"result\",\"title\":$(printf '%s' "$TITLE" | jstr),\"body\":$(printf '%s' "$text" | jstr),\"task_id\":$task$extra}"; echo ;;
|
||||
"") echo 'send.sh "<text>" | send.sh <file> | send.sh inbox | send.sh result "<text>" | send.sh get <id> | send.sh done <id>' >&2; exit 1 ;;
|
||||
*) if [ -f "$cmd" ]; then post drop "{\"from\":$(printf '%s' "$M" | jstr),\"to\":\"$TO\",\"kind\":\"file\",\"title\":$(printf '%s' "${TITLE:-$(basename "$cmd")}" | jstr),\"body\":$(printf '%s' "${2:-}" | jstr),$(upload "$cmd")}"
|
||||
else post drop "{\"from\":$(printf '%s' "$M" | jstr),\"to\":\"$TO\",\"kind\":\"text\",\"title\":$(printf '%s' "$TITLE" | jstr),\"body\":$(printf '%s' "$cmd" | jstr)}"; fi; echo ;;
|
||||
esac
|
||||
1
relay/index.html
Normal file
1
relay/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Igneum relay</title><meta name="robots" content="noindex,nofollow"><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0C0C0E;color:#9A9A9E;font:14px 'IBM Plex Mono',monospace}</style></head><body>igneum relay</body></html>
|
||||
108
relay/lib/relay.mjs
Normal file
108
relay/lib/relay.mjs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Shared pieces for the Igneum relay function. Zero dependencies apart from @vercel/blob.
|
||||
// Storage: Neon (HTTP SQL driver) for every item, Vercel Blob (store igneum-relay, public URLs with a
|
||||
// random suffix) for files. The token in the URL path is the only secret the web page holds; scripts
|
||||
// may also present the intake key in x-igneum-key.
|
||||
import { put } from '@vercel/blob';
|
||||
import { generateClientTokenFromReadWriteToken } from '@vercel/blob/client';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
export const MAX_INLINE = 4 * 1024 * 1024; // raw upload through the function (Vercel body cap is 4.5 MB)
|
||||
export const MAX_BLOB = 50 * 1024 * 1024; // direct-to-Blob upload with a client token
|
||||
export const MAX_BODY = 1024 * 1024; // text body per item
|
||||
export const KINDS = new Set(['text', 'file', 'task', 'result', 'run']);
|
||||
export const ROLES = new Set(['miner', 'prover', 'bench', 'mac', 'phone', '']);
|
||||
|
||||
export function neon() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error('DATABASE_URL is not set');
|
||||
const host = new URL(url).hostname.replace('-pooler', '');
|
||||
return async (query, params = []) => {
|
||||
const r = await fetch(`https://${host}/sql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, params }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.message || JSON.stringify(j));
|
||||
return j.rows;
|
||||
};
|
||||
}
|
||||
|
||||
export const str = (v, max) => (v === undefined || v === null ? '' : String(v)).slice(0, max);
|
||||
|
||||
export function authed(req) {
|
||||
const q = req.query || {};
|
||||
const token = process.env.RELAY_TOKEN;
|
||||
const key = process.env.RELAY_KEY;
|
||||
const given = q.token || req.headers['x-relay-token'];
|
||||
if (token && typeof given === 'string' && given === token) return 'token';
|
||||
const k = req.headers['x-igneum-key'];
|
||||
if (key && typeof k === 'string' && k === key) return 'key';
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function readJson(req) {
|
||||
if (req.body !== undefined && req.body !== null) {
|
||||
if (typeof req.body === 'string') return req.body ? JSON.parse(req.body) : {};
|
||||
if (Buffer.isBuffer(req.body)) return req.body.length ? JSON.parse(req.body.toString('utf8')) : {};
|
||||
return req.body;
|
||||
}
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
const s = Buffer.concat(chunks).toString('utf8');
|
||||
return s ? JSON.parse(s) : {};
|
||||
}
|
||||
|
||||
export async function readRaw(req) {
|
||||
if (Buffer.isBuffer(req.body)) return req.body;
|
||||
if (typeof req.body === 'string') return Buffer.from(req.body, 'utf8');
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export function safeName(name) {
|
||||
const n = str(name, 200).replace(/[\\/]+/g, '_').replace(/[^\w.\-+ ()\[\]]/g, '_').trim();
|
||||
return n || 'file';
|
||||
}
|
||||
|
||||
export function blobPath(name) {
|
||||
return `relay/${randomBytes(6).toString('hex')}/${safeName(name)}`;
|
||||
}
|
||||
|
||||
export async function storeBuffer(name, buf, contentType) {
|
||||
const r = await put(blobPath(name), buf, {
|
||||
access: 'public', addRandomSuffix: true, contentType: contentType || 'application/octet-stream',
|
||||
});
|
||||
return { url: r.url, size: buf.length };
|
||||
}
|
||||
|
||||
export async function clientUploadToken(name, size) {
|
||||
const pathname = blobPath(name);
|
||||
const token = await generateClientTokenFromReadWriteToken({
|
||||
pathname,
|
||||
addRandomSuffix: true,
|
||||
allowOverwrite: false,
|
||||
maximumSizeInBytes: MAX_BLOB,
|
||||
validUntil: Date.now() + 60 * 60 * 1000,
|
||||
});
|
||||
return { token, pathname, put_url: `https://vercel.com/api/blob/?pathname=${encodeURIComponent(pathname)}`, api_version: '11', max: MAX_BLOB, size };
|
||||
}
|
||||
|
||||
export const ITEM_COLS = 'id, ts, from_machine, to_machine, kind, title, body, file_name, file_url, size, read, read_at, done, done_at, flags, task_id, (file_b64 IS NOT NULL) AS inline';
|
||||
|
||||
export const iso = v => { if (!v) return null; const d = new Date(String(v).replace(' ', 'T').replace(/([+-]\d\d)$/, '$1:00')); return isNaN(d) ? String(v) : d.toISOString(); };
|
||||
|
||||
export function rowOut(r) {
|
||||
return {
|
||||
id: Number(r.id), ts: iso(r.ts), from: r.from_machine, to: r.to_machine, kind: r.kind, title: r.title, body: r.body,
|
||||
file_name: r.file_name, has_file: !!(r.file_url || r.inline), size: Number(r.size || 0),
|
||||
read: !!r.read, read_at: iso(r.read_at), done: !!r.done, done_at: iso(r.done_at), flags: r.flags || {}, task_id: r.task_id == null ? null : Number(r.task_id),
|
||||
};
|
||||
}
|
||||
|
||||
export async function touch(sql, name) {
|
||||
if (!name) return;
|
||||
await sql(`INSERT INTO relay_machines (name, role, named, last_seen) VALUES ($1, '', false, now())
|
||||
ON CONFLICT (name) DO UPDATE SET last_seen = now()`, [str(name, 80)]);
|
||||
}
|
||||
109
relay/package-lock.json
generated
Normal file
109
relay/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
{
|
||||
"name": "igneum-relay",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "igneum-relay",
|
||||
"dependencies": {
|
||||
"@vercel/blob": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/busboy": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
||||
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/blob": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-1.1.1.tgz",
|
||||
"integrity": "sha512-heiJGj2qt5qTv6yiShH9f6KRAoZGj+lz61GQ+lBRL4lhvUmKI9A51KYlQTnsUd9ymdFlKHBlvmPeG+yGz2Qsbg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"async-retry": "^1.3.3",
|
||||
"is-buffer": "^2.0.5",
|
||||
"is-node-process": "^1.2.0",
|
||||
"throttleit": "^2.1.0",
|
||||
"undici": "^5.28.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14"
|
||||
}
|
||||
},
|
||||
"node_modules/async-retry": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz",
|
||||
"integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"retry": "0.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/is-buffer": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
|
||||
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/is-node-process": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
|
||||
"integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/throttleit": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
|
||||
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "5.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
|
||||
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
relay/package.json
Normal file
6
relay/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "igneum-relay",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": { "@vercel/blob": "^1.1.1" }
|
||||
}
|
||||
7
relay/playbooks/mac-smoke.sh
Normal file
7
relay/playbooks/mac-smoke.sh
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# Igneum playbook for the Mac role (bash): a smoke test of the runner. Queue: node tools/relay.mjs run Mac "mac smoke" relay/playbooks/mac-smoke.sh
|
||||
echo "mac-smoke on $(hostname -s) as ${RELAY_MACHINE:-?} pass ${RELAY_PASS:-?} task ${RELAY_TASK_ID:-?}"
|
||||
uname -a
|
||||
sw_vers 2>/dev/null || true
|
||||
echo "RESULT ok"
|
||||
exit 0
|
||||
23
relay/playbooks/miner-v4.ps1
Normal file
23
relay/playbooks/miner-v4.ps1
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Igneum playbook: fresh miner v4 install and start. Downloads igneum-windows-v4.zip, replaces C:\igneum-v4, starts START-IGNEUM.bat in its own window.
|
||||
# Queue: node tools/relay.mjs run PC1 "miner v4" relay/playbooks/miner-v4.ps1
|
||||
# UNTESTED on a PC as of 4 Oct 2026.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$zipUrl = '__DL_BASE__/igneum-windows-v4.zip'
|
||||
$root = 'C:\igneum-v4'
|
||||
$zip = Join-Path $env:TEMP 'igneum-windows-v4.zip'
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$old = Join-Path $root 'igneum-windows\STOP-IGNEUM.bat'
|
||||
if (Test-Path $old) { Write-Host 'stopping the running miner'; & cmd.exe /c "`"$old`"" 2>&1 | Out-Null; Start-Sleep 3 }
|
||||
Get-Process igneum-miner, igneumd -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "downloading $zipUrl"
|
||||
Invoke-WebRequest -Uri $zipUrl -OutFile $zip -UseBasicParsing -TimeoutSec 900
|
||||
if (Test-Path $root) { Remove-Item $root -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $root | Out-Null
|
||||
Expand-Archive -Path $zip -DestinationPath $root -Force
|
||||
$start = Join-Path $root 'igneum-windows\START-IGNEUM.bat'
|
||||
if (-not (Test-Path $start)) { Write-Host "ERROR: $start missing after extract"; Get-ChildItem $root -Recurse | Select-Object -First 20 | ForEach-Object { $_.FullName }; exit 2 }
|
||||
Write-Host "starting $start in a new window"
|
||||
Start-Process cmd.exe -ArgumentList '/k', "`"$start`"" -WorkingDirectory (Split-Path $start)
|
||||
Start-Sleep 20
|
||||
Get-Process igneum-miner, igneumd -ErrorAction SilentlyContinue | ForEach-Object { Write-Host ("running: " + $_.ProcessName + " pid " + $_.Id) }
|
||||
exit 0
|
||||
7
relay/playbooks/oneclick-test.ps1
Normal file
7
relay/playbooks/oneclick-test.ps1
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Igneum playbook: placeholder for the one-click package test. Fill in once the package name and its checks are settled.
|
||||
# Queue: node tools/relay.mjs run PC1 "one-click test" relay/playbooks/oneclick-test.ps1
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Write-Host "oneclick-test placeholder on $env:COMPUTERNAME as $env:RELAY_MACHINE (pass $env:RELAY_PASS)"
|
||||
Write-Host 'planned: download the one-click zip from __DL_BASE__, extract to C:\igneum-oneclick, run its START script, wait 60 s, post the log'
|
||||
Get-ChildItem C:\ -Directory -Filter 'igneum*' -ErrorAction SilentlyContinue | ForEach-Object { Write-Host ("present: " + $_.FullName) }
|
||||
exit 0
|
||||
24
relay/playbooks/prove-block.ps1
Normal file
24
relay/playbooks/prove-block.ps1
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Igneum playbook: prove the fixture block on the GPU then the CPU (PROVE-BLOCK in the prover package) and post results/*.json.
|
||||
# Queue: node tools/relay.mjs run PC2 "prove block" relay/playbooks/prove-block.ps1
|
||||
# Pause mining on that PC first: the prover takes the whole GPU. UNTESTED on a PC as of 4 Oct 2026.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$distro = 'Ubuntu-24.04'
|
||||
$pkg = 'C:\igneum-prove\igneum-prove-wsl2'
|
||||
$fixture = 'block-78-increment'; if ($env:FIXTURE) { $fixture = $env:FIXTURE }
|
||||
function Strip([string] $s) { if ($null -eq $s) { return '' }; return ($s -replace "`0", '') }
|
||||
if (-not (Test-Path (Join-Path $pkg 'prove-block.sh'))) { Write-Host "ERROR: $pkg\prove-block.sh missing; run prover-setup first"; exit 2 }
|
||||
$start = Get-Date
|
||||
Write-Host "proving $fixture inside $distro (first run compiles for 10 to 30 minutes, approximate)"
|
||||
& wsl.exe -d $distro -u igneum -- bash /mnt/c/igneum-prove/igneum-prove-wsl2/prove-block.sh $fixture core 2>&1 | ForEach-Object { Strip "$_" }
|
||||
$rc = $LASTEXITCODE
|
||||
Write-Host "prove-block.sh exit $rc"
|
||||
$results = Join-Path $pkg 'results'
|
||||
if (Test-Path $results) {
|
||||
Get-ChildItem $results -Filter '*.json' | Where-Object { $_.LastWriteTime -ge $start } | ForEach-Object {
|
||||
Write-Host ("posting " + $_.Name)
|
||||
& $env:RELAY_SEND -TaskId ([long]$env:RELAY_TASK_ID) -Title ("prove result " + $_.Name) $_.FullName
|
||||
Get-Content $_.FullName -Raw | Select-Object -First 1
|
||||
}
|
||||
}
|
||||
Get-ChildItem $pkg -Filter 'prove-*.log' | Where-Object { $_.LastWriteTime -ge $start } | Select-Object -First 1 | ForEach-Object { Write-Host "RESULT lines:"; Select-String -Path $_.FullName -Pattern '^RESULT' | ForEach-Object { $_.Line } }
|
||||
exit $rc
|
||||
26
relay/playbooks/prover-setup.ps1
Normal file
26
relay/playbooks/prover-setup.ps1
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Igneum playbook: install the SP1 prover package inside WSL (CUDA toolkit, Rust, sp1up, pre-build). Needs wsl-setup first.
|
||||
# Queue: node tools/relay.mjs run PC2 "prover setup" relay/playbooks/prover-setup.ps1
|
||||
# Downloads igneum-prove-wsl2.zip from the downloads host (relay.mjs fills __DL_BASE__ in), extracts to C:\igneum-prove,
|
||||
# runs setup-wsl.sh as the igneum user with sudo unlocked (NOPASSWD from wsl-setup; sudo -S with the password as a fallback).
|
||||
# 15 to 40 minutes, 4 to 5 GB of downloads (approximate, from the package README). UNTESTED on a PC as of 4 Oct 2026.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$distro = 'Ubuntu-24.04'
|
||||
$zipUrl = '__DL_BASE__/igneum-prove-wsl2.zip'
|
||||
$root = 'C:\igneum-prove'
|
||||
$zip = Join-Path $env:TEMP 'igneum-prove-wsl2.zip'
|
||||
function Strip([string] $s) { if ($null -eq $s) { return '' }; return ($s -replace "`0", '') }
|
||||
Write-Host "downloading $zipUrl"
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
Invoke-WebRequest -Uri $zipUrl -OutFile $zip -UseBasicParsing -TimeoutSec 600
|
||||
New-Item -ItemType Directory -Force -Path $root | Out-Null
|
||||
Expand-Archive -Path $zip -DestinationPath $root -Force
|
||||
$pkg = Join-Path $root 'igneum-prove-wsl2'
|
||||
if (-not (Test-Path (Join-Path $pkg 'setup-wsl.sh'))) { Write-Host "ERROR: setup-wsl.sh missing under $pkg"; exit 2 }
|
||||
$linuxDir = '/mnt/c/igneum-prove/igneum-prove-wsl2'
|
||||
Write-Host "running setup-wsl.sh inside $distro as igneum (sudo unlocked)"
|
||||
$cmd = "echo igneum | sudo -S -v 2>/dev/null; sudo -n true || echo 'sudo still asks for a password: wsl-setup did not run'; cd $linuxDir && bash ./setup-wsl.sh"
|
||||
& wsl.exe -d $distro -u igneum -- bash -lc $cmd 2>&1 | ForEach-Object { Strip "$_" }
|
||||
$rc = $LASTEXITCODE
|
||||
Write-Host "setup-wsl.sh exit $rc"
|
||||
& wsl.exe -d $distro -u igneum -- bash -lc 'export PATH="$HOME/.cargo/bin:$HOME/.sp1/bin:$PATH"; cargo prove --version; ls -la $HOME/igneum-prove/proving/igneum-prove/target/release/igneum-prove-host 2>/dev/null || echo "host binary not built"' 2>&1 | ForEach-Object { Strip "$_" }
|
||||
exit $rc
|
||||
56
relay/playbooks/wsl-setup.ps1
Normal file
56
relay/playbooks/wsl-setup.ps1
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Igneum playbook: WSL2 + Ubuntu 24.04 with a ready `igneum` user, no questions asked. Two passes around one reboot.
|
||||
# Queue from the Mac: node tools/relay.mjs run PC2 "wsl setup" relay/playbooks/wsl-setup.ps1 --elevated --reboot-continue
|
||||
# Pass 1: hypervisor on, VirtualMachinePlatform + WSL features, prints RELAY-REBOOT (the agent reboots and re-arms).
|
||||
# Pass 2: wsl --install Ubuntu-24.04 --no-launch, creates the user igneum (password igneum, sudo without a password),
|
||||
# makes it the default, checks the GPU is visible inside WSL.
|
||||
# UNTESTED on a PC as of 4 Oct 2026: written from the wsl.exe and dism.exe documentation; expect a first-run fix.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$distro = 'Ubuntu-24.04'
|
||||
$pass = 1; if ($env:RELAY_PASS) { $pass = [int]$env:RELAY_PASS }
|
||||
$markerDir = Join-Path $env:ProgramData 'igneum'; New-Item -ItemType Directory -Force -Path $markerDir | Out-Null
|
||||
$marker = Join-Path $markerDir 'wsl-setup.pass1'
|
||||
function Strip([string] $s) { if ($null -eq $s) { return '' }; return ($s -replace "`0", '') }
|
||||
Write-Host "wsl-setup pass $pass on $env:COMPUTERNAME"
|
||||
|
||||
$installed = $false
|
||||
try { $list = Strip (((& wsl.exe --list --quiet 2>&1) | Out-String)); if ($list -match 'Ubuntu-24\.04') { $installed = $true } } catch {}
|
||||
|
||||
if ($pass -le 1 -and -not (Test-Path $marker) -and -not $installed) {
|
||||
Write-Host 'pass 1: hypervisor + features'
|
||||
& bcdedit.exe /set hypervisorlaunchtype auto
|
||||
& dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
|
||||
& dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
|
||||
Set-Content -Path $marker -Value (Get-Date -Format o)
|
||||
Write-Host 'features enabled; a restart is needed before Ubuntu can be installed'
|
||||
Write-Host 'RELAY-REBOOT'
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host 'pass 2: WSL kernel, Ubuntu 24.04, the igneum user'
|
||||
& wsl.exe --update 2>&1 | ForEach-Object { Strip "$_" }
|
||||
& wsl.exe --set-default-version 2 2>&1 | ForEach-Object { Strip "$_" }
|
||||
if (-not $installed) {
|
||||
& wsl.exe --install -d $distro --no-launch 2>&1 | ForEach-Object { Strip "$_" }
|
||||
Start-Sleep 5
|
||||
}
|
||||
$list = Strip (((& wsl.exe --list --verbose 2>&1) | Out-String)); Write-Host $list
|
||||
if ($list -notmatch 'Ubuntu-24\.04') { Write-Host "ERROR: $distro is not registered after the install"; exit 2 }
|
||||
|
||||
$mk = 'id igneum >/dev/null 2>&1 || (useradd -m -s /bin/bash igneum && echo igneum:igneum | chpasswd && usermod -aG sudo igneum); ' +
|
||||
'echo "igneum ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/igneum && chmod 440 /etc/sudoers.d/igneum; ' +
|
||||
'printf "[user]\ndefault=igneum\n[boot]\nsystemd=true\n" > /etc/wsl.conf; id igneum'
|
||||
& wsl.exe -d $distro -u root -- bash -c $mk 2>&1 | ForEach-Object { Strip "$_" }
|
||||
& wsl.exe --terminate $distro 2>&1 | Out-Null
|
||||
|
||||
$wslconfig = Join-Path $env:USERPROFILE '.wslconfig'
|
||||
if (-not (Test-Path $wslconfig)) {
|
||||
$ramGb = [math]::Floor((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB)
|
||||
$give = [math]::Max(16, [math]::Floor($ramGb * 0.75))
|
||||
"[wsl2]`nmemory=${give}GB`nswap=16GB`n" | Set-Content -Path $wslconfig -Encoding ascii
|
||||
Write-Host "wrote $wslconfig (memory=${give}GB of $ramGb GB)"
|
||||
& wsl.exe --shutdown 2>&1 | Out-Null
|
||||
}
|
||||
Write-Host 'check: user, kernel, GPU inside WSL'
|
||||
& wsl.exe -d $distro -- bash -lc 'id; uname -r; nvidia-smi --query-gpu=name,driver_version --format=csv,noheader 2>/dev/null || ls -l /usr/lib/wsl/lib/libcuda.so.1 2>/dev/null || echo "no GPU visible inside WSL (update the Windows NVIDIA driver, then wsl --update)"' 2>&1 | ForEach-Object { Strip "$_" }
|
||||
Write-Host 'wsl-setup done'
|
||||
exit 0
|
||||
2
relay/robots.txt
Normal file
2
relay/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow: /
|
||||
328
relay/ui.html
Normal file
328
relay/ui.html
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Igneum relay</title>
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="theme-color" content="#0C0C0E">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='48' fill='%230C0C0E'/%3E%3Ccircle cx='50' cy='50' r='45' fill='none' stroke='%23F2541B' stroke-width='6'/%3E%3Cg transform='translate(24 24) scale(0.52)'%3E%3Cpolygon points='50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34' fill='%23F2541B'/%3E%3Cpolygon points='50,42 59,58 50,82 41,58' fill='%230C0C0E'/%3E%3C/g%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Unbounded:wght@700;900&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
||||
<style>
|
||||
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;--gutter:16px;--r:14px}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--obsidian);color:var(--bone)}
|
||||
body{font-family:'IBM Plex Sans',system-ui,-apple-system,sans-serif;font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased;padding-bottom:env(safe-area-inset-bottom)}
|
||||
a{color:inherit;text-decoration:none}
|
||||
button{font:inherit;color:inherit;cursor:pointer}
|
||||
.mono{font-family:'IBM Plex Mono',monospace}
|
||||
.wrap{max-width:860px;margin:0 auto;padding-inline:var(--gutter)}
|
||||
header{position:sticky;top:0;z-index:5;background:rgba(12,12,14,.88);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--line)}
|
||||
header .wrap{display:flex;align-items:center;justify-content:space-between;min-height:56px;gap:12px}
|
||||
.brand{display:flex;align-items:center;gap:10px}
|
||||
.brand .word{font-family:'Unbounded',sans-serif;font-weight:900;font-size:16px;letter-spacing:.06em}
|
||||
.brand .sub{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
|
||||
.status{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash);display:flex;align-items:center;gap:8px;white-space:nowrap}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--molten);display:inline-block}
|
||||
.dot.off{background:var(--ash)}
|
||||
main{padding-block:14px 48px;display:flex;flex-direction:column;gap:14px}
|
||||
|
||||
/* waiting strip */
|
||||
.waiting{display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px}
|
||||
.waiting::-webkit-scrollbar{display:none}
|
||||
.wchip{flex:0 0 auto;display:inline-flex;align-items:center;gap:8px;border:1px solid var(--line-2);border-radius:999px;padding:6px 12px;background:var(--graphite);font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--ink-2)}
|
||||
.wchip b{color:var(--bone);font-weight:500}
|
||||
.wchip .n{background:var(--ember);color:var(--ember-ink);border-radius:999px;min-width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;font-weight:500;padding-inline:6px}
|
||||
.wchip.zero .n{background:var(--line-2);color:var(--ash)}
|
||||
.wchip.on{border-color:var(--ember)}
|
||||
.wchip .role{color:var(--ash)}
|
||||
.unknown{border:1px solid var(--molten);border-radius:var(--r);padding:12px 14px;background:var(--graphite);display:flex;flex-wrap:wrap;gap:10px;align-items:center;font-size:14px}
|
||||
.unknown .mono{color:var(--molten)}
|
||||
.unknown input{width:90px}
|
||||
|
||||
/* composer */
|
||||
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--r);padding:14px}
|
||||
.composer{display:flex;flex-direction:column;gap:10px}
|
||||
.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
||||
.row label{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ash)}
|
||||
select,input[type=text],textarea{background:var(--obsidian);border:1px solid var(--line-2);color:var(--bone);border-radius:10px;padding:10px 12px;font:inherit;min-height:42px}
|
||||
select{appearance:none;-webkit-appearance:none;padding-right:30px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%239A9A9E' stroke-width='1.6' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 11px center}
|
||||
textarea{width:100%;min-height:96px;resize:vertical;font-family:'IBM Plex Mono',monospace;font-size:14px;line-height:1.45}
|
||||
input[type=text]{width:100%}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:42px;padding:9px 16px;border-radius:10px;font-weight:600;font-size:14px;border:1px solid var(--line-2);color:var(--bone);background:transparent;transition:border-color .15s,background .15s}
|
||||
.btn:hover{border-color:var(--ash)}
|
||||
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
||||
.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B}
|
||||
.btn.primary:disabled{opacity:.5;cursor:default}
|
||||
.btn.small{min-height:32px;padding:5px 11px;font-size:12px;font-weight:500}
|
||||
.drop{border:1px dashed var(--line-2);border-radius:10px;padding:12px;text-align:center;color:var(--ash);font-size:13px;cursor:pointer;transition:border-color .15s,color .15s}
|
||||
.drop.over{border-color:var(--ember);color:var(--bone)}
|
||||
.drop.has{border-style:solid;border-color:var(--molten);color:var(--bone)}
|
||||
.drop input{display:none}
|
||||
.seg{display:inline-flex;border:1px solid var(--line-2);border-radius:10px;overflow:hidden}
|
||||
.seg button{background:transparent;border:0;padding:8px 12px;font-size:13px;color:var(--ash);min-height:40px}
|
||||
.seg button.on{background:var(--line);color:var(--bone)}
|
||||
.progress{height:3px;background:var(--line);border-radius:2px;overflow:hidden;display:none}
|
||||
.progress i{display:block;height:100%;width:0;background:var(--ember);transition:width .2s}
|
||||
.msg{font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--ash);min-height:16px}
|
||||
.msg.err{color:var(--ember)}
|
||||
|
||||
/* filters */
|
||||
.filters{display:flex;gap:8px;overflow-x:auto;scrollbar-width:none}
|
||||
.filters::-webkit-scrollbar{display:none}
|
||||
.chip{flex:0 0 auto;border:1px solid var(--line-2);background:transparent;color:var(--ink-2);border-radius:999px;padding:6px 12px;font-size:13px;min-height:34px}
|
||||
.chip.on{background:var(--bone);color:var(--obsidian);border-color:var(--bone)}
|
||||
|
||||
/* feed */
|
||||
.group{display:flex;flex-direction:column;gap:8px}
|
||||
.ghead{display:flex;align-items:baseline;justify-content:space-between;gap:10px;padding:8px 2px 2px}
|
||||
.ghead h2{font-family:'Unbounded',sans-serif;font-weight:700;font-size:14px;margin:0;letter-spacing:.02em}
|
||||
.ghead .meta{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash)}
|
||||
.item{background:var(--graphite);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px;display:flex;flex-direction:column;gap:8px;cursor:pointer;min-width:0}
|
||||
.item.unread{border-color:var(--line-2);box-shadow:inset 3px 0 0 var(--ember)}
|
||||
.item.done{opacity:.7}
|
||||
.ihead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.badge{font-family:'IBM Plex Mono',monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:6px;padding:3px 7px;border:1px solid var(--line-2);color:var(--ink-2)}
|
||||
.badge.text{color:var(--ink-2)}
|
||||
.badge.file{color:var(--molten);border-color:rgba(255,179,92,.5)}
|
||||
.badge.task{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
||||
.badge.run{background:var(--molten);color:var(--ember-ink);border-color:var(--molten)}
|
||||
.badge.result{color:var(--molten);border-color:var(--molten)}
|
||||
.ihead .route{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash)}
|
||||
.ihead .route b{color:var(--ink-2);font-weight:500}
|
||||
.ihead .time{margin-left:auto;font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash);white-space:nowrap}
|
||||
.title{font-weight:600;font-size:15px;line-height:1.35;overflow-wrap:anywhere}
|
||||
.preview{color:var(--ink-2);font-family:'IBM Plex Mono',monospace;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.body{display:none;flex-direction:column;gap:8px}
|
||||
.item.open .body{display:flex}
|
||||
.item.open .preview{display:none}
|
||||
pre{position:relative;margin:0;background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:12px 12px 12px;font-family:'IBM Plex Mono',monospace;font-size:12.5px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink-2);max-height:60vh;overflow:auto}
|
||||
pre.code{color:var(--bone)}
|
||||
.block{position:relative}
|
||||
.copy{position:absolute;top:6px;right:6px;z-index:1}
|
||||
.copy.ok{border-color:var(--molten);color:var(--molten)}
|
||||
.file{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-family:'IBM Plex Mono',monospace;font-size:12.5px}
|
||||
.file .name{color:var(--molten);overflow-wrap:anywhere}
|
||||
.file .size{color:var(--ash)}
|
||||
.acts{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.flags{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash)}
|
||||
.empty{color:var(--ash);text-align:center;padding:32px 0;font-family:'IBM Plex Mono',monospace;font-size:12px}
|
||||
.more{align-self:center}
|
||||
@media (min-width:700px){:root{--gutter:24px}body{font-size:16px}.item{padding:14px 16px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="wrap">
|
||||
<div class="brand"><svg width="22" height="22" viewBox="0 0 100 100" aria-hidden="true"><circle cx="50" cy="50" r="48" fill="#0C0C0E"/><circle cx="50" cy="50" r="45" fill="none" stroke="#F2541B" stroke-width="6"/><g transform="translate(24 24) scale(0.52)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"/><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"/></g></svg><span class="word">IGNEUM</span><span class="sub">relay</span></div>
|
||||
<div class="status"><span class="dot" id="dot"></span><span id="stat">connecting</span></div>
|
||||
</div></header>
|
||||
<main class="wrap">
|
||||
<div id="unknown"></div>
|
||||
<div class="waiting" id="waiting"></div>
|
||||
<section class="card composer" id="composer">
|
||||
<div class="row">
|
||||
<label>from</label><select id="from"></select>
|
||||
<label>to</label><select id="to"></select>
|
||||
<div class="seg" id="kindseg"><button data-k="text" class="on">note</button><button data-k="task">task</button></div>
|
||||
</div>
|
||||
<input type="text" id="title" placeholder="title (optional for notes, the headline for tasks)">
|
||||
<textarea id="text" placeholder="paste text, a command, a log, a path. Shift+Enter for a new line, Enter sends."></textarea>
|
||||
<label class="drop" id="drop"><input type="file" id="file"><span id="droplabel">drop a file here or tap to pick one (up to 50 MB)</span></label>
|
||||
<div class="progress" id="prog"><i></i></div>
|
||||
<div class="row"><button class="btn primary" id="send">Send</button><button class="btn" id="clear">Clear</button><span class="msg" id="msg"></span></div>
|
||||
</section>
|
||||
<div class="filters" id="filters"></div>
|
||||
<div id="feed"><div class="empty">loading</div></div>
|
||||
<button class="btn more" id="more" style="display:none">older</button>
|
||||
</main>
|
||||
<template id="tpl-item"></template>
|
||||
<script>
|
||||
(() => {
|
||||
const m = location.pathname.match(/^\/r\/([^/]+)/);
|
||||
const TOKEN = m ? m[1] : '';
|
||||
const API = `/r/${TOKEN}/api/`;
|
||||
const $ = s => document.querySelector(s);
|
||||
const DEFAULT_MACHINES = ['Mac', 'PC1', 'PC2', 'Phone'];
|
||||
const state = { items: [], machines: [], filter: 'all', from: localStorage.getItem('relay.from') || guessFrom(), to: localStorage.getItem('relay.to') || 'all', kind: 'text', file: null, open: new Set(), maxId: 0, minId: 0, more: true };
|
||||
function guessFrom() { return /iPhone|Android|iPad/i.test(navigator.userAgent) ? 'Phone' : 'Mac'; }
|
||||
|
||||
async function api(fn, opts = {}) {
|
||||
const r = await fetch(API + fn + (opts.q ? '?' + new URLSearchParams(opts.q) : ''), opts.body !== undefined ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(opts.body) } : {});
|
||||
const j = await r.json().catch(() => ({ ok: false, error: r.status }));
|
||||
if (!r.ok || j.ok === false) throw new Error(j.error || ('http ' + r.status));
|
||||
return j;
|
||||
}
|
||||
const fmtSize = n => n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
|
||||
const ago = ts => { const s = (Date.now() - new Date(ts)) / 1000; if (s < 60) return 'now'; if (s < 3600) return Math.floor(s / 60) + 'm'; if (s < 86400) return Math.floor(s / 3600) + 'h'; return Math.floor(s / 86400) + 'd'; };
|
||||
const when = ts => { const d = new Date(ts); return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) + ' ' + d.toTimeString().slice(0, 5); };
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
function setStat(txt, on) { $('#stat').textContent = txt; $('#dot').classList.toggle('off', !on); }
|
||||
|
||||
function machineNames() {
|
||||
const names = new Set(DEFAULT_MACHINES);
|
||||
for (const mc of state.machines) names.add(mc.name);
|
||||
return [...names];
|
||||
}
|
||||
function fillSelects() {
|
||||
const names = machineNames();
|
||||
const from = $('#from'); from.innerHTML = names.map(n => `<option ${n === state.from ? 'selected' : ''}>${esc(n)}</option>`).join('');
|
||||
const to = $('#to'); to.innerHTML = ['all', ...names].map(n => `<option value="${esc(n)}" ${n === state.to ? 'selected' : ''}>${n === 'all' ? 'everyone' : esc(n)}</option>`).join('');
|
||||
}
|
||||
|
||||
function renderWaiting() {
|
||||
const w = $('#waiting');
|
||||
const list = state.machines.filter(mc => mc.named !== false || mc.unread);
|
||||
w.innerHTML = list.map(mc => `<button class="wchip ${mc.unread ? 'on' : 'zero'} ${state.filter === mc.name ? 'on' : ''}" data-m="${esc(mc.name)}"><b>${esc(mc.name)}</b>${mc.role ? `<span class="role">${esc(mc.role)}</span>` : ''}<span class="n">${mc.unread || 0}</span><span class="role">${mc.last_seen ? ago(mc.last_seen) : 'never'}</span></button>`).join('');
|
||||
w.querySelectorAll('.wchip').forEach(b => b.onclick = () => { state.filter = state.filter === b.dataset.m ? 'all' : b.dataset.m; renderFilters(); renderFeed(); renderWaiting(); });
|
||||
const unknown = state.machines.filter(mc => mc.named === false && mc.hostname);
|
||||
$('#unknown').innerHTML = unknown.map(mc => `<div class="unknown"><span>New machine <span class="mono">${esc(mc.hostname)}</span>${mc.info && mc.info.gpus ? ` (${esc([].concat(mc.info.gpus).join(', '))})` : ''}. Name it:</span><input type="text" placeholder="PC2" data-h="${esc(mc.hostname)}"><button class="btn small primary" data-h="${esc(mc.hostname)}">Save</button></div>`).join('');
|
||||
$('#unknown').querySelectorAll('button').forEach(b => b.onclick = async () => {
|
||||
const inp = $('#unknown').querySelector(`input[data-h="${CSS.escape(b.dataset.h)}"]`);
|
||||
const name = inp.value.trim(); if (!name) return inp.focus();
|
||||
try { await api('name', { body: { hostname: b.dataset.h, name } }); await refresh(true); } catch (e) { flash(e.message, true); }
|
||||
});
|
||||
}
|
||||
function renderFilters() {
|
||||
const f = $('#filters');
|
||||
const names = ['all', ...machineNames()];
|
||||
f.innerHTML = names.map(n => `<button class="chip ${state.filter === n ? 'on' : ''}" data-f="${esc(n)}">${n === 'all' ? 'everything' : esc(n)}</button>`).join('');
|
||||
f.querySelectorAll('.chip').forEach(b => b.onclick = () => { state.filter = b.dataset.f; renderFilters(); renderFeed(); renderWaiting(); });
|
||||
}
|
||||
|
||||
function segments(body) {
|
||||
// split ``` fences into code blocks so each gets its own copy button
|
||||
const out = []; const re = /```[^\n]*\n([\s\S]*?)```/g; let last = 0, x;
|
||||
while ((x = re.exec(body))) { if (x.index > last) out.push({ code: false, t: body.slice(last, x.index).trim() }); out.push({ code: true, t: x[1].replace(/\n$/, '') }); last = x.index + x[0].length; }
|
||||
if (last < body.length) out.push({ code: false, t: body.slice(last).trim() });
|
||||
return out.filter(s => s.t);
|
||||
}
|
||||
function itemHtml(it) {
|
||||
const open = state.open.has(it.id);
|
||||
const route = it.to === 'all' ? `<b>${esc(it.from)}</b> to everyone` : `<b>${esc(it.from)}</b> to <b>${esc(it.to)}</b>`;
|
||||
const flags = Object.entries(it.flags || {}).filter(([k, v]) => v !== false && v !== null && v !== '').map(([k, v]) => v === true ? k : `${k}=${v}`).join(' ');
|
||||
const unread = !it.read && (it.kind === 'task' || it.kind === 'run');
|
||||
const segs = segments(it.body || '');
|
||||
const bodyHtml = segs.map(s => `<div class="block"><button class="btn small copy" data-copy>${s.code ? 'copy' : 'copy'}</button><pre class="${s.code ? 'code' : ''}">${esc(s.t)}</pre></div>`).join('');
|
||||
const file = it.has_file ? `<div class="file"><span class="name">${esc(it.file_name || 'file')}</span><span class="size">${fmtSize(it.size)}</span><a class="btn small" href="${API}file?id=${it.id}&download=1" download onclick="event.stopPropagation()">download</a><a class="btn small" href="${API}file?id=${it.id}" target="_blank" rel="noopener" onclick="event.stopPropagation()">open</a></div>` : '';
|
||||
const acts = `<div class="acts">${unread ? `<button class="btn small" data-act="ack">mark read</button>` : ''}${(it.kind === 'task' || it.kind === 'run') && !it.done ? `<button class="btn small" data-act="done">done</button>` : ''}<button class="btn small" data-act="copyall">copy all</button><button class="btn small" data-act="del">delete</button></div>`;
|
||||
const preview = (it.body || '').split('\n').find(l => l.trim()) || (it.has_file ? it.file_name : '') || '';
|
||||
return `<article class="item ${open ? 'open' : ''} ${unread ? 'unread' : ''} ${it.done ? 'done' : ''}" data-id="${it.id}">
|
||||
<div class="ihead"><span class="badge ${it.kind}">${it.kind}</span><span class="route">${route}</span><span class="time" title="${esc(it.ts)}">${when(it.ts)}</span></div>
|
||||
${it.title ? `<div class="title">${esc(it.title)}</div>` : ''}
|
||||
<div class="preview">${esc(preview)}</div>
|
||||
<div class="body">${flags ? `<div class="flags">${esc(flags)}${it.task_id ? ` task #${it.task_id}` : ''}${it.done ? ' done' : it.read ? ' read' : ''} #${it.id}</div>` : `<div class="flags">#${it.id}${it.task_id ? ` for task #${it.task_id}` : ''}${it.done ? ' done' : it.read && (it.kind === 'task' || it.kind === 'run') ? ' read' : ''}</div>`}${bodyHtml}${file}${acts}</div>
|
||||
</article>`;
|
||||
}
|
||||
function renderFeed() {
|
||||
const feed = $('#feed');
|
||||
const items = state.items.filter(it => state.filter === 'all' || it.from === state.filter || it.to === state.filter);
|
||||
if (!items.length) { feed.innerHTML = `<div class="empty">nothing here yet</div>`; $('#more').style.display = 'none'; return; }
|
||||
// group by the machine that posted it, groups ordered by their newest item, items newest first inside
|
||||
const groups = new Map();
|
||||
for (const it of items) { const k = it.from; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(it); }
|
||||
feed.innerHTML = [...groups.entries()].map(([name, list]) => `<section class="group"><div class="ghead"><h2>${esc(name)}</h2><span class="meta">${list.length} item${list.length === 1 ? '' : 's'}, latest ${ago(list[0].ts)} ago</span></div>${list.map(itemHtml).join('')}</section>`).join('');
|
||||
$('#more').style.display = state.more && state.items.length >= 200 ? '' : 'none';
|
||||
feed.querySelectorAll('.item').forEach(el => {
|
||||
el.onclick = e => { if (e.target.closest('button,a,pre')) return; const id = Number(el.dataset.id); state.open.has(id) ? state.open.delete(id) : state.open.add(id); el.classList.toggle('open'); };
|
||||
el.querySelectorAll('[data-copy]').forEach(b => b.onclick = async e => { e.stopPropagation(); await copy(b.nextElementSibling.textContent, b); });
|
||||
el.querySelectorAll('[data-act]').forEach(b => b.onclick = async e => {
|
||||
e.stopPropagation(); const id = Number(el.dataset.id); const it = state.items.find(i => i.id === id);
|
||||
try {
|
||||
if (b.dataset.act === 'ack') { await api('ack', { body: { ids: [id] } }); it.read = true; }
|
||||
if (b.dataset.act === 'done') { await api('done', { body: { id } }); it.done = true; it.read = true; }
|
||||
if (b.dataset.act === 'copyall') { await copy(it.body || '', b); return; }
|
||||
if (b.dataset.act === 'del') { if (!confirm('Delete this item?')) return; await api('delete', { body: { id } }); state.items = state.items.filter(i => i.id !== id); }
|
||||
renderFeed(); refresh(true);
|
||||
} catch (err) { flash(err.message, true); }
|
||||
});
|
||||
});
|
||||
}
|
||||
async function copy(text, btn) {
|
||||
try { await navigator.clipboard.writeText(text); } catch { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
|
||||
if (btn) { const old = btn.textContent; btn.textContent = 'copied'; btn.classList.add('ok'); setTimeout(() => { btn.textContent = old; btn.classList.remove('ok'); }, 1200); }
|
||||
}
|
||||
function flash(t, err) { const el = $('#msg'); el.textContent = t; el.classList.toggle('err', !!err); if (!err) setTimeout(() => { if (el.textContent === t) el.textContent = ''; }, 4000); }
|
||||
|
||||
async function refresh(full) {
|
||||
try {
|
||||
const q = full || !state.maxId ? { limit: 200 } : { since: state.maxId };
|
||||
const j = await api('feed', { q });
|
||||
state.machines = j.machines;
|
||||
if (full || !state.maxId) { state.items = j.items; state.more = j.items.length >= 200; }
|
||||
else if (j.items.length) { const ids = new Set(state.items.map(i => i.id)); state.items = [...j.items.filter(i => !ids.has(i.id)), ...state.items]; }
|
||||
if (!full && state.maxId) {
|
||||
// keep read/done flags of visible items fresh without refetching everything: cheap second call only when machines report unread
|
||||
if (j.items.length === 0 && state.items.length) { const j2 = await api('feed', { q: { limit: 60 } }); for (const n of j2.items) { const o = state.items.find(i => i.id === n.id); if (o) Object.assign(o, n); } }
|
||||
}
|
||||
state.maxId = state.items.reduce((a, i) => Math.max(a, i.id), 0);
|
||||
state.minId = state.items.reduce((a, i) => Math.min(a, i.id), Infinity);
|
||||
fillSelects(); renderWaiting(); renderFilters(); renderFeed();
|
||||
setStat('live ' + new Date().toTimeString().slice(0, 8), true);
|
||||
} catch (e) { setStat('offline: ' + e.message, false); }
|
||||
}
|
||||
async function older() {
|
||||
try { const j = await api('feed', { q: { before: state.minId, limit: 200 } }); state.items = [...state.items, ...j.items]; state.more = j.items.length >= 200; state.minId = state.items.reduce((a, i) => Math.min(a, i.id), Infinity); renderFeed(); } catch (e) { flash(e.message, true); }
|
||||
}
|
||||
|
||||
// composer
|
||||
$('#from').onchange = e => { state.from = e.target.value; localStorage.setItem('relay.from', state.from); };
|
||||
$('#to').onchange = e => { state.to = e.target.value; localStorage.setItem('relay.to', state.to); };
|
||||
$('#kindseg').querySelectorAll('button').forEach(b => b.onclick = () => { state.kind = b.dataset.k; $('#kindseg').querySelectorAll('button').forEach(x => x.classList.toggle('on', x === b)); $('#title').placeholder = state.kind === 'task' ? 'what the machine should do (the headline)' : 'title (optional for notes, the headline for tasks)'; });
|
||||
const drop = $('#drop');
|
||||
const setFile = f => { state.file = f || null; drop.classList.toggle('has', !!f); $('#droplabel').textContent = f ? `${f.name} (${fmtSize(f.size)})` : 'drop a file here or tap to pick one (up to 50 MB)'; };
|
||||
$('#file').onchange = e => setFile(e.target.files[0]);
|
||||
['dragenter', 'dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('over'); }));
|
||||
['dragleave', 'drop'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('over'); }));
|
||||
document.addEventListener('drop', e => { if (e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]); });
|
||||
document.addEventListener('paste', e => { const f = [...(e.clipboardData.files || [])][0]; if (f && document.activeElement !== $('#text')) setFile(f); });
|
||||
$('#text').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !/Mobi|Android/i.test(navigator.userAgent)) { e.preventDefault(); send(); } });
|
||||
$('#clear').onclick = () => { $('#text').value = ''; $('#title').value = ''; setFile(null); $('#file').value = ''; flash(''); };
|
||||
$('#send').onclick = send;
|
||||
$('#more').onclick = older;
|
||||
|
||||
async function uploadFile(f) {
|
||||
const t = await api('upload', { body: { name: f.name, size: f.size } });
|
||||
if (f.size > t.max) throw new Error(`file over ${fmtSize(t.max)}`);
|
||||
const prog = $('#prog'); prog.style.display = 'block'; const bar = prog.firstElementChild;
|
||||
const url = await new Promise((resolve, reject) => {
|
||||
const x = new XMLHttpRequest();
|
||||
x.open('PUT', t.put_url);
|
||||
// only headers the Blob API allows cross-origin; the client token already carries the pathname and the random suffix
|
||||
x.setRequestHeader('authorization', 'Bearer ' + t.token);
|
||||
x.setRequestHeader('x-api-version', t.api_version);
|
||||
x.setRequestHeader('content-type', f.type || 'application/octet-stream');
|
||||
x.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = Math.round(e.loaded / e.total * 100) + '%'; };
|
||||
x.onload = () => { try { const j = JSON.parse(x.responseText); if (x.status < 300 && j.url) resolve(j.url); else reject(new Error(j.error && j.error.message || ('upload http ' + x.status))); } catch { reject(new Error('upload http ' + x.status)); } };
|
||||
x.onerror = () => reject(new Error('upload failed'));
|
||||
x.send(f);
|
||||
});
|
||||
prog.style.display = 'none'; bar.style.width = '0';
|
||||
return { file_name: f.name, file_url: url, size: f.size };
|
||||
}
|
||||
async function send() {
|
||||
const text = $('#text').value; const title = $('#title').value.trim();
|
||||
if (!text.trim() && !state.file && !title) return flash('nothing to send', true);
|
||||
if (state.kind === 'task' && state.to === 'all' && false) return;
|
||||
const btn = $('#send'); btn.disabled = true; flash(state.file ? 'uploading' : 'sending');
|
||||
try {
|
||||
const o = { from: state.from, to: state.to, kind: state.kind, title, body: text };
|
||||
if (state.file) Object.assign(o, await uploadFile(state.file));
|
||||
if (!o.body && !o.file_url) o.kind = 'text';
|
||||
if (o.file_url && o.kind === 'text') o.kind = 'file';
|
||||
await api(state.kind === 'task' ? 'task' : 'drop', { body: o });
|
||||
$('#text').value = ''; $('#title').value = ''; setFile(null); $('#file').value = '';
|
||||
flash('sent'); await refresh(false);
|
||||
} catch (e) { flash(e.message, true); } finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
if (!TOKEN) { setStat('no token in the URL', false); document.body.innerHTML += '<p class="empty">open the relay through its /r/<token>/ link</p>'; return; }
|
||||
refresh(true);
|
||||
setInterval(() => { if (document.visibilityState === 'visible') refresh(false); }, 15000);
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') refresh(false); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
17
relay/vercel.json
Normal file
17
relay/vercel.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"cleanUrls": true,
|
||||
"trailingSlash": false,
|
||||
"rewrites": [
|
||||
{ "source": "/r/:token", "destination": "/ui" },
|
||||
{ "source": "/r/:token/api/:fn", "destination": "/api/relay?token=:token&fn=:fn" }
|
||||
],
|
||||
"headers": [
|
||||
{ "source": "/(.*)", "headers": [
|
||||
{ "key": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "key": "X-Frame-Options", "value": "DENY" },
|
||||
{ "key": "X-Robots-Tag", "value": "noindex, nofollow" },
|
||||
{ "key": "Referrer-Policy", "value": "no-referrer" },
|
||||
{ "key": "Cache-Control", "value": "no-store" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
140
tools/relay.mjs
Normal file
140
tools/relay.mjs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
#!/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
|
||||
// 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 "..."]
|
||||
// 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] a script the igneum-agent runs
|
||||
// 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
|
||||
// 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 (the URL secret), log-intake-key (x-igneum-key), dl-token (for __DL_BASE__ in
|
||||
// playbooks) and relay-url (optional, default https://relay.igneum.network). Zero dependencies.
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { homedir, tmpdir, hostname } from 'node:os';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
|
||||
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
|
||||
const cfg = n => { try { return readFileSync(join(homedir(), '.config', 'igneum', n), 'utf8').trim(); } catch { return ''; } };
|
||||
const TOKEN = cfg('relay-token'); const KEY = cfg('log-intake-key'); 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}/r/${TOKEN}/api/`;
|
||||
const WEB = `${BASE}/r/${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', 'ack', 'all'].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: { 'x-igneum-key': KEY } }
|
||||
: { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-igneum-key': KEY }, 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: { 'x-igneum-key': KEY }, 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;
|
||||
}
|
||||
function playbook(path) {
|
||||
let s = readFileSync(path, 'utf8');
|
||||
s = s.replace(/__DL_BASE__/g, DL ? `https://dl.igneum.network/dl/${DL}` : 'https://dl.igneum.network/dl/MISSING-DL-TOKEN');
|
||||
return s;
|
||||
}
|
||||
|
||||
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(`${WEB}\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' }); 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'], shell: file.endsWith('.sh') ? 'bash' : 'powershell', script: basename(file) };
|
||||
} 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 === 'inbox') {
|
||||
const machine = pos[1]; if (!machine) throw new Error('inbox <machine>');
|
||||
const j = await api('inbox', { q: { machine, ...(flags.ack ? { ack: 1 } : {}) } });
|
||||
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 ${WEB} 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); }
|
||||
Loading…
Reference in a new issue