Files
MeshTender/internal/web/static/serial-setup.js
T

364 lines
14 KiB
JavaScript

// Drives the "set up a brand-new repeater over USB" step of the add-repeater
// wizard. Unlike console.js (which bridges a KISS modem's raw bytes to the server
// over a WebSocket), this talks the repeater's own plain-text serial CLI
// DIRECTLY: it writes "command\n" and reads the text the device echoes back.
//
// The repeater's identity is generated here, in the browser, with WebCrypto
// Ed25519. The private key NEVER leaves the page — only the public key is posted
// to the server (to create the repeater record). The server generates the
// ordered command list with a placeholder where the `set prv.key` line goes; we
// splice the real key in locally before running.
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
// on wires an event handler, skipping silently if the element is absent, so a
// trimmed-down template can omit controls without breaking setup.
var on = function (id, event, fn) {
var el = $(id);
if (el) el.addEventListener(event, fn);
};
var hex = function (bytes) {
return Array.prototype.map.call(bytes, function (b) {
return b.toString(16).padStart(2, "0");
}).join("");
};
var b64urlToBytes = function (s) {
s = s.replace(/-/g, "+").replace(/_/g, "/");
while (s.length % 4) s += "=";
var bin = atob(s);
var out = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
};
var hexToBytes = function (s) {
var out = new Uint8Array(s.length / 2);
for (var i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
return out;
};
// --- feature detection -------------------------------------------------
var hasSerial = "serial" in navigator;
var hasEd25519 = !!(window.crypto && window.crypto.subtle);
if (!hasSerial || !hasEd25519) {
var u = $("unsupported");
if (u) u.hidden = false;
}
// --- identity (WebCrypto Ed25519) --------------------------------------
// A MeshCore private key is the 64-byte Ed25519 key (32-byte seed ‖ 32-byte
// public key), so `set prv.key` wants 128 hex chars. We build that from the
// JWK's `d` (seed) and `x` (public key). state holds the resolved keypair.
var state = { privHex: null, pubHex: null, radio: null, identityPlaceholder: null };
// generateKeyPair returns {privHex(128), pubHex(64)} for a fresh random key.
async function generateKeyPair() {
var kp = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
var jwk = await crypto.subtle.exportKey("jwk", kp.privateKey);
var seed = b64urlToBytes(jwk.d); // 32 bytes
var pub = b64urlToBytes(jwk.x); // 32 bytes
return { privHex: hex(seed) + hex(pub), pubHex: hex(pub) };
}
// pubFromSeed derives the 32-byte public key from a 32-byte seed by importing
// it as PKCS#8 (fixed DER prefix + seed) and re-exporting the JWK.
async function pubFromSeed(seed) {
var prefix = hexToBytes("302e020100300506032b657004220420");
var pkcs8 = new Uint8Array(prefix.length + seed.length);
pkcs8.set(prefix); pkcs8.set(seed, prefix.length);
var key = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
var jwk = await crypto.subtle.exportKey("jwk", key);
return b64urlToBytes(jwk.x);
}
function setPubKey(text) { $("pubkey").textContent = text || "—"; }
function setKeyState(privHex, pubHex) {
state.privHex = privHex; state.pubHex = pubHex;
setPubKey(pubHex);
}
function clearKey() { state.privHex = null; state.pubHex = null; setPubKey(null); }
// Parse a pasted key: 128 hex = full 64-byte key (last 32 bytes are the pubkey);
// 64 hex = a 32-byte seed (derive the pubkey).
async function applyPastedKey() {
var raw = ($("privkey").value || "").trim().toLowerCase().replace(/\s+/g, "");
if (!/^[0-9a-f]+$/.test(raw)) { clearKey(); return; }
if (raw.length === 128) {
setKeyState(raw, raw.slice(64));
} else if (raw.length === 64) {
var pub = await pubFromSeed(hexToBytes(raw));
setKeyState(raw + hex(pub), hex(pub));
} else {
clearKey();
}
}
// --- key mode toggles --------------------------------------------------
var grinding = false;
function selectedKeyMode() {
var el = document.querySelector('input[name="keymode"]:checked');
return el ? el.value : "random";
}
function refreshKeyMode() {
var mode = selectedKeyMode();
$("key-random").hidden = mode !== "random";
$("key-prefix").hidden = mode !== "prefix";
$("key-paste").hidden = mode !== "paste";
grinding = false; $("grind-stop").hidden = true;
$("grind-status").textContent = "";
if (mode === "random") {
generateKeyPair().then(function (k) { setKeyState(k.privHex, k.pubHex); });
} else if (mode === "paste") {
applyPastedKey();
} else {
clearKey();
}
}
async function grind() {
if (grinding) return;
var prefix = ($("prefix").value || "").trim().toLowerCase();
if (!/^[0-9a-f]*$/.test(prefix)) {
$("grind-status").textContent = "Prefix must be hex (0-9, a-f).";
return;
}
if (prefix === "") { refreshKeyMode(); return; }
grinding = true;
$("grind-stop").hidden = false;
clearKey();
var tries = 0;
while (grinding) {
var k = await generateKeyPair();
tries++;
if (k.pubHex.startsWith(prefix)) {
setKeyState(k.privHex, k.pubHex);
$("grind-status").textContent = "Found after " + tries + " tries.";
break;
}
if (tries % 200 === 0) $("grind-status").textContent = "Searching… " + tries + " tries";
}
grinding = false;
$("grind-stop").hidden = true;
}
// --- org config / preset toggle ----------------------------------------
function populateOrgs() {
var sel = $("org");
(window.SETUP_ORGS || []).forEach(function (o) {
var opt = document.createElement("option");
opt.value = String(o.ID); opt.textContent = o.Name;
sel.appendChild(opt);
});
}
function currentOrg() {
var id = parseInt($("org").value, 10) || 0;
if (!id) return null;
return (window.SETUP_ORGS || []).find(function (o) { return o.ID === id; }) || null;
}
function refreshOrg() {
var org = currentOrg();
var profiles = (org && org.Profiles) || [];
$("preset-wrap").hidden = !!org;
$("profile-wrap").hidden = !(org && profiles.length);
var psel = $("profile");
psel.innerHTML = "";
profiles.forEach(function (name) {
var opt = document.createElement("option");
opt.value = name; opt.textContent = name;
psel.appendChild(opt);
});
}
// --- location ----------------------------------------------------------
var loc = { lat: null, lon: null };
function initMap() {
if (!window.regionMapView) return;
regionMapView("map", {
onPick: function (lat, lon) {
loc.lat = lat; loc.lon = lon;
$("loc-status").textContent = "Location: " + lat.toFixed(5) + ", " + lon.toFixed(5);
},
});
}
// --- generate command list ---------------------------------------------
function readRadio() {
return {
freqMhz: parseFloat($("radio_freq_mhz").value),
bwKhz: parseFloat($("radio_bw_khz").value),
sf: parseInt($("radio_sf").value, 10),
cr: parseInt($("radio_cr").value, 10),
};
}
function maskKeyLine(line) {
// Show only the first 8 hex chars of the private key in the preview.
return line.replace(/(set prv\.key\s+)([0-9a-f]{8})[0-9a-f]+/i, "$1$2… (hidden)");
}
var commands = null; // resolved command list with the real key spliced in
async function generate() {
if (!state.privHex || !state.pubHex) {
alert("Generate or enter a key first.");
return;
}
var name = ($("name").value || "").trim();
if (!name) { alert("Name is required."); return; }
var org = currentOrg();
var body = {
name: name,
orgId: org ? org.ID : 0,
profile: org && !$("profile-wrap").hidden ? $("profile").value : "",
lat: loc.lat, lon: loc.lon,
};
if (!org) {
var r = readRadio();
body.freqMhz = r.freqMhz; body.bwKhz = r.bwKhz; body.sf = r.sf; body.cr = r.cr;
}
var resp;
try {
resp = await fetch("/repeaters/setup/commands", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} catch (e) { alert("Could not reach the server: " + e.message); return; }
if (!resp.ok) { alert("Could not build commands: " + (await resp.text())); return; }
var data = await resp.json();
state.radio = data.radio;
var idLine = "set prv.key " + state.privHex;
commands = data.commands.map(function (c) {
return c === data.identityPlaceholder ? idLine : c;
});
$("cmdlist").textContent = commands.map(maskKeyLine).join("\n");
$("review-card").hidden = false;
$("review-card").scrollIntoView({ behavior: "smooth" });
}
// --- run over USB serial ------------------------------------------------
function addLog(stateName, message) {
var li = document.createElement("li");
li.className = "ev ev-" + stateName;
li.textContent = message;
$("log").appendChild(li);
$("log").scrollTop = $("log").scrollHeight;
}
// readForDevice reads decoded text from the port until it goes quiet for
// `idleMs` or `maxMs` elapses — repeater CLI replies are short and terminated
// by a prompt/newline, but we don't depend on an exact terminator.
async function readForDevice(reader, decoder, idleMs, maxMs) {
var out = "";
var deadline = performance.now() + maxMs;
while (performance.now() < deadline) {
var timer;
var idle = new Promise(function (res) { timer = setTimeout(function () { res("__idle__"); }, idleMs); });
var chunk;
try {
chunk = await Promise.race([reader.read(), idle]);
} finally { clearTimeout(timer); }
if (chunk === "__idle__") break;
if (chunk.done) break;
if (chunk.value) out += decoder.decode(chunk.value, { stream: true });
}
return out.trim();
}
async function run() {
if (!commands) return;
if (commands.some(function (c) { return /<[^>]+>/.test(c); })) {
addLog("error", "Refusing to run: an unfilled placeholder remains in the command list.");
return;
}
$("run").disabled = true;
var port, writer, reader;
var encoder = new TextEncoder();
var decoder = new TextDecoder();
try {
addLog("info", "Requesting serial port…");
port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
writer = port.writable.getWriter();
reader = port.readable.getReader();
addLog("info", "Connected (115200 8N1). Running setup…");
for (var i = 0; i < commands.length; i++) {
var cmd = commands[i];
var shown = maskKeyLine(cmd);
var isReboot = /^reboot\b/.test(cmd);
addLog("sent", "→ " + shown);
await writer.write(encoder.encode(cmd + "\n"));
if (isReboot) {
addLog("info", "Reboot sent — the device will restart to apply its new identity and radio.");
break;
}
var reply = await readForDevice(reader, decoder, 400, 3000);
if (reply) addLog("reply", reply);
else addLog("noreply", "(no reply)");
}
addLog("info", "Setup commands sent. Saving repeater…");
await complete();
} catch (e) {
addLog("error", e.message);
$("run").disabled = false;
} finally {
try { if (reader) { await reader.cancel(); reader.releaseLock(); } } catch (_) {}
try { if (writer) writer.releaseLock(); } catch (_) {}
try { if (port) await port.close(); } catch (_) {}
}
}
async function complete() {
var form = new URLSearchParams();
form.set("name", ($("name").value || "").trim());
form.set("public_key", state.pubHex);
form.set("radio_freq_mhz", String(state.radio.freqMhz));
form.set("radio_bw_khz", String(state.radio.bwKhz));
form.set("radio_sf", String(state.radio.sf));
form.set("radio_cr", String(state.radio.cr));
if (loc.lat != null && loc.lon != null) {
form.set("lat", loc.lat.toFixed(6));
form.set("lon", loc.lon.toFixed(6));
}
// Optional visibility toggles — guarded so a trimmed-down template can omit them.
var showOrg = $("show_on_public_org");
if (showOrg && showOrg.checked) form.set("show_on_public_org", "1");
var exposePage = $("expose_public_page");
if (exposePage && exposePage.checked) form.set("expose_public_page", "1");
var resp = await fetch("/repeaters/setup/complete", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(),
});
if (!resp.ok) {
addLog("error", "Could not save repeater: " + (await resp.text()));
return;
}
var data = await resp.json();
addLog("info", "Saved. Redirecting…");
window.location = data.redirect;
}
// --- wire up -----------------------------------------------------------
if (!hasSerial || !hasEd25519) return;
populateOrgs();
refreshOrg();
refreshKeyMode();
initMap();
document.querySelectorAll('input[name="keymode"]').forEach(function (el) {
el.addEventListener("change", refreshKeyMode);
});
on("regen", "click", function () {
generateKeyPair().then(function (k) { setKeyState(k.privHex, k.pubHex); });
});
on("privkey", "input", function () {
if (selectedKeyMode() === "paste") applyPastedKey();
});
on("grind", "click", grind);
on("grind-stop", "click", function () { grinding = false; });
on("org", "change", refreshOrg);
on("generate", "click", generate);
on("run", "click", run);
})();