v0.20.0:0 - per-spark ssh-key copy + wireguard status badge
This commit is contained in:
@@ -54,13 +54,13 @@ Subsystem guidance lives in `docs/guides/` and loads when matching files are tou
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- **Working (v0.19.0:0, installed and serving):** swap dashboard; chat / transcribe / diarize(+chunk) / TTS proxies; embeddings + rerank + hybrid search (Qdrant); `/scrub` + `/rehydrate`; label-merge incl. dual-channel mode. Spark 2 audio stack healthy (11k+ requests/12h, all 200).
|
- **Working (v0.20.0:0, installed and serving):** swap dashboard; chat / transcribe / diarize(+chunk) / TTS proxies; embeddings + rerank + hybrid search (Qdrant); `/scrub` + `/rehydrate`; label-merge incl. dual-channel mode. Spark 2 audio stack healthy (11k+ requests/12h, all 200).
|
||||||
- **Security hardening shipped (v0.19.0:0, 2026-06-12):** closed an SSH command-injection path (`shellsafe.py` validates + `shlex.quote`s every user value crossing into a Spark command), a Qdrant collection path-injection, and added a same-origin (CSRF) guard on control endpoints (proxy/data API exempt, consumers unaffected). Full evidence in `EVALUATION.md`; remaining non-blocking P2/P3 debt now lives in `ROADMAP.md`.
|
- **Security hardening shipped (v0.19.0:0, 2026-06-12):** closed an SSH command-injection path (`shellsafe.py` validates + `shlex.quote`s every user value crossing into a Spark command), a Qdrant collection path-injection, and added a same-origin (CSRF) guard on control endpoints (proxy/data API exempt, consumers unaffected). Full evidence in `EVALUATION.md`; remaining non-blocking P2/P3 debt now lives in `ROADMAP.md`.
|
||||||
- **Git history scrubbed (2026-06-12):** owner-specific IPs/hosts/user/key-name/personal-names purged from all commits/tags/messages via `git filter-repo`, force-pushed to `gitea` (every SHA changed); 0 hits across all refs. Pre-rewrite backup bundle: `../spark-control-prehistory-rewrite.bundle`. Owner declined SSH-key rotation (only the key *name* leaked, never the material) — don't re-flag.
|
- **Git history scrubbed (2026-06-12):** owner-specific IPs/hosts/user/key-name/personal-names purged from all commits/tags/messages via `git filter-repo`, force-pushed to `gitea` (every SHA changed); 0 hits across all refs. Pre-rewrite backup bundle: `../spark-control-prehistory-rewrite.bundle`. Owner declined SSH-key rotation (only the key *name* leaked, never the material) — don't re-flag.- **Shipped — Spark connectivity helpers (v0.20.0:0, built + installed 2026-06-15):** two read-mostly hardware-card additions. (a) **SSH-key copy:** small copy icon top-right of each reachable card → `POST /api/spark/{name}/ssh-key` (generate-if-missing + return the Spark's *outbound* pubkey; non-destructive; CSRF-guarded; no request input reaches the command so no shellsafe). UI pops `#sshkey-dialog` (key + paste-on-Mac one-liner) since plain-HTTP blocks `navigator.clipboard`. Opposite direction from the StartOS `showPublicKey` action (that grants the *dashboard* access to the Sparks). (b) **WireGuard status badge:** the `hardware.py` probe now also reports `wg_iface`/`wg_addr` via unprivileged `ip -o link show type wireguard` (no root/sudo, ends in a pipe to awk so it can't trip the probe's `set -e`); `renderHardware` shows a `VPN <ip>` badge in the meta line when a tunnel is up. Reflects interface presence, not live peer reachability (true handshake age would need `sudo wg show`). Verified: clean `make x86` + `start-cli package install` exit 0, the real `ip ... type wireguard` output on spark2 matches the parser, and — **confirmed in-browser** — the SSH-key icon works. That also closes the long-open v0.19.0 question: the same-origin CSRF guard does NOT false-block control endpoints behind the StartOS proxy (the SSH-key POST goes through it). The `VPN 10.59.211.6` badge render is confirmed in-browser too — feature fully verified.
|
||||||
- **Only unverified bit of v0.19.0:0:** an on-box click-through of one control action (swap / service start/stop) to confirm the CSRF guard doesn't false-positive-block the dashboard behind the StartOS proxy. If a normal action ever returns "cross-origin request … blocked," the fix is loosening the `Host`/`Origin` check in `csrf_guard`.
|
- **spark2 joined the `starttunnel` WireGuard subnet (2026-06-15):** config installed at `/etc/wireguard/starttunnel.conf`, interface `starttunnel` up at `10.59.211.6/24`, `wg-quick@starttunnel` enabled (survives reboot). Split tunnel (`AllowedIPs = 10.59.211.0/24`) so the Spark keeps its LAN route — the dashboard's SSH is unaffected. Purpose: let a bot on spark2 reach the owner's Mac off-LAN. **Finding:** passwordless sudo is NOT configured on spark2 (`sudo wg show` → "a password is required") — the earlier assumption was wrong; harmless here since the badge is sudo-free, but note it before designing any dashboard feature that needs root on a Spark.
|
||||||
- **In progress — Signal Engine "flakiness":** diagnosed, not a server bug — transient 1–4s unresponsiveness while the single GPU is continuously busy. Client-side remedy drafted (in-flight cap 2, hard ceiling 3 across audio endpoints, retry-with-backoff on timeout/503), with the owner to forward to that dev.
|
- **In progress — Signal Engine "flakiness":** diagnosed, not a server bug — transient 1–4s unresponsiveness while the single GPU is continuously busy. Client-side remedy drafted (in-flight cap 2, hard ceiling 3 across audio endpoints, retry-with-backoff on timeout/503), with the owner to forward to that dev.
|
||||||
- **Decided, not implemented:** no public interface / no API token auth — LAN + WireGuard/Tailscale split-tunnel only (the CSRF guard now covers the browser-driven vector). An empirical audio concurrency sweep is offered but needs the owner's OK in a quiet window.
|
- **Decided, not implemented:** no public interface / no API token auth — LAN + WireGuard/Tailscale split-tunnel only (the CSRF guard now covers the browser-driven vector). An empirical audio concurrency sweep is offered but needs the owner's OK in a quiet window.
|
||||||
- **Known limits:** `/health` blips while the GPU is busy (mitigated client-side); dual-channel can miss a quiet local word under loud remote bleed; the connectivity log misses sub-5s outages between 5s polls; diarizer caps at 4 speakers.
|
- **Known limits:** `/health` blips while the GPU is busy (mitigated client-side); dual-channel can miss a quiet local word under loud remote bleed; the connectivity log misses sub-5s outages between 5s polls; diarizer caps at 4 speakers.
|
||||||
- **Repo wart:** commit `8d839e3` (was `367d986` pre-rewrite) is labeled `v0.13.0:4` but contains everything through v0.18.0:0 — per-version commits for v0.14–v0.18 don't exist. Keep commit messages accurate.
|
- **Repo wart:** commit `8d839e3` (was `367d986` pre-rewrite) is labeled `v0.13.0:4` but contains everything through v0.18.0:0 — per-version commits for v0.14–v0.18 don't exist. Keep commit messages accurate.
|
||||||
- **Hosting:** pushes to the owner's self-hosted Gitea — remote `gitea`, branch `master`, over SSH. Push after committing.
|
- **Hosting:** pushes to the owner's self-hosted Gitea — remote `gitea`, branch `master`, over SSH. Push after committing.
|
||||||
- **Next:** (1) on-box CSRF click-through; (2) owner forwards the concurrency note to the Signal Engine dev; (3) concurrency sweep if the dev wants the measured knee; (4) parakeet-asr `--memory` cap via Reapply-patches; (5) start the `ROADMAP.md` tech-debt list (a pytest harness first).
|
- **Next:** (1) owner forwards the concurrency note to the Signal Engine dev; (2) concurrency sweep if the dev wants the measured knee; (3) parakeet-asr `--memory` cap via Reapply-patches; (4) start the `ROADMAP.md` tech-debt list (a pytest harness first).
|
||||||
|
|||||||
@@ -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}')
|
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}')
|
DEFIF=$(ip route show default 2>/dev/null | awk '{print $5; exit}')
|
||||||
echo MAC=$(cat /sys/class/net/$DEFIF/address 2>/dev/null)
|
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()
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +87,11 @@ def _parse(out: str) -> dict:
|
|||||||
# MAC address on the default-route interface (for Wake-on-LAN)
|
# MAC address on the default-route interface (for Wake-on-LAN)
|
||||||
if info.get("mac"):
|
if info.get("mac"):
|
||||||
parsed["mac"] = info["mac"].lower()
|
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
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -401,6 +401,53 @@ async def wake_spark(name: str) -> dict:
|
|||||||
return {"ok": True, "spark": name, "mac": mac, "delivered_via": delivered_via}
|
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")
|
@app.get("/api/services")
|
||||||
async def get_services() -> dict:
|
async def get_services() -> dict:
|
||||||
"""Lifecycle state of always-on support services (Parakeet, Kokoro, …).
|
"""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() {
|
function renderHardware() {
|
||||||
const panel = el('#hardware-panel');
|
const panel = el('#hardware-panel');
|
||||||
const grid = el('#hardware-grid');
|
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_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`);
|
if (s.gpu_power_w != null) gpuExtras.push(`${s.gpu_power_w.toFixed(0)}W`);
|
||||||
const gpuExtrasStr = gpuExtras.length ? ` · ${gpuExtras.join(' · ')}` : '';
|
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.className = 'hw-card';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="name">${escapeHtml(s.hostname || key)}</span>
|
<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>
|
||||||
<div class="hw-metric">
|
<div class="hw-metric">
|
||||||
<span class="label">CPU</span>
|
<span class="label">CPU</span>
|
||||||
@@ -1849,11 +1885,15 @@ async function init() {
|
|||||||
el('#nim-prog-close').addEventListener('click', () => el('#nim-progress-dialog').close());
|
el('#nim-prog-close').addEventListener('click', () => el('#nim-progress-dialog').close());
|
||||||
el('#open-connectivity').addEventListener('click', openConnectivityDialog);
|
el('#open-connectivity').addEventListener('click', openConnectivityDialog);
|
||||||
el('#connectivity-close').addEventListener('click', () => el('#connectivity-dialog').close());
|
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) => {
|
el('#hardware-grid').addEventListener('click', (e) => {
|
||||||
const btn = e.target.closest('[data-wake]');
|
const wbtn = e.target.closest('[data-wake]');
|
||||||
if (btn) wakeSpark(btn.dataset.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();
|
setupCatalogDialog();
|
||||||
setupAdvancedDialog();
|
setupAdvancedDialog();
|
||||||
// Open WebUI link from /api/config
|
// Open WebUI link from /api/config
|
||||||
|
|||||||
@@ -244,6 +244,24 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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">
|
<dialog id="advanced-dialog" class="modal">
|
||||||
<form method="dialog" class="modal-form" id="advanced-form">
|
<form method="dialog" class="modal-form" id="advanced-form">
|
||||||
<h3 id="adv-title">Advanced settings</h3>
|
<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 .name { font-weight: 600; font-size: 15px; }
|
||||||
.hw-card .head .meta { color: var(--muted); font-size: 12px; margin-left: auto; }
|
.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 { border-color: rgba(239, 68, 68, 0.4); }
|
||||||
.hw-card.unreachable .name { color: var(--error); }
|
.hw-card.unreachable .name { color: var(--error); }
|
||||||
.hw-card.unreachable ol { color: var(--muted); }
|
.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 .wol-row .btn { padding: 5px 10px; font-size: 12px; }
|
||||||
.hw-card .mac-display { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
.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 {
|
.connectivity-content {
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
|
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
export const v0_1_0 = VersionInfo.of({
|
export const v0_1_0 = VersionInfo.of({
|
||||||
version: '0.19.0:0',
|
version: '0.20.0:0',
|
||||||
releaseNotes: {
|
releaseNotes: {
|
||||||
en_US:
|
en_US:
|
||||||
'v0.19.0:0 — security hardening of the cluster-control surface (no change to the proxy/data APIs your other apps use). (1) Every user-supplied value that reaches an SSH command on the Sparks — model repo, vLLM args/knobs, NIM image/container, service names — is now strictly validated and shell-quoted, closing a command-injection path. (2) The Qdrant collection name in /api/search is validated so it can no longer be used to reach other collections. (3) State-changing dashboard endpoints (model swap, NIM install, service start/stop, disk delete, etc.) now require a same-origin request, blocking cross-site (CSRF) attacks from a malicious page open in your browser. The OpenAI-compatible proxies (/v1/*), the redaction gateway (/scrub, /rehydrate), /api/search, /api/audio/*, and /api/health-event are exempt, so Recap Relay, the CRM, Open WebUI and other consumers are unaffected.',
|
"v0.20.0:0 — Spark connectivity helpers on the hardware cards. (1) A small copy icon in each card's top-right corner grabs that Spark's SSH public key — the key the Spark uses to log in to OTHER machines (e.g. your Mac). If the Spark has no key yet, one is generated on the spot (no passphrase, so apps can use it unattended); an existing key is never overwritten. A dialog shows the key plus a ready-to-paste command for adding it on the target machine. (This is the opposite direction from the existing \"Show Public Key\" action, which grants THIS dashboard access to your Sparks.) (2) If a Spark is on a WireGuard tunnel, its card now shows a read-only \"VPN <ip>\" badge next to the uptime, so you can see at a glance that the box is reachable off-LAN. All read-only — the dashboard does not configure the tunnel.",
|
||||||
},
|
},
|
||||||
migrations: {
|
migrations: {
|
||||||
up: async ({ effects }) => {},
|
up: async ({ effects }) => {},
|
||||||
|
|||||||
Reference in New Issue
Block a user