ui-ota: the publisher (tools/ui-ota/publish.mjs), publish-manifest.sh --ui and --no-ui, the plan with the security notes
publish.mjs packs the fifteen served files with one fixed mtime (reproducible; the self-test checks it), hashes, signs the entry through igneum-ota-sign sign-ui with the key in ~/.config/igneum (never read or printed here), copies the bundle into the folder's ui/ and hands ui.json to publish-manifest.sh --ui, the one writer of the signed manifest, which verifies the entry and the bundle's hash before signing; --dry-run writes nothing, --verify reads the live manifest back against dl/<token>/ui and dl/public/ui; --no-ui withdraws the channel. Both self-tests sit on the one gate. docs/plans/ui-ota.md: the shape, the engine, the security notes, the operator recipe, the tests, per tier. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b6a0fd0d75
commit
e02f5e14d0
4 changed files with 328 additions and 4 deletions
121
docs/plans/ui-ota.md
Normal file
121
docs/plans/ui-ota.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# The interface over the air: smaller UI changes without a new version
|
||||
|
||||
7 October 2026, 10:5x UK. the project lead: "can we make smaller UI based updates over the air without a new version being
|
||||
needed?" Branch `ui-ota`, worktree `../igneum-wt-ui-ota`, off the miner-ui-5 tip e9fe1106; the shipper takes it into
|
||||
0.3.20. The dashboard (app/igneum-app/ui: index.html, app.js, app.css, live-dag.js, proof-core.js, the fonts, the
|
||||
mark) is embedded in the engine binary today; from 0.3.20 a signed bundle of the same folder can replace it between
|
||||
app versions. The engine, the node, the miner and the workers never move this way: a bundle is files the engine
|
||||
serves on 127.0.0.1, nothing it runs.
|
||||
|
||||
## 1. What a miner sees
|
||||
|
||||
| Where | What |
|
||||
|---|---|
|
||||
| Nothing, usually | The hourly manifest check finds an interface bundle, downloads about 400 KB, checks it, swaps it in; the open window reloads itself the next second it is idle (no input focused, no sheet or update card open, not mid-setup). |
|
||||
| Settings > Interface | "Interface 1.0.1, over the air, 7 Oct 2026" or "Interface 1.0.0, built in"; the help line says what is pending, refused, held back or skipped. |
|
||||
| Settings > Interface, the switch | "Use the built-in interface": the embedded dashboard serves even when a bundle is active; off again takes the bundle at the next load. |
|
||||
| Activity | "interface 1.0.1 is published: downloading (412 KB)", "interface 1.0.1 installed; the window takes it at its next load", or "interface 1.0.1 was refused: ... The built-in interface serves". |
|
||||
|
||||
## 2. The manifest's interface channel
|
||||
|
||||
The signed manifest (igneum-app-latest.json, src/manifest.rs) gains one object, covered by the manifest's own
|
||||
signature like every other field:
|
||||
|
||||
```
|
||||
"ui": { "version": "1.0.1", "sha256": "<64 hex>", "size": 412345,
|
||||
"url": "https://dl.igneum.network/dl/<token>/ui/igneum-ui-1.0.1.tar.gz",
|
||||
"min_engine": "0.3.20", "signature": "<128 hex>" }
|
||||
```
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| version | the interface's own three-part line (1.0.0 is the one embedded in 0.3.20; ui/VERSION in the tree) |
|
||||
| sha256, size | of the tar.gz; both checked after the download |
|
||||
| url | https on the downloads host, under ui/; a bundle is never fetched from anywhere the manifest did not name |
|
||||
| min_engine | the lowest app version that may serve it; an engine below it ignores the entry and says so in its log |
|
||||
| signature | Ed25519 by the release key (the public half pinned in src/manifest.rs) over `igneum-ui\n<version>\n<sha256>\n<min_engine>\n` (`manifest::ui_sign_bytes`); the bundle stays verifiable on its own, and the shipper's point stands: the manifest signature already covers it, this one is the belt to that brace |
|
||||
|
||||
The kill switch is the manifest without a `ui` object (`publish-manifest.sh --no-ui`): every engine retires its bundle
|
||||
at the next check and the built-in interface serves. A bundle that fails any check is marked bad on that machine
|
||||
and never applied again; the next version is a new chance.
|
||||
|
||||
## 3. The engine (src/uiota.rs, src/ota.rs, src/server.rs)
|
||||
|
||||
| Step | What happens |
|
||||
|---|---|
|
||||
| decide | `uiota::decide`: skip when the built-in interface is chosen, when min_engine is newer than the engine, when the version was marked bad, when it is the active one, or when it is the embedded one |
|
||||
| download | curl with resume into `<app data>/ui/<version>.tar.gz`; size and sha256 against the entry; the entry's signature against the pinned key |
|
||||
| unpack | the system tar into `<app data>/ui/<version>.new`; index.html, app.js and app.css must be there; the bundle's VERSION must say the manifest's version; renamed to `<app data>/ui/<version>` |
|
||||
| swap | `<app data>/ui/current.json` (written to .tmp, renamed) names the version; the server reads the pointer through `Shared::ui_dir` and serves the bundle's files for the fixed names in `uiota::SERVED` (nothing else is read from the folder; a file the bundle lacks falls back to the embedded one); the embedded files stay in the binary |
|
||||
| reload | `state.ui.active_version` changes; the page compares it with the version it loaded (`View.shouldReload`) and reloads when idle |
|
||||
| confirm | the first page load from an unconfirmed bundle starts a 10 s wait; the page POSTs `/api/ui/health` after its first paint (a `window.error` before that posts the error instead); a ping confirms, an error or silence rolls back |
|
||||
| roll back | the pointer goes back to "embedded", the version lands in `<app data>/ui/bad.json`, one Activity line, one log line (`ui: <version> marked bad: <why>`) |
|
||||
| state | `state.ui` {embedded_version, active_version, source, installed_at, confirmed, published_version, busy, error, bad, builtin, note}; `settings.ui_builtin` |
|
||||
|
||||
Log lines the shipper reads back: `ui bundle <version> active (installed <unix>)`, `ui bundle <version> confirmed
|
||||
by the page`, `ui: <version> marked bad: <why>`, `ui: no ui object in the manifest; interface <version> retired`.
|
||||
|
||||
## 4. Security notes
|
||||
|
||||
- Same origin: the bundle is served by this engine on 127.0.0.1 under the per-launch token path, exactly as the
|
||||
embedded files are; the page's fetches, the dashboard's POST rule (`from_dashboard`) and the token are unchanged.
|
||||
- No remote scripts: the bundle is a copy of the tree's ui folder; index.html loads only its own files; the
|
||||
test `ui-ota.test.mjs` fails on a `<script src="http...">`. The engine never evaluates anything from the bundle;
|
||||
it serves bytes.
|
||||
- The signature is the only trust: the manifest's Ed25519 signature (the pinned release key) covers the entry, the
|
||||
entry's own signature covers version, hash and engine floor, and the hash covers the bytes. No bundle is applied on
|
||||
a hash alone, and the URL is never trusted beyond "where to fetch".
|
||||
- Fixed names: only the fifteen served names are ever read from a bundle folder (`uiota::SERVED`); a path is never
|
||||
built from a request.
|
||||
- The kill switch: removing the `ui` object, or publishing a bundle whose min_engine is above every engine.
|
||||
- A bundle cannot brick a window: no index.html, a parse error in app.js, a JS error on first paint or a page that
|
||||
never answers all roll back within 10 s of the first load, and the version is never retried on that machine.
|
||||
- The private key stays in `~/.config/igneum/ota-signing-key`; tools/ui-ota/publish.mjs calls igneum-ota-sign and
|
||||
never reads or prints the key; tools/ci/no-secrets-check.sh keeps the name out of the tree.
|
||||
|
||||
## 5. The publisher (tools/ui-ota/publish.mjs, packaging/ota/publish-manifest.sh --ui)
|
||||
|
||||
The one-line operator recipe, staged first as every cut is (the live folder changes only in the deploy step):
|
||||
|
||||
```
|
||||
IGNEUM_DLSITE=<scratch copy of the downloads folder> node tools/ui-ota/publish.mjs --version 1.0.1 --min-engine 0.3.20 --notes "one line" --dry-run
|
||||
node tools/ui-ota/publish.mjs --version 1.0.1 --min-engine 0.3.20 --notes "one line" --deploy --public
|
||||
node tools/ui-ota/publish.mjs --verify
|
||||
```
|
||||
|
||||
What it does: writes ui/VERSION from --version, packs the fifteen served files in sorted order with one fixed mtime
|
||||
(the same tree gives the same bytes; the self-test checks it), hashes, signs the entry through
|
||||
`igneum-ota-sign sign-ui`, copies the tar into the folder's ui/, writes ui.json and calls
|
||||
`publish-manifest.sh --version <the folder's app version> --ui ui.json`, which verifies the entry's signature and the
|
||||
bundle's hash in the folder before it signs the manifest. `--dry-run` stops after the pack and the entry (nothing
|
||||
signed, nothing written to any downloads folder). `--verify` reads the live manifest, checks the bundle in the
|
||||
folder's dl/<token>/ui and dl/public/ui against ui.sha256 and the entry's signature, and exits 1 on any miss: the
|
||||
read-back the shipper asked for. `--no-ui` on publish-manifest.sh withdraws the channel.
|
||||
|
||||
## 6. Tests and gates
|
||||
|
||||
| Test | Where |
|
||||
|---|---|
|
||||
| a good bundle installs, the pointer swaps atomically | `uiota::tests::a_good_bundle_installs_and_the_pointer_swaps_atomically` |
|
||||
| a bad signature, a tampered hash, a wrong size are refused before anything is unpacked | `uiota::tests::a_bad_signature_is_refused_before_anything_is_unpacked` |
|
||||
| a too-new min_engine is ignored, and every other skip | `uiota::tests::the_decision_covers_every_reason_to_skip` |
|
||||
| a broken bundle (no index.html) is refused and leaves nothing behind; a renamed bundle is refused by its VERSION | `uiota::tests::a_broken_bundle_...`, `a_renamed_bundle_...` |
|
||||
| the health wait rolls back to the embedded interface and marks the version bad; a first-paint error too; a ping confirms | `uiota::tests::the_health_wait_rolls_back_...` |
|
||||
| the ui entry parses, every bad field fails, the entry's signature breaks on any change | `manifest::tests::the_ui_entry_parses_and_every_bad_field_fails` |
|
||||
| the Settings words, the reload rule, the health ping and no remote script | `ui/ui-ota.test.mjs` (on the one gate) |
|
||||
| the pack is reproducible and complete, a good entry verifies, a tampered hash and a wrong key do not | `node tools/ui-ota/publish.mjs --self-test` (on the one gate; the sign half runs where the signer is built) |
|
||||
|
||||
## 7. Per tier
|
||||
|
||||
Every tier the same: the bundle is about 400 KB (the fonts are most of it), one download an hour at most, no
|
||||
restart, no mining pause; a machine without a window (a rig, a pool box) swaps the pointer and confirms at the next
|
||||
window open; Windows, Linux and macOS alike (the system tar on all three); a second engine (`--sweep`) never updates,
|
||||
so it never swaps an interface either.
|
||||
|
||||
## 8. Owed
|
||||
|
||||
| What | Who |
|
||||
|---|---|
|
||||
| The first real publish (1.0.1) once 0.3.20 is on every platform, with the read-back line in the release notes | the shipper |
|
||||
| A CI check that the live manifest's ui.sha256 equals the tar in dl/public (`publish.mjs --verify` is the hand form; CI has no dlsite folder) | the shipper's post-deploy step |
|
||||
| Captures of Settings > Interface in both states for the site | the app lane, after its rebase |
|
||||
|
|
@ -17,6 +17,10 @@
|
|||
# [--tuning tuning.json | --no-tuning] the fleet's per-card kernel tuning (tools/tuning.mjs writes it;
|
||||
# docs/design/miner-tuning.md); carried over from the current
|
||||
# manifest when not given, as is consensus.override
|
||||
# [--ui ui.json | --no-ui] the interface channel (tools/ui-ota/publish.mjs writes ui.json:
|
||||
# {version, sha256, size, url, min_engine, signature}, the bundle
|
||||
# already in the folder's ui/); carried over when not given;
|
||||
# --no-ui withdraws it (the kill switch; docs/plans/ui-ota.md)
|
||||
#
|
||||
# A platform you do not pass is carried over from the manifest already in the folder when that one has the same
|
||||
# version (the Windows build lands later than the Mac one: publish the Mac entry first, add the Windows entry when
|
||||
|
|
@ -59,7 +63,7 @@ if [ "${1:-}" = "--self-test-height" ]; then
|
|||
height_rule "" 1000 || { echo "self-test failed: no height was refused"; exit 1; }
|
||||
echo "self-test passed: a height at or below the live DAA is refused, a future one and none pass"; exit 0
|
||||
fi
|
||||
OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0
|
||||
OVERRIDE="" TUNING_FILE="" NO_TUNING=0 PUBLIC=0 UI_FILE="" NO_UI=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift 2 ;;
|
||||
|
|
@ -73,6 +77,8 @@ while [ $# -gt 0 ]; do
|
|||
--channel) CHANNEL="$2"; shift 2 ;;
|
||||
--tuning) TUNING_FILE="$2"; shift 2 ;;
|
||||
--no-tuning) NO_TUNING=1; shift ;;
|
||||
--ui) UI_FILE="$2"; shift 2 ;;
|
||||
--no-ui) NO_UI=1; shift ;;
|
||||
--base-url) BASE="$2"; shift 2 ;;
|
||||
--dest) DEST="$2"; shift 2 ;;
|
||||
--deploy) DEPLOY=1; shift ;;
|
||||
|
|
@ -198,11 +204,31 @@ elif [ "$NO_TUNING" = 0 ] && [ -f "$OLD" ]; then
|
|||
[ -n "$TUNING" ] && echo "tuning: carried over from the current manifest ($(python3 -c 'import json,sys; print(len(json.loads(sys.argv[1])["cards"]))' "$TUNING") card model(s))"
|
||||
fi
|
||||
|
||||
# ui: the interface channel, given here (ui.json from tools/ui-ota/publish.mjs, its own signature checked with the
|
||||
# signer and its bundle present in the folder), else carried over; --no-ui withdraws it
|
||||
UI=""
|
||||
if [ -n "$UI_FILE" ]; then
|
||||
[ -f "$UI_FILE" ] || { echo "missing: $UI_FILE" >&2; exit 1; }
|
||||
UI="$(python3 -c 'import json,sys; u=json.load(open(sys.argv[1])); need=("version","sha256","size","url","min_engine","signature"); assert all(k in u for k in need), "ui.json needs "+", ".join(need); print(json.dumps({k:u[k] for k in need}, sort_keys=True, separators=(",",":")))' "$UI_FILE")"
|
||||
read -r UI_VERSION UI_SHA UI_MIN UI_SIG UI_URL < <(python3 -c 'import json,sys; u=json.loads(sys.argv[1]); print(u["version"], u["sha256"], u["min_engine"], u["signature"], u["url"])' "$UI")
|
||||
"$SIGNER" verify-ui "$PUB" "$UI_VERSION" "$UI_SHA" "$UI_MIN" "$UI_SIG" >/dev/null || { echo "ui.json: the interface signature does not verify against $PUB" >&2; exit 1; }
|
||||
UI_NAME="$(basename "$UI_URL")"
|
||||
[ -f "$DEST/ui/$UI_NAME" ] || { echo "ui: the bundle $DEST/ui/$UI_NAME is not in the folder (tools/ui-ota/publish.mjs copies it)" >&2; exit 1; }
|
||||
read -r got_sum _ < <("$SIGNER" sha256 "$DEST/ui/$UI_NAME")
|
||||
[ "$got_sum" = "$UI_SHA" ] || { echo "ui: $DEST/ui/$UI_NAME has sha256 $got_sum, ui.json says $UI_SHA" >&2; exit 1; }
|
||||
echo "ui: interface $UI_VERSION (min engine $UI_MIN) at $UI_NAME, signature OK"
|
||||
elif [ "$NO_UI" = 0 ] && [ -f "$OLD" ]; then
|
||||
UI="$(python3 -c 'import json,sys; u=json.load(open(sys.argv[1])).get("ui"); print(json.dumps(u, sort_keys=True, separators=(",",":")) if isinstance(u, dict) and u.get("version") else "")' "$OLD" 2>/dev/null || true)"
|
||||
[ -n "$UI" ] && echo "ui: carried over from the current manifest (interface $(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["version"])' "$UI"))"
|
||||
elif [ "$NO_UI" = 1 ]; then
|
||||
echo "ui: withdrawn (--no-ui): every app falls back to its built-in interface at its next check"
|
||||
fi
|
||||
|
||||
# canonical JSON: sorted keys, no whitespace; the signature is over these exact bytes
|
||||
NEW="$DEST/igneum-app-latest.json.new"
|
||||
python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" <<'PY'
|
||||
python3 - "$NEW" "$VERSION" "$CHANNEL" "$NOTES" "$MIN_SUPPORTED" "$ACTIVATION" "$DEADLINE" "$MAC_ENTRY" "$WIN_ENTRY" "${OVERRIDE:-}" "${TUNING:-}" "${UI:-}" <<'PY'
|
||||
import json, sys, datetime
|
||||
out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning = sys.argv[1:12]
|
||||
out, version, channel, notes, min_supported, activation, deadline, mac, win, override, tuning, ui = sys.argv[1:13]
|
||||
override = json.loads(override) if override else None
|
||||
if override is not None and (not isinstance(override, dict) or not override): raise SystemExit("--override must be a non-empty JSON object")
|
||||
def entry(s):
|
||||
|
|
@ -220,6 +246,8 @@ m = {
|
|||
}
|
||||
if tuning:
|
||||
m["tuning"] = json.loads(tuning)
|
||||
if ui:
|
||||
m["ui"] = json.loads(ui)
|
||||
open(out, "w").write(json.dumps(m, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
|
||||
PY
|
||||
"$SIGNER" sign "$KEY" "$NEW" > "$NEW.sig"
|
||||
|
|
|
|||
|
|
@ -78,8 +78,9 @@ tree_checks() {
|
|||
run "faucet unit tests" node --test site/api/faucet.test.mjs
|
||||
run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs
|
||||
run "ship tool self-test" node tools/ship-app.mjs --self-test
|
||||
run "interface bundle publisher self-test (pack, and sign when the signer is built)" node tools/ui-ota/publish.mjs --self-test
|
||||
run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs
|
||||
run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs
|
||||
run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs app/igneum-app/ui/ui-ota.test.mjs
|
||||
run "no secret file names and no 64-hex secrets in the tree" bash -c 'bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh'
|
||||
}
|
||||
|
||||
|
|
|
|||
174
tools/ui-ota/publish.mjs
Normal file
174
tools/ui-ota/publish.mjs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
#!/usr/bin/env node
|
||||
// The interface over the air, publisher side (docs/plans/ui-ota.md): packs app/igneum-app/ui into one tar.gz, hashes
|
||||
// it, signs the entry with the release key through igneum-ota-sign (the key stays in ~/.config/igneum, never here),
|
||||
// copies the bundle into the downloads folder's ui/ and hands the channel to packaging/ota/publish-manifest.sh --ui,
|
||||
// the one writer of the signed manifest. Nothing here deploys: --deploy is passed through to publish-manifest.sh,
|
||||
// which deploys from the live folder; a cut is staged in a scratch copy (IGNEUM_DLSITE) as every other publish.
|
||||
//
|
||||
// node tools/ui-ota/publish.mjs --version 1.0.1 --min-engine 0.3.19 [--notes "one line"] [--dry-run] [--deploy] [--public]
|
||||
// node tools/ui-ota/publish.mjs --verify [--url https://dl.igneum.network/dl/<token>/igneum-app-latest.json]
|
||||
// the live manifest's ui entry against the bundle in the folder (the read-back)
|
||||
// node tools/ui-ota/publish.mjs --self-test pack, hash, sign and verify with a throwaway key in a scratch folder
|
||||
//
|
||||
// --dry-run packs, hashes and signs into a scratch folder and prints the entry; nothing is written to any downloads
|
||||
// folder and publish-manifest.sh is not called. The bundle's VERSION file is written from --version before packing.
|
||||
// The version is three-part (the publish rule); min-engine is the lowest app version that may serve it.
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '../..');
|
||||
const uiDir = path.join(root, 'app/igneum-app/ui');
|
||||
const signer = path.join(root, 'app/igneum-app/target/release/igneum-ota-sign');
|
||||
const keyFile = path.join(os.homedir(), '.config/igneum/ota-signing-key');
|
||||
const pubFile = path.join(os.homedir(), '.config/igneum/ota-signing-key.pub');
|
||||
/// what goes into the bundle: the served files (src/uiota.rs SERVED), never the tests
|
||||
export const FILES = ['index.html', 'app.css', 'app.js', 'mark.svg', 'live-dag.js', 'proof-core.js', 'VERSION', 'fonts/IBMPlexMono-400.woff2', 'fonts/IBMPlexMono-500.woff2', 'fonts/IBMPlexSans-400.woff2', 'fonts/IBMPlexSans-500.woff2', 'fonts/IBMPlexSans-600.woff2', 'fonts/Unbounded-500.woff2', 'fonts/Unbounded-700.woff2', 'fonts/Unbounded-900.woff2'];
|
||||
|
||||
function arg(name, d) { const i = process.argv.indexOf(name); return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : d; }
|
||||
const flag = (name) => process.argv.includes(name);
|
||||
// the mtime every packed file carries (1 January 2026 UTC), so a pack is a function of the tree alone
|
||||
const STAMP = new Date(Date.UTC(2026, 0, 1));
|
||||
const threePart = (v) => /^\d+\.\d+\.\d+$/.test(v);
|
||||
|
||||
export function sha256(file) { return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); }
|
||||
|
||||
// Copies the served files into a clean folder with the VERSION stamped, packs them in sorted order (one tar, the
|
||||
// system's; gzip -n keeps the archive free of a timestamp), returns {tar, sha256, size}.
|
||||
export function pack(srcDir, version, outDir, files = FILES) {
|
||||
const stage = path.join(outDir, 'stage');
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
for (const f of files) {
|
||||
const from = path.join(srcDir, f), to = path.join(stage, f);
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
if (f === 'VERSION') fs.writeFileSync(to, version + '\n'); else fs.copyFileSync(from, to);
|
||||
fs.utimesSync(to, STAMP, STAMP); // one mtime for every file: the same tree packs to the same bytes
|
||||
}
|
||||
fs.utimesSync(stage, STAMP, STAMP);
|
||||
fs.utimesSync(path.join(stage, 'fonts'), STAMP, STAMP);
|
||||
const tar = path.join(outDir, `igneum-ui-${version}.tar`);
|
||||
const sorted = [...files].sort();
|
||||
execFileSync('tar', ['-cf', tar, '-C', stage, ...sorted]);
|
||||
fs.rmSync(tar + '.gz', { force: true });
|
||||
execFileSync('gzip', ['-n', '-9', tar]);
|
||||
const gz = tar + '.gz';
|
||||
return { tar: gz, sha256: sha256(gz), size: fs.statSync(gz).size };
|
||||
}
|
||||
|
||||
function run(cmd, args) {
|
||||
// the Mac's cargo lives in ~/.cargo/bin (the one publish-manifest.sh uses); node's PATH may hold an older one
|
||||
const env = { ...process.env, PATH: path.join(os.homedir(), '.cargo/bin') + path.delimiter + (process.env.PATH || '') };
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf8', env });
|
||||
if (r.status !== 0) throw new Error(`${path.basename(cmd)} ${args.filter((a) => !a.includes('ota-signing-key')).join(' ')}: ${(r.stderr || r.stdout || '').trim()}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
export function signEntry(signerBin, key, version, sha, minEngine) { return run(signerBin, ['sign-ui', key, version, sha, minEngine]); }
|
||||
export function verifyEntry(signerBin, pub, e) { return run(signerBin, ['verify-ui', pub, e.version, e.sha256, e.min_engine, e.signature]) === 'verifies'; }
|
||||
|
||||
function ensureSigner() {
|
||||
if (fs.existsSync(signer)) return signer;
|
||||
console.log('building igneum-ota-sign');
|
||||
run('cargo', ['build', '--release', '-j', '4', '--bin', 'igneum-ota-sign', '--quiet', '--manifest-path', path.join(root, 'app/igneum-app/Cargo.toml')]);
|
||||
return signer;
|
||||
}
|
||||
|
||||
function selfTest() {
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-ota-selftest-'));
|
||||
const fails = [];
|
||||
try {
|
||||
const a = pack(uiDir, '9.9.9', path.join(scratch, 'a'));
|
||||
const b = pack(uiDir, '9.9.9', path.join(scratch, 'b'));
|
||||
if (a.sha256 !== b.sha256) fails.push('two packs of the same tree differ (the bundle is not reproducible)');
|
||||
if (a.size < 100_000) fails.push('the bundle is too small to hold the fonts: ' + a.size);
|
||||
const list = execFileSync('tar', ['-tzf', a.tar], { encoding: 'utf8' }).trim().split('\n').sort();
|
||||
for (const f of FILES) if (!list.includes(f)) fails.push('the bundle lacks ' + f);
|
||||
if (list.some((f) => f.endsWith('.test.mjs'))) fails.push('a test file went into the bundle');
|
||||
const v = fs.readFileSync(path.join(scratch, 'a/stage/VERSION'), 'utf8').trim();
|
||||
if (v !== '9.9.9') fails.push('the VERSION file was not stamped: ' + v);
|
||||
// the signing half needs the built signer (the Rust tests cover verify_ui_entry; CI has no signer binary)
|
||||
if (!fs.existsSync(signer)) { fs.rmSync(scratch, { recursive: true, force: true }); if (fails.length) { console.error('self-test failed:\n ' + fails.join('\n ')); process.exit(1); } console.log('self-test passed (pack half): the pack is reproducible and complete; no signer binary here, the sign half is skipped'); return; }
|
||||
const s = signer;
|
||||
const key = path.join(scratch, 'key'), pub = path.join(scratch, 'key.pub');
|
||||
run(s, ['keygen', key, pub]);
|
||||
const sig = signEntry(s, key, '9.9.9', a.sha256, '0.3.19');
|
||||
const e = { version: '9.9.9', sha256: a.sha256, size: a.size, url: 'https://dl.igneum.network/dl/x/ui/igneum-ui-9.9.9.tar.gz', min_engine: '0.3.19', signature: sig };
|
||||
if (!verifyEntry(s, pub, e)) fails.push('a good entry did not verify');
|
||||
let bad = false; try { bad = verifyEntry(s, pub, { ...e, sha256: '00'.repeat(32) }); } catch (x) { bad = false; }
|
||||
if (bad) fails.push('a tampered hash verified');
|
||||
let other = false; const key2 = path.join(scratch, 'key2'), pub2 = path.join(scratch, 'key2.pub'); run(s, ['keygen', key2, pub2]);
|
||||
try { other = verifyEntry(s, pub2, e); } catch (x) { other = false; }
|
||||
if (other) fails.push('an entry verified against the wrong key');
|
||||
} catch (x) { fails.push(String(x.message || x)); }
|
||||
fs.rmSync(scratch, { recursive: true, force: true });
|
||||
if (fails.length) { console.error('self-test failed:\n ' + fails.join('\n ')); process.exit(1); }
|
||||
console.log('self-test passed: the pack is reproducible and complete, a good entry verifies, a tampered hash and a wrong key do not');
|
||||
}
|
||||
|
||||
function verifyLive() {
|
||||
const token = fs.readFileSync(path.join(os.homedir(), '.config/igneum/dl-token'), 'utf8').trim();
|
||||
const url = arg('--url', `https://dl.igneum.network/dl/${token}/igneum-app-latest.json`);
|
||||
const dlsite = process.env.IGNEUM_DLSITE || fs.readFileSync(path.join(os.homedir(), '.config/igneum/dlsite-dir'), 'utf8').trim();
|
||||
const text = run('curl', ['-fsSL', '--max-time', '20', url]);
|
||||
const m = JSON.parse(text);
|
||||
const mask = (t) => String(t).split(token).join('<token>');
|
||||
if (!m.ui) { console.log('live manifest: no ui object (the built-in interface serves everywhere)'); return; }
|
||||
const name = path.basename(m.ui.url);
|
||||
const local = path.join(dlsite, 'dl', token, 'ui', name), pub = path.join(dlsite, 'dl', 'public', 'ui', name);
|
||||
const lines = [`live manifest: interface ${m.ui.version}, min engine ${m.ui.min_engine}, ${mask(m.ui.url)}`];
|
||||
let ok = true;
|
||||
for (const f of [local, pub]) {
|
||||
if (!fs.existsSync(f)) { lines.push(` ${mask(f)}: missing`); if (f === local) ok = false; continue; }
|
||||
const sum = sha256(f);
|
||||
lines.push(` ${mask(f)}: sha256 ${sum === m.ui.sha256 ? 'matches' : 'DIFFERS (' + sum + ')'}`);
|
||||
if (sum !== m.ui.sha256) ok = false;
|
||||
}
|
||||
const s = ensureSigner();
|
||||
const sigOk = verifyEntry(s, pubFile, m.ui);
|
||||
lines.push(` signature: ${sigOk ? 'verifies' : 'DOES NOT VERIFY'}`);
|
||||
console.log(lines.join('\n'));
|
||||
if (!ok || !sigOk) process.exit(1);
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (flag('--self-test')) return selfTest();
|
||||
if (flag('--verify')) return verifyLive();
|
||||
const version = arg('--version'), minEngine = arg('--min-engine'), notes = arg('--notes', '');
|
||||
if (!version || !threePart(version)) { console.error('--version major.minor.patch is required (the interface has its own line, 1.0.0 upward)'); process.exit(2); }
|
||||
if (!minEngine || !threePart(minEngine)) { console.error('--min-engine major.minor.patch is required (the lowest app version that may serve this bundle)'); process.exit(2); }
|
||||
const dry = flag('--dry-run');
|
||||
const s = ensureSigner();
|
||||
const token = fs.readFileSync(path.join(os.homedir(), '.config/igneum/dl-token'), 'utf8').trim();
|
||||
const dlsite = process.env.IGNEUM_DLSITE || fs.readFileSync(path.join(os.homedir(), '.config/igneum/dlsite-dir'), 'utf8').trim();
|
||||
const dest = path.join(dlsite, 'dl', token);
|
||||
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-ota-'));
|
||||
const p = pack(uiDir, version, scratch);
|
||||
const name = path.basename(p.tar);
|
||||
const url = `https://dl.igneum.network/dl/${token}/ui/${name}`;
|
||||
const entry = { version, sha256: p.sha256, size: p.size, url, min_engine: minEngine, signature: dry ? '(signed at publish)' : signEntry(s, keyFile, version, p.sha256, minEngine) };
|
||||
const shown = { ...entry, url: url.split(token).join('<token>') };
|
||||
console.log('interface bundle: ' + JSON.stringify(shown, null, 1));
|
||||
if (dry) { console.log(`dry run: nothing written to ${dest.split(token).join('<token>')}; the bundle is at ${p.tar}`); return; }
|
||||
if (!verifyEntry(s, pubFile, entry)) { console.error('the entry does not verify against ' + pubFile); process.exit(1); }
|
||||
fs.mkdirSync(path.join(dest, 'ui'), { recursive: true });
|
||||
fs.copyFileSync(p.tar, path.join(dest, 'ui', name));
|
||||
const uiJson = path.join(scratch, 'ui.json');
|
||||
fs.writeFileSync(uiJson, JSON.stringify(entry));
|
||||
const pm = path.join(root, 'packaging/ota/publish-manifest.sh');
|
||||
const args = ['--version', arg('--app-version', readFolderVersion(dest)), '--ui', uiJson, '--notes', notes || `interface ${version} over the air`];
|
||||
if (flag('--deploy')) args.push('--deploy');
|
||||
if (flag('--public')) args.push('--public');
|
||||
console.log(`publish-manifest.sh ${args.join(' ').split(token).join('<token>')}`);
|
||||
const r = spawnSync('bash', [pm, ...args], { stdio: 'inherit', env: { ...process.env, IGNEUM_DLSITE: dlsite } });
|
||||
process.exit(r.status || 0);
|
||||
}
|
||||
|
||||
function readFolderVersion(dest) {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(dest, 'igneum-app-latest.json'), 'utf8')).version; } catch (e) { console.error('no manifest in the folder: pass --app-version'); process.exit(2); }
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) main();
|
||||
Loading…
Reference in a new issue