v0.20.0:0 - per-spark ssh-key copy + wireguard status badge
This commit is contained in:
@@ -26,6 +26,9 @@ echo GPU=$(nvidia-smi --query-gpu=name,utilization.gpu,temperature.gpu,power.dra
|
||||
echo GPU_MEM_USED_MIB=$(nvidia-smi --query-compute-apps=used_gpu_memory --format=csv,noheader,nounits 2>/dev/null | awk '{s+=$1} END {print s+0}')
|
||||
DEFIF=$(ip route show default 2>/dev/null | awk '{print $5; exit}')
|
||||
echo MAC=$(cat /sys/class/net/$DEFIF/address 2>/dev/null)
|
||||
WGIF=$(ip -o link show type wireguard 2>/dev/null | awk -F': ' 'NR==1 {print $2}')
|
||||
echo WG_IFACE=$WGIF
|
||||
echo WG_ADDR=$(ip -o -4 addr show "$WGIF" 2>/dev/null | awk 'NR==1 {print $4}')
|
||||
""".strip()
|
||||
|
||||
|
||||
@@ -84,6 +87,11 @@ def _parse(out: str) -> dict:
|
||||
# MAC address on the default-route interface (for Wake-on-LAN)
|
||||
if info.get("mac"):
|
||||
parsed["mac"] = info["mac"].lower()
|
||||
# WireGuard tunnel membership: name + address of the first wg interface, if
|
||||
# any. Read-only and unprivileged (`ip` needs no root), so it never depends
|
||||
# on sudo and never breaks the probe — absence just yields no badge.
|
||||
parsed["wg_iface"] = info.get("wg_iface") or None
|
||||
parsed["wg_addr"] = info.get("wg_addr") or None
|
||||
return parsed
|
||||
|
||||
|
||||
|
||||
@@ -401,6 +401,53 @@ async def wake_spark(name: str) -> dict:
|
||||
return {"ok": True, "spark": name, "mac": mac, "delivered_via": delivered_via}
|
||||
|
||||
|
||||
@app.post("/api/spark/{name}/ssh-key")
|
||||
async def spark_ssh_key(name: str) -> dict:
|
||||
"""Ensure the named Spark has an ed25519 keypair and return its PUBLIC key.
|
||||
|
||||
This is the Spark's *outbound* identity — the key it uses to log in to other
|
||||
machines (e.g. the operator's Mac). It is the opposite direction from, and
|
||||
distinct from, the package's own key shown by the StartOS "Show Public Key"
|
||||
action (which grants this dashboard SSH access to the Sparks).
|
||||
|
||||
Non-destructive: generates the key only if absent, never overwrites an
|
||||
existing one (which may already be an identity the Spark uses elsewhere).
|
||||
Public keys are not secret, so returning it is safe. No request-supplied
|
||||
value reaches the command — `name` is constrained to a fixed set and
|
||||
host/user come from operator config — so there is nothing to shell-quote.
|
||||
"""
|
||||
if name not in ("spark1", "spark2"):
|
||||
raise HTTPException(404, f"unknown spark: {name}")
|
||||
host = settings.spark1_host if name == "spark1" else settings.spark2_host
|
||||
user = settings.spark1_user if name == "spark1" else settings.spark2_user
|
||||
if not host or not user:
|
||||
raise HTTPException(400, f"{name} is not configured")
|
||||
# Empty passphrase so the key is usable unattended; comment carries the
|
||||
# remote hostname so it's identifiable in an authorized_keys file later.
|
||||
cmd = (
|
||||
"set -e; "
|
||||
"mkdir -p ~/.ssh && chmod 700 ~/.ssh; "
|
||||
"if [ ! -f ~/.ssh/id_ed25519 ]; then "
|
||||
'ssh-keygen -t ed25519 -N "" -C "spark-control@$(hostname)" -f ~/.ssh/id_ed25519 >/dev/null 2>&1; '
|
||||
"echo CREATED=1; else echo CREATED=0; fi; "
|
||||
"[ -f ~/.ssh/id_ed25519.pub ] || ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub; "
|
||||
"echo PUBKEY=$(cat ~/.ssh/id_ed25519.pub)"
|
||||
)
|
||||
rc, out, err = await ssh_run(host, user, cmd, settings, timeout=15)
|
||||
if rc != 0:
|
||||
raise HTTPException(502, f"couldn't read/create the SSH key on {name}: {err.strip() or out.strip() or f'rc={rc}'}")
|
||||
created = False
|
||||
pubkey = ""
|
||||
for line in out.splitlines():
|
||||
if line.startswith("CREATED="):
|
||||
created = line.strip() == "CREATED=1"
|
||||
elif line.startswith("PUBKEY="):
|
||||
pubkey = line[len("PUBKEY="):].strip()
|
||||
if not pubkey:
|
||||
raise HTTPException(502, f"no public key returned from {name}")
|
||||
return {"ok": True, "spark": name, "host": host, "user": user, "pubkey": pubkey, "created": created}
|
||||
|
||||
|
||||
@app.get("/api/services")
|
||||
async def get_services() -> dict:
|
||||
"""Lifecycle state of always-on support services (Parakeet, Kokoro, …).
|
||||
|
||||
+44
-4
@@ -305,6 +305,32 @@ async function wakeSpark(name) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate-if-missing + copy this Spark's OUTBOUND ssh public key (the key the
|
||||
// Spark uses to log in to other machines, e.g. the Mac). Distinct from the
|
||||
// package's own key in the StartOS "Show Public Key" action.
|
||||
async function copySparkSshKey(name, btn) {
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const r = await fetchJSON(`/api/spark/${name}/ssh-key`, { method: 'POST' });
|
||||
// Best-effort clipboard copy; on plain-HTTP this no-ops, but the dialog
|
||||
// below always shows the key for manual selection.
|
||||
await copyText(r.pubkey, btn);
|
||||
const label = r.host ? `${name} (${r.host})` : name;
|
||||
el('#sshkey-title').textContent = `${name} — SSH public key`;
|
||||
el('#sshkey-intro').textContent = r.created
|
||||
? `Generated a new SSH key on ${label} and copied it to your clipboard. This is the key ${name} uses to log in to OTHER machines.`
|
||||
: `${label} already had an SSH key; copied its public key to your clipboard. This is the key ${name} uses to log in to OTHER machines.`;
|
||||
el('#sshkey-value').textContent = r.pubkey;
|
||||
el('#sshkey-install').textContent =
|
||||
`mkdir -p ~/.ssh && echo '${r.pubkey}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
|
||||
el('#sshkey-dialog').showModal();
|
||||
} catch (e) {
|
||||
alert(`Couldn't get the SSH key for ${name}: ${e.message}`);
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHardware() {
|
||||
const panel = el('#hardware-panel');
|
||||
const grid = el('#hardware-grid');
|
||||
@@ -358,11 +384,21 @@ function renderHardware() {
|
||||
if (s.gpu_temp_c != null) gpuExtras.push(`${s.gpu_temp_c}°C`);
|
||||
if (s.gpu_power_w != null) gpuExtras.push(`${s.gpu_power_w.toFixed(0)}W`);
|
||||
const gpuExtrasStr = gpuExtras.length ? ` · ${gpuExtras.join(' · ')}` : '';
|
||||
// Read-only WireGuard badge: shown only when the Spark has a wg interface up.
|
||||
// "VPN <ip>" means it's a peer on that tunnel (reachable off-LAN when the
|
||||
// tunnel is up); it reflects interface presence, not live peer reachability.
|
||||
const wgIp = s.wg_addr ? String(s.wg_addr).split('/')[0] : '';
|
||||
const wgBadge = s.wg_iface
|
||||
? ` · <span class="wg-badge" title="On WireGuard tunnel '${escapeHtml(s.wg_iface)}'${wgIp ? ' as ' + escapeHtml(wgIp) : ''} — reachable off-LAN while the tunnel is up">VPN${wgIp ? ' ' + escapeHtml(wgIp) : ''}</span>`
|
||||
: '';
|
||||
card.className = 'hw-card';
|
||||
card.innerHTML = `
|
||||
<div class="head">
|
||||
<span class="name">${escapeHtml(s.hostname || key)}</span>
|
||||
<span class="meta">${escapeHtml(key)} · ${escapeHtml(s.gpu_name || '')} · ${escapeHtml(s.uptime || '')}</span>
|
||||
<span class="meta">${escapeHtml(key)} · ${escapeHtml(s.gpu_name || '')} · ${escapeHtml(s.uptime || '')}${wgBadge}</span>
|
||||
<button class="icon-btn ssh-key-btn" data-ssh-key="${escapeHtml(key)}" title="Copy this Spark's SSH public key (creates one if it doesn't have one) — e.g. to let it log in to your Mac" aria-label="Copy SSH public key">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="hw-metric">
|
||||
<span class="label">CPU</span>
|
||||
@@ -1849,11 +1885,15 @@ async function init() {
|
||||
el('#nim-prog-close').addEventListener('click', () => el('#nim-progress-dialog').close());
|
||||
el('#open-connectivity').addEventListener('click', openConnectivityDialog);
|
||||
el('#connectivity-close').addEventListener('click', () => el('#connectivity-dialog').close());
|
||||
// Wake-on-LAN buttons live on unreachable hardware cards; delegate.
|
||||
// Hardware-card buttons (Wake-on-LAN on unreachable cards; SSH-key copy on
|
||||
// reachable ones) are rendered dynamically, so delegate from the grid.
|
||||
el('#hardware-grid').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-wake]');
|
||||
if (btn) wakeSpark(btn.dataset.wake);
|
||||
const wbtn = e.target.closest('[data-wake]');
|
||||
if (wbtn) { wakeSpark(wbtn.dataset.wake); return; }
|
||||
const kbtn = e.target.closest('[data-ssh-key]');
|
||||
if (kbtn) { copySparkSshKey(kbtn.dataset.sshKey, kbtn); return; }
|
||||
});
|
||||
el('#sshkey-close').addEventListener('click', () => el('#sshkey-dialog').close());
|
||||
setupCatalogDialog();
|
||||
setupAdvancedDialog();
|
||||
// Open WebUI link from /api/config
|
||||
|
||||
@@ -244,6 +244,24 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="sshkey-dialog" class="modal">
|
||||
<form method="dialog" class="modal-form">
|
||||
<h3 id="sshkey-title">SSH public key</h3>
|
||||
<p id="sshkey-intro" class="muted small"></p>
|
||||
<div class="sshkey-row">
|
||||
<pre id="sshkey-value" class="snippet copyable" data-copy-self title="Click to copy"></pre>
|
||||
<button type="button" class="icon-btn" data-copy="#sshkey-value" title="Copy public key" aria-label="Copy public key">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="muted small">To let this Spark log in to another machine (e.g. your Mac), run this in a terminal <em>on that machine</em>:</p>
|
||||
<pre id="sshkey-install" class="snippet copyable" data-copy-self title="Click to copy"></pre>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="sshkey-close" class="btn">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="advanced-dialog" class="modal">
|
||||
<form method="dialog" class="modal-form" id="advanced-form">
|
||||
<h3 id="adv-title">Advanced settings</h3>
|
||||
|
||||
@@ -374,6 +374,12 @@ main {
|
||||
}
|
||||
.hw-card .head .name { font-weight: 600; font-size: 15px; }
|
||||
.hw-card .head .meta { color: var(--muted); font-size: 12px; margin-left: auto; }
|
||||
/* WireGuard "VPN <ip>" badge in the meta line — accent (green) = on a tunnel. */
|
||||
.hw-card .head .meta .wg-badge { color: var(--accent); font-weight: 600; cursor: help; }
|
||||
/* Copy-this-Spark's-ssh-key button pins to the top-right corner; meta keeps
|
||||
its margin-left:auto so name/meta/button read left→right→corner. */
|
||||
.hw-card .head .ssh-key-btn { align-self: flex-start; padding: 3px 6px; }
|
||||
.hw-card .head .ssh-key-btn svg { width: 13px; height: 13px; }
|
||||
.hw-card.unreachable { border-color: rgba(239, 68, 68, 0.4); }
|
||||
.hw-card.unreachable .name { color: var(--error); }
|
||||
.hw-card.unreachable ol { color: var(--muted); }
|
||||
@@ -387,6 +393,10 @@ main {
|
||||
}
|
||||
.hw-card .wol-row .btn { padding: 5px 10px; font-size: 12px; }
|
||||
.hw-card .mac-display { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
/* SSH-key dialog: key line beside its copy button; long key wraps rather than scrolls. */
|
||||
.sshkey-row { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.sshkey-row .snippet { flex: 1; margin: 0; white-space: pre-wrap; word-break: break-all; }
|
||||
#sshkey-install { white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
.connectivity-content {
|
||||
max-height: 360px;
|
||||
|
||||
Reference in New Issue
Block a user