Scanning archi-dev-box from OUTSIDE found five ports serving their screens with no login — lnd-ui 18083, bitcoin-ui 8334, fips-ui 8336, electrs-ui 50002 and the Fedimint Guardian 8175 — none of which appeared in the gate's unprotected list. They are host-networked, so Podman publishes nothing to pin and their manifests declared 'ports: []'; the gate builds its map from declared ports, so it neither protected them nor reported them. An audit that reports success while five screens are open is worse than no audit. Their nginx now listens on 127.0.0.1 instead of 0.0.0.0, and each port is declared 'auth: gated' so the daemon owns the outside. 'bind:' on a host-networked app is a statement of where the container listens, not a publish instruction — quadlet already skips PublishPort in host mode. Guardian 8175 is declared on the fedimint app because its companion has no manifest, and the gate keys on port, not container. Credential paths were NOT exposed and are verified so: /lnd-connect-info, the /proxy/lnd/ passthrough, container logs and every RPC method through these screens all return 401 unauthenticated. What leaked was the page shell. Also fixes the delivery gap that would have made this unshippable: only bitcoin-ui, lnd-ui and electrs-ui were ever rsynced to /opt/archipelago/docker, so edits to fips-ui and fedimint-ui reached nodes through no path at all. All five now sync; the two whose rebuilds the daemon owns are synced without being handed to container-specs. Every remaining undeclared port is now declared with a stated reason — gated: botfights 9100, router 8084, pine 10380; exempt with rationale: fedimint consensus 8173/8174, gateway 8176/9737, netbird 8086/8087 (TLS + own auth, and enrolled devices cannot hold a session), pine TLS 10381, lightning-stack REST 8091 (macaroon, mirrors lnd). Zero undeclared ports remain across all 56 manifests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
403 lines
20 KiB
YAML
403 lines
20 KiB
YAML
app:
|
||
id: pine
|
||
name: Pine
|
||
version: "1.3.0"
|
||
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.
|
||
category: home
|
||
|
||
# The user-facing launcher (app_id + container both "pine", matching the
|
||
# runtime references + the live container so the orchestrator adopts it). A
|
||
# tiny nginx that serves the "Connect Pine to WiFi" provisioner page for the
|
||
# voice stack. The two Wyoming engines (pine-whisper, pine-piper) are internal
|
||
# stack members.
|
||
container_name: pine
|
||
|
||
container:
|
||
image: docker.io/library/nginx:1.27-alpine
|
||
pull_policy: if-not-present
|
||
network: archy-net
|
||
network_aliases: [pine]
|
||
# The provisioner uses Web Bluetooth (Improv-over-BLE) to push WiFi creds to
|
||
# the PineVoice speaker. navigator.bluetooth only exists in a SECURE CONTEXT
|
||
# (https or localhost), so the launcher terminates TLS with a self-signed
|
||
# cert — otherwise the "Connect Pine to WiFi" button is inert on the LAN.
|
||
# Idempotent: kept as-is when crt+key already exist. Mirrors the netbird
|
||
# secure-context fix (#15).
|
||
generated_certs:
|
||
- crt: /var/lib/archipelago/pine/tls.crt
|
||
key: /var/lib/archipelago/pine/tls.key
|
||
|
||
dependencies:
|
||
- app_id: pine-whisper
|
||
- app_id: pine-piper
|
||
- app_id: pine-openwakeword
|
||
- storage: 128Mi
|
||
|
||
resources:
|
||
memory_limit: 64Mi
|
||
|
||
security:
|
||
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
|
||
# workers) binds :443 inside the container — needs the worker-drop caps +
|
||
# NET_BIND_SERVICE for the privileged port.
|
||
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
|
||
readonly_root: false
|
||
no_new_privileges: true
|
||
network_policy: isolated
|
||
|
||
ports:
|
||
# 10380 (http) is the Open target — the UI launches apps as
|
||
# http://host:10380 (resolveAppUrl). nginx there 301-redirects to the https
|
||
# listener on 10381, so the new tab lands on a secure context where
|
||
# navigator.bluetooth (the "Connect Pine to WiFi" provisioner) works.
|
||
- host: 10380
|
||
container: 80
|
||
protocol: tcp
|
||
bind: 127.0.0.1
|
||
auth: gated
|
||
- host: 10381
|
||
container: 443
|
||
protocol: tcp
|
||
auth: none
|
||
auth_rationale: >-
|
||
Pine's TLS listener. The gate speaks plain HTTP, so fronting this port would
|
||
break the secure context navigator.bluetooth needs for WiFi provisioning.
|
||
The plain-HTTP entry point (10380) is gated, and it is what the UI opens.
|
||
|
||
volumes:
|
||
- type: bind
|
||
source: /var/lib/archipelago/pine/nginx.conf
|
||
target: /etc/nginx/conf.d/default.conf
|
||
options: [ro]
|
||
- type: bind
|
||
source: /var/lib/archipelago/pine/tls.crt
|
||
target: /etc/nginx/tls.crt
|
||
options: [ro]
|
||
- type: bind
|
||
source: /var/lib/archipelago/pine/tls.key
|
||
target: /etc/nginx/tls.key
|
||
options: [ro]
|
||
- type: bind
|
||
source: /var/lib/archipelago/pine/index.html
|
||
target: /usr/share/nginx/html/index.html
|
||
options: [ro]
|
||
|
||
environment: []
|
||
|
||
files:
|
||
- path: /var/lib/archipelago/pine/nginx.conf
|
||
overwrite: true
|
||
content: |
|
||
server {
|
||
listen 80;
|
||
server_name _;
|
||
return 301 https://$host:10381$request_uri;
|
||
}
|
||
server {
|
||
listen 443 ssl;
|
||
server_name _;
|
||
ssl_certificate /etc/nginx/tls.crt;
|
||
ssl_certificate_key /etc/nginx/tls.key;
|
||
root /usr/share/nginx/html;
|
||
index index.html;
|
||
# Live node facts for the status card — proxied to the node's
|
||
# public status tier so the (https) page can fetch same-origin.
|
||
location = /node-status {
|
||
proxy_pass http://host.containers.internal:80/api/pine/status;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Host $host;
|
||
proxy_connect_timeout 5s;
|
||
proxy_read_timeout 10s;
|
||
}
|
||
location / { try_files $uri $uri/ /index.html; }
|
||
}
|
||
- path: /var/lib/archipelago/pine/index.html
|
||
overwrite: true
|
||
content: |
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Pine — connect your speaker</title>
|
||
<style>
|
||
:root { color-scheme: dark; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; font: 16px/1.6 system-ui, -apple-system, sans-serif;
|
||
background: #0f1512; color: #e7efe9; }
|
||
.wrap { max-width: 520px; margin: 0 auto; padding: 40px 22px 64px; }
|
||
header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; }
|
||
header svg { width: 52px; height: 52px; flex: 0 0 auto; }
|
||
h1 { font-size: 26px; margin: 0; }
|
||
.tag { color: #8fbfa5; font-size: 14px; margin: 2px 0 0; }
|
||
.card { background: #16201b; border: 1px solid #24332b;
|
||
border-radius: 14px; padding: 20px; margin: 20px 0; }
|
||
.status .row { display: flex; gap: 10px; align-items: baseline; font-size: 14px; }
|
||
.status .row b { min-width: 84px; color: #9fd3b6; }
|
||
.status code { background: #0f1512; padding: 1px 6px; border-radius: 5px;
|
||
font-size: 13px; color: #cfe9d9; }
|
||
label { display: block; font-size: 13px; color: #9fbfad; margin: 14px 0 5px; }
|
||
input { width: 100%; padding: 11px 12px; font-size: 15px; color: #e7efe9;
|
||
background: #0f1512; border: 1px solid #2b3d33; border-radius: 9px;
|
||
outline: none; }
|
||
input:focus { border-color: #4fae82; }
|
||
button { width: 100%; margin-top: 18px; padding: 13px; font-size: 15px;
|
||
font-weight: 600; color: #06110b; background: #7fd6a6; border: 0;
|
||
border-radius: 10px; cursor: pointer; transition: filter .15s; }
|
||
button:hover:not(:disabled) { filter: brightness(1.08); }
|
||
button:disabled { opacity: .45; cursor: default; }
|
||
#log { margin-top: 16px; padding: 12px; min-height: 68px; font-size: 13px;
|
||
font-family: ui-monospace, monospace; white-space: pre-wrap;
|
||
background: #0b100d; border: 1px solid #1e2a23; border-radius: 9px;
|
||
color: #a9c6b6; max-height: 220px; overflow-y: auto; }
|
||
.ok { color: #7fd6a6; } .err { color: #ee8f8f; } .warn { color: #e8c878; }
|
||
.ha { color: #6d8578; font-size: 13px; margin-top: 22px; }
|
||
.ha b { color: #9fbfad; }
|
||
.insecure { display: none; background: #2a1f12; border-color: #4a3418;
|
||
color: #e8c878; font-size: 13px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<header>
|
||
<svg viewBox="0 0 512 512" aria-hidden="true"><g fill="#7fd6a6">
|
||
<rect x="236" y="396" width="40" height="72" rx="6"/>
|
||
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
|
||
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
|
||
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
|
||
</g></svg>
|
||
<div><h1>Pine</h1>
|
||
<p class="tag">Connect your speaker — everything stays on your node.</p></div>
|
||
</header>
|
||
|
||
<div class="card status">
|
||
<div class="row"><b>Whisper</b><span>speech-to-text ready on <code>:10300</code></span></div>
|
||
<div class="row"><b>Piper</b><span>text-to-speech ready on <code>:10200</code></span></div>
|
||
<div class="row"><b>Wake word</b><span>“Hey Jarvis” on the speaker (openWakeWord on <code>:10400</code>)</span></div>
|
||
<div class="row"><b>Speaker</b><span>put it in pairing mode — ring LED blinking yellow</span></div>
|
||
</div>
|
||
|
||
<div class="card status">
|
||
<div class="row"><b>Node</b><span id="ns-node">checking…</span></div>
|
||
<div class="row"><b>Bitcoin</b><span id="ns-btc">—</span></div>
|
||
<div class="row"><b>Peers</b><span id="ns-peers">—</span></div>
|
||
</div>
|
||
|
||
<div class="card insecure" id="insecure">
|
||
This page isn’t running over HTTPS, so the browser blocks Bluetooth.
|
||
Open it via its <b>https://…:10380</b> address (accept the self-signed
|
||
certificate) and the button below will work.
|
||
</div>
|
||
|
||
<div class="card">
|
||
<label for="ssid">WiFi network (SSID)</label>
|
||
<input id="ssid" autocomplete="off" spellcheck="false" placeholder="Your 2.4 GHz network">
|
||
<label for="pass">WiFi password — typed here, sent only over Bluetooth to the speaker</label>
|
||
<input id="pass" type="password" autocomplete="off">
|
||
<button id="go">Connect Pine to WiFi</button>
|
||
<div id="log">Ready. Click the button, then pick “PineVoice” in the Bluetooth popup.</div>
|
||
</div>
|
||
|
||
<p class="ha">After WiFi joins, one manual step remains — pair the
|
||
speaker in Home Assistant: <b>Settings → Devices & services →
|
||
Add Wyoming Protocol</b>, host = the speaker’s IP, port
|
||
<b>10700</b>. Whisper, Piper, openWakeWord and the Assist pipeline
|
||
are wired up automatically when Pine installs. Wake word:
|
||
<b>“Hey Jarvis.”</b> Ask node things like <i>“what’s the block
|
||
height?”</i>, <i>“how many peers?”</i>, <i>“is the node
|
||
synced?”</i> or <i>“what’s my lightning balance?”</i> — and when a
|
||
Claude API key is set on the node, anything else gets answered by
|
||
Claude. New mesh messages are announced on the speaker too.</p>
|
||
<p class="ha">Troubleshooting: if it hears you (LED reacts) but answers
|
||
are silent, unplug and replug the speaker — an interrupted answer can
|
||
wedge its audio output until it reboots.</p>
|
||
</div>
|
||
|
||
<script>
|
||
"use strict";
|
||
// Improv-over-BLE (improv-wifi spec, verified vs improv-wifi-sdk 1.4.0).
|
||
const SVC = "00467768-6228-2272-4663-277478268000";
|
||
const CHAR_STATE = "00467768-6228-2272-4663-277478268001";
|
||
const CHAR_ERROR = "00467768-6228-2272-4663-277478268002";
|
||
const CHAR_RPC = "00467768-6228-2272-4663-277478268003";
|
||
const CHAR_RESULT = "00467768-6228-2272-4663-277478268004";
|
||
const STATES = {1:"authorization required",2:"authorized",3:"provisioning…",4:"PROVISIONED"};
|
||
const ERRORS = {1:"invalid RPC packet",2:"unknown RPC command",
|
||
3:"unable to connect — wrong password, or the SSID isn't reachable on 2.4 GHz",
|
||
4:"not authorized — press the button on the device",5:"bad hostname",
|
||
255:"unknown device error"};
|
||
|
||
const logEl = document.getElementById("log");
|
||
const log = (msg, cls) => { const l = document.createElement("div");
|
||
if (cls) l.className = cls; l.textContent = msg; logEl.appendChild(l);
|
||
logEl.scrollTop = logEl.scrollHeight; };
|
||
const hex = dv => [...new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength)]
|
||
.map(b => b.toString(16).padStart(2, "0")).join(" ");
|
||
|
||
const btn = document.getElementById("go");
|
||
if (!navigator.bluetooth) {
|
||
document.getElementById("insecure").style.display = "block";
|
||
logEl.textContent = "Web Bluetooth unavailable — open this page over https (see note above).";
|
||
}
|
||
|
||
btn.addEventListener("click", async () => {
|
||
const ssid = document.getElementById("ssid").value.trim();
|
||
const pass = document.getElementById("pass").value;
|
||
if (!ssid) { log("Enter your WiFi network name first.", "err"); return; }
|
||
if (!navigator.bluetooth) { log("No Web Bluetooth — open this page over https.", "err"); return; }
|
||
btn.disabled = true; logEl.textContent = "";
|
||
let device;
|
||
try {
|
||
log("Opening Bluetooth device chooser…");
|
||
device = await navigator.bluetooth.requestDevice({
|
||
filters: [{ services: [SVC] }, { namePrefix: "PineVoice" }],
|
||
optionalServices: [SVC],
|
||
});
|
||
log(`Selected: ${device.name || "(unnamed)"}`);
|
||
device.addEventListener("gattserverdisconnected", () => log("BLE disconnected."));
|
||
log("Connecting…");
|
||
const gatt = await device.gatt.connect();
|
||
const svc = await gatt.getPrimaryService(SVC);
|
||
const stateChar = await svc.getCharacteristic(CHAR_STATE);
|
||
const errorChar = await svc.getCharacteristic(CHAR_ERROR);
|
||
const rpcChar = await svc.getCharacteristic(CHAR_RPC);
|
||
const resultChar = await svc.getCharacteristic(CHAR_RESULT);
|
||
|
||
let done, fail;
|
||
const outcome = new Promise((res, rej) => { done = res; fail = rej; });
|
||
let authorize; const authorized = new Promise(res => { authorize = res; });
|
||
let state = -1;
|
||
const onState = (s, via = "") => {
|
||
if (s === state) return; state = s;
|
||
log(`Device state${via}: ${STATES[s] || s}`, s === 4 ? "ok" : undefined);
|
||
if (s >= 2) authorize();
|
||
if (s === 4) done(null);
|
||
};
|
||
stateChar.addEventListener("characteristicvaluechanged",
|
||
e => onState(e.target.value.getUint8(0)));
|
||
errorChar.addEventListener("characteristicvaluechanged", e => {
|
||
const c = e.target.value.getUint8(0);
|
||
if (c !== 0) fail(new Error(ERRORS[c] || `error ${c}`)); });
|
||
resultChar.addEventListener("characteristicvaluechanged", e => {
|
||
const v = e.target.value; log(`RPC result: ${hex(v)}`);
|
||
if (v.byteLength > 2 && v.getUint8(1) > 0) {
|
||
const n = v.getUint8(2); const b = new Uint8Array(n);
|
||
for (let i = 0; i < n; i++) b[i] = v.getUint8(3 + i);
|
||
done(new TextDecoder().decode(b)); } });
|
||
await stateChar.startNotifications();
|
||
await errorChar.startNotifications();
|
||
await resultChar.startNotifications();
|
||
onState((await stateChar.readValue()).getUint8(0));
|
||
|
||
const poller = setInterval(async () => {
|
||
try { onState((await stateChar.readValue()).getUint8(0), " (polled)");
|
||
const ec = (await errorChar.readValue()).getUint8(0);
|
||
if (ec !== 0) fail(new Error(ERRORS[ec] || `error ${ec}`)); } catch {} }, 2000);
|
||
|
||
let nextUrl;
|
||
try {
|
||
if (state === 1) {
|
||
log("Authorization required — press the centre button on the speaker now.", "warn");
|
||
await Promise.race([authorized, outcome,
|
||
new Promise((_, rej) => setTimeout(
|
||
() => rej(new Error("timed out waiting for the button press")), 60000))]);
|
||
log("Authorized.", "ok");
|
||
}
|
||
const enc = new TextEncoder();
|
||
const sb = enc.encode(ssid), pb = enc.encode(pass);
|
||
const data = new Uint8Array([sb.length, ...sb, pb.length, ...pb]);
|
||
const pkt = new Uint8Array([1, data.length, ...data, 0]);
|
||
pkt[pkt.length - 1] = pkt.reduce((s, b) => s + b, 0);
|
||
log(`Sending WiFi credentials for “${ssid}”…`);
|
||
if (rpcChar.writeValueWithResponse) await rpcChar.writeValueWithResponse(pkt);
|
||
else await rpcChar.writeValue(pkt);
|
||
log("Credentials delivered — speaker acknowledged.", "ok");
|
||
log("Joining WiFi… (the speaker plays a sound within ~20s either way)");
|
||
const timeout = new Promise((_, rej) => setTimeout(
|
||
() => rej(new Error("timed out after 60s waiting for the device")), 60000));
|
||
nextUrl = await Promise.race([outcome, timeout]);
|
||
} finally { clearInterval(poller); }
|
||
|
||
log("✓ PROVISIONED — the ring LED should breathe dim magenta.", "ok");
|
||
if (nextUrl) log(`Device reports next step: ${nextUrl}`, "ok");
|
||
try { gatt.disconnect(); } catch {}
|
||
} catch (err) {
|
||
log(`✗ ${err.name || "Error"}: ${err.message}`, "err");
|
||
if (err.name === "NotFoundError")
|
||
log("No speaker matched, or you closed the chooser. LED blinking yellow? "
|
||
+ "Hold the dot button 15s to reset.", "warn");
|
||
if (err.name === "NetworkError")
|
||
log("BLE link dropped — move closer, and make sure the Mac isn't already "
|
||
+ "paired to the speaker (it can hold the link exclusively).", "warn");
|
||
try { device?.gatt?.disconnect(); } catch {}
|
||
} finally { btn.disabled = false; }
|
||
});
|
||
|
||
// Live node status card — public tier of /api/pine/status via the
|
||
// same-origin /node-status proxy. Best-effort: failures just show
|
||
// "unavailable" and retry on the next tick.
|
||
const nsNode = document.getElementById("ns-node");
|
||
const nsBtc = document.getElementById("ns-btc");
|
||
const nsPeers = document.getElementById("ns-peers");
|
||
async function refreshNodeStatus() {
|
||
try {
|
||
const r = await fetch("/node-status", { cache: "no-store" });
|
||
if (!r.ok) throw new Error(String(r.status));
|
||
const s = await r.json();
|
||
const up = Math.floor((s.uptime_seconds || 0) / 3600);
|
||
nsNode.textContent = `Archipelago ${s.version || "?"} — up ${up}h`;
|
||
if (s.bitcoin && s.bitcoin.height != null) {
|
||
const pct = s.bitcoin.sync_percent;
|
||
nsBtc.textContent = `block ${s.bitcoin.height}` +
|
||
(pct != null ? (pct >= 99.99 ? " — synced" : ` — ${pct}% synced`) : "");
|
||
} else {
|
||
nsBtc.textContent = "not running";
|
||
}
|
||
const btcPeers = s.bitcoin && s.bitcoin.peers != null ? s.bitcoin.peers : "?";
|
||
const meshPeers = s.mesh ? s.mesh.peers : 0;
|
||
nsPeers.textContent = `${btcPeers} bitcoin` +
|
||
(s.mesh && s.mesh.enabled ? `, ${meshPeers} mesh` : "");
|
||
} catch {
|
||
nsNode.textContent = "node status unavailable";
|
||
nsBtc.textContent = "—";
|
||
nsPeers.textContent = "—";
|
||
}
|
||
}
|
||
refreshNodeStatus();
|
||
setInterval(refreshNodeStatus, 30000);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
|
||
health_check:
|
||
type: tcp
|
||
endpoint: localhost:443
|
||
interval: 30s
|
||
timeout: 5s
|
||
retries: 5
|
||
start_period: 10s
|
||
|
||
interfaces:
|
||
main:
|
||
name: Pine
|
||
description: Connect your speaker to WiFi and check the voice assistant
|
||
type: ui
|
||
port: 10380
|
||
protocol: http
|
||
path: /
|
||
|
||
metadata:
|
||
author: Archipelago
|
||
icon: /assets/img/app-icons/pine.svg
|
||
website: https://github.com/rhasspy/wyoming
|
||
repo: https://github.com/rhasspy/wyoming
|
||
license: MIT
|
||
category: home
|
||
launch:
|
||
open_in_new_tab: true
|
||
tags:
|
||
- home
|
||
- voice
|
||
- assistant
|
||
- privacy
|