feat(pine): node-status endpoint + Claude voice brain + mesh announcements + wake-word groundwork
- GET /api/pine/status: public tier (version/uptime/bitcoin/mesh facts) for the Pine page; bearer-token tier (Lightning balances, latest mesh message) for the seeded Home Assistant sensors. Token minted 0600 at secrets/pine-status-token; nginx location ships via the bootstrap self-heal + canonical ISO conf. Replaces the per-node socat forwarder and bitcoind RPC credentials living in HA's configuration.yaml. - pine_ha seeder: REST sensors + four ask-Archy intents (block height, peers, sync %, Lightning balance) with LLM tool descriptions; Claude conversation agent (anthropic entry from the node's claude-api-key) set as pipeline default with prefer_local_intents so exact phrases stay local and fuzzy ones tool-route through Claude; mesh-message announce automation on every Assist satellite; bounded config markers with legacy-block migration. - pine-openwakeword joins the pine stack (all lifecycle sites) — on-node wake-word engine groundwork for the custom "Yo Archy" model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b288be314c
commit
e074a117d2
@ -580,6 +580,26 @@ impl ApiHandler {
|
||||
self.handle_app_catalog_proxy().await
|
||||
}
|
||||
|
||||
// Pine node status — public tier (version/uptime/height/sync/peer
|
||||
// counts) is unauthenticated like /bitcoin-status; Lightning
|
||||
// balances + latest mesh message additionally require the bearer
|
||||
// token the pine/HA seeder minted (or a valid session).
|
||||
(Method::GET, "/api/pine/status") => {
|
||||
let bearer = headers
|
||||
.get(hyper::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
let authorized = self.rpc_handler.pine_status_token_ok(bearer).await
|
||||
|| self.is_authenticated(&headers).await;
|
||||
let body = self.rpc_handler.pine_status_json(authorized).await;
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
}
|
||||
|
||||
// LND connect info — nginx validates session cookie (presence check),
|
||||
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
||||
// No backend auth check here because the LND UI iframe fetches this
|
||||
|
||||
@ -27,6 +27,7 @@ mod nostr;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
mod peers;
|
||||
mod pine_status;
|
||||
mod response;
|
||||
mod router;
|
||||
mod security;
|
||||
|
||||
@ -497,7 +497,12 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"netbird-server".into(),
|
||||
],
|
||||
// Pine voice-assistant stack: launcher + the two Wyoming engines.
|
||||
"pine" => vec!["pine".into(), "pine-whisper".into(), "pine-piper".into()],
|
||||
"pine" => vec![
|
||||
"pine".into(),
|
||||
"pine-whisper".into(),
|
||||
"pine-piper".into(),
|
||||
"pine-openwakeword".into(),
|
||||
],
|
||||
"nostr-vpn" => vec![
|
||||
"nostr-vpn".into(),
|
||||
"archy-nostr-vpn".into(),
|
||||
|
||||
@ -598,6 +598,7 @@ pub(super) fn needs_archy_net(package_id: &str) -> bool {
|
||||
| "pine"
|
||||
| "pine-whisper"
|
||||
| "pine-piper"
|
||||
| "pine-openwakeword"
|
||||
)
|
||||
}
|
||||
|
||||
@ -629,7 +630,7 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"pine" => &["pine-whisper", "pine-piper", "pine"],
|
||||
"pine" => &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
|
||||
@ -1,12 +1,21 @@
|
||||
//! Home Assistant <-> Pine voice-stack provisioning.
|
||||
//!
|
||||
//! The Pine app ships the two Wyoming engines (pine-whisper :10300,
|
||||
//! pine-piper :10200) as plain containers. Home Assistant only uses them once
|
||||
//! a `wyoming` config entry exists for each, and once an Assist pipeline
|
||||
//! points at the resulting `stt.faster_whisper` / `tts.piper` entities. Out of
|
||||
//! the box that meant clicking through HA's integration UI twice and building
|
||||
//! a pipeline by hand — so seed all of it directly into HA's `.storage` when
|
||||
//! both apps are present.
|
||||
//! The Pine app ships the Wyoming engines (pine-whisper :10300, pine-piper
|
||||
//! :10200, pine-openwakeword :10400) as plain containers. Home Assistant only
|
||||
//! uses them once a `wyoming` config entry exists for each, and once an
|
||||
//! Assist pipeline points at the resulting entities. Out of the box that
|
||||
//! meant clicking through HA's integration UI and building a pipeline by
|
||||
//! hand — so seed all of it directly into HA's `.storage` when both apps are
|
||||
//! present:
|
||||
//!
|
||||
//! - a `wyoming` config entry per engine,
|
||||
//! - an Assist pipeline wired to whisper + piper,
|
||||
//! - "ask Archy" voice intents backed by REST sensors on the node's
|
||||
//! `/api/pine/status` endpoint (block height, peers, sync, balances),
|
||||
//! - a Claude conversation agent (HA's `anthropic` integration) using the
|
||||
//! node's shared `secrets/claude-api-key`, set as the pipeline's brain with
|
||||
//! `prefer_local_intents: true` so node questions stay local/free,
|
||||
//! - an automation announcing new mesh messages on any Assist satellite.
|
||||
//!
|
||||
//! Everything here is best-effort (warn, never fail an install): HA migrates
|
||||
//! the minimal store shapes we write to its current schema on boot, and skips
|
||||
@ -15,17 +24,23 @@
|
||||
use serde_json::{json, Value};
|
||||
use tracing::{info, warn};
|
||||
|
||||
const HA_CONFIG_DIR: &str = "/var/lib/archipelago/home-assistant";
|
||||
const HA_STORAGE_DIR: &str = "/var/lib/archipelago/home-assistant/.storage";
|
||||
const NODE_SECRETS_DIR: &str = "/var/lib/archipelago/secrets";
|
||||
|
||||
/// The two Wyoming engines the Pine stack publishes on the host.
|
||||
/// The Wyoming engines the Pine stack publishes on the host.
|
||||
/// HA runs under pasta, so the host is reachable as host.containers.internal.
|
||||
const PINE_ENGINES: [(&str, &str, u16); 2] = [
|
||||
const PINE_ENGINES: [(&str, &str, u16); 3] = [
|
||||
("faster-whisper", "host.containers.internal", 10300),
|
||||
("piper", "host.containers.internal", 10200),
|
||||
("openwakeword", "host.containers.internal", 10400),
|
||||
];
|
||||
|
||||
/// Seed Home Assistant with the Pine voice defaults: a `wyoming` config entry
|
||||
/// per engine and an Assist pipeline wired to them. Called after the Pine
|
||||
/// Entity id the Anthropic conversation subentry produces (device name
|
||||
/// "Claude conversation" -> slug). Deterministic on a fresh registry.
|
||||
const CLAUDE_CONVERSATION_ENTITY: &str = "conversation.claude_conversation";
|
||||
|
||||
/// Seed Home Assistant with the Pine voice defaults. Called after the Pine
|
||||
/// stack installs and after Home Assistant installs (each side no-ops when
|
||||
/// the other is missing). HA picks the new entries up on its next start; the
|
||||
/// caller restarts the container when it is already running.
|
||||
@ -39,34 +54,202 @@ pub(super) async fn seed_home_assistant_pine_defaults() -> bool {
|
||||
}
|
||||
|
||||
let entries_changed = seed_wyoming_config_entries(storage).await;
|
||||
let pipeline_changed = seed_assist_pipeline(storage).await;
|
||||
let claude = seed_claude_conversation(storage).await;
|
||||
let pipeline_changed = seed_assist_pipeline(
|
||||
storage,
|
||||
claude.available.then_some(CLAUDE_CONVERSATION_ENTITY),
|
||||
)
|
||||
.await;
|
||||
let intents_changed = seed_voice_intents().await;
|
||||
entries_changed || pipeline_changed || intents_changed
|
||||
let automation_changed = seed_mesh_announce_automation().await;
|
||||
entries_changed || claude.changed || pipeline_changed || intents_changed || automation_changed
|
||||
}
|
||||
|
||||
/// Marker guarding the seeded block in configuration.yaml (idempotence).
|
||||
const VOICE_MARKER: &str = "# --- archipelago pine voice (seeded) ---";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice intents + REST sensors (configuration.yaml + custom_sentences)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Seed "ask Archy" node-info voice intents: custom sentences + intent_script
|
||||
/// + a REST sensor for the block height (via the mempool backend when that
|
||||
/// app is installed). Skips cleanly when the user already defines
|
||||
/// intent_script themselves.
|
||||
/// Markers bounding the seeded block in configuration.yaml. The begin marker
|
||||
/// doubles as the legacy marker (early seeds had no end marker and always sat
|
||||
/// at EOF, so legacy upgrade = replace from begin marker to end of file).
|
||||
const VOICE_MARKER: &str = "# --- archipelago pine voice (seeded) ---";
|
||||
const VOICE_END_MARKER: &str = "# --- archipelago pine voice (end) ---";
|
||||
|
||||
/// Read the pine status token (minting it on first use, 0600). The same
|
||||
/// token gates the sensitive tier of `/api/pine/status` (see
|
||||
/// `api::rpc::pine_status`), so seeding it into HA's REST sensor config is
|
||||
/// what lets HA — and only HA — read balances and mesh text.
|
||||
async fn ensure_status_token() -> Option<String> {
|
||||
let dir = std::path::Path::new(NODE_SECRETS_DIR);
|
||||
let path = dir.join(crate::api::rpc::pine_status::PINE_STATUS_TOKEN_FILE);
|
||||
if let Ok(existing) = tokio::fs::read_to_string(&path).await {
|
||||
let existing = existing.trim().to_string();
|
||||
if !existing.is_empty() {
|
||||
return Some(existing);
|
||||
}
|
||||
}
|
||||
if let Err(e) = tokio::fs::create_dir_all(dir).await {
|
||||
warn!("pine/HA seed: cannot create {}: {}", NODE_SECRETS_DIR, e);
|
||||
return None;
|
||||
}
|
||||
let raw: [u8; 32] = rand::random();
|
||||
let token = hex::encode(raw);
|
||||
if let Err(e) = tokio::fs::write(&path, &token).await {
|
||||
warn!("pine/HA seed: writing status token failed: {e}");
|
||||
return None;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
info!("pine/HA seed: minted pine status token");
|
||||
Some(token)
|
||||
}
|
||||
|
||||
/// The seeded configuration.yaml block: REST sensors polling the node's
|
||||
/// status endpoint (through nginx on :80) + intent_script answers.
|
||||
fn voice_config_block(token: &str) -> String {
|
||||
format!(
|
||||
r#"{VOICE_MARKER}
|
||||
rest:
|
||||
- resource: http://host.containers.internal/api/pine/status
|
||||
headers:
|
||||
Authorization: "Bearer {token}"
|
||||
scan_interval: 30
|
||||
sensor:
|
||||
- name: "Archy Block Height"
|
||||
unique_id: archy_block_height
|
||||
value_template: "{{{{ value_json.bitcoin.height }}}}"
|
||||
- name: "Archy Bitcoin Sync"
|
||||
unique_id: archy_bitcoin_sync
|
||||
unit_of_measurement: "%"
|
||||
value_template: "{{{{ value_json.bitcoin.sync_percent }}}}"
|
||||
- name: "Archy Bitcoin Peers"
|
||||
unique_id: archy_bitcoin_peers
|
||||
value_template: "{{{{ value_json.bitcoin.peers }}}}"
|
||||
- name: "Archy Mesh Peers"
|
||||
unique_id: archy_mesh_peers
|
||||
value_template: "{{{{ value_json.mesh.peers }}}}"
|
||||
- name: "Archy Lightning Balance"
|
||||
unique_id: archy_lightning_balance
|
||||
unit_of_measurement: "sats"
|
||||
value_template: "{{{{ (value_json.lightning or {{}}).get('channel_balance_sats') }}}}"
|
||||
- name: "Archy Onchain Balance"
|
||||
unique_id: archy_onchain_balance
|
||||
unit_of_measurement: "sats"
|
||||
value_template: "{{{{ (value_json.lightning or {{}}).get('balance_sats') }}}}"
|
||||
- name: "Archy Mesh Message"
|
||||
unique_id: archy_mesh_message
|
||||
value_template: "{{{{ (value_json.mesh_message or {{}}).get('id') }}}}"
|
||||
json_attributes_path: "$.mesh_message"
|
||||
json_attributes:
|
||||
- from
|
||||
- text
|
||||
- timestamp
|
||||
intent_script:
|
||||
ArchyBlockHeight:
|
||||
description: >-
|
||||
Get the current Bitcoin block height of this node. Use for any question
|
||||
about the block height, chain tip, or number of blocks.
|
||||
speech:
|
||||
text: >-
|
||||
{{% if states('sensor.archy_block_height') not in ['unknown', 'unavailable', 'None'] %}}
|
||||
The block height is {{{{ states('sensor.archy_block_height') }}}}.
|
||||
{{% else %}}
|
||||
I can't read the block height right now.
|
||||
{{% endif %}}
|
||||
ArchyPeers:
|
||||
description: >-
|
||||
Get how many peers this node is connected to (bitcoin peers and mesh
|
||||
radio peers). Use for any question about peer or connection counts.
|
||||
speech:
|
||||
text: >-
|
||||
{{% set btc = states('sensor.archy_bitcoin_peers') %}}
|
||||
{{% set mesh = states('sensor.archy_mesh_peers') %}}
|
||||
{{% if btc not in ['unknown', 'unavailable', 'None'] %}}
|
||||
The node has {{{{ btc }}}} bitcoin peers{{% if mesh not in ['unknown', 'unavailable', 'None'] and mesh | int(0) > 0 %}} and {{{{ mesh }}}} mesh peers{{% endif %}}.
|
||||
{{% else %}}
|
||||
I can't read the peer count right now.
|
||||
{{% endif %}}
|
||||
ArchySyncStatus:
|
||||
description: >-
|
||||
Get this node's Bitcoin sync status / progress percentage. Use for any
|
||||
question about whether the node or bitcoin is synced, syncing, or up to
|
||||
date.
|
||||
speech:
|
||||
text: >-
|
||||
{{% set pct = states('sensor.archy_bitcoin_sync') %}}
|
||||
{{% if pct in ['unknown', 'unavailable', 'None'] %}}
|
||||
I can't read the sync status right now.
|
||||
{{% elif pct | float(0) >= 99.99 %}}
|
||||
The node is fully synced at block {{{{ states('sensor.archy_block_height') }}}}.
|
||||
{{% else %}}
|
||||
Bitcoin is {{{{ pct }}}} percent synced.
|
||||
{{% endif %}}
|
||||
ArchyLightningBalance:
|
||||
description: >-
|
||||
Get the Lightning wallet balance of this node in sats. Use for any
|
||||
question about the lightning balance, wallet balance, or how many sats
|
||||
are available.
|
||||
speech:
|
||||
text: >-
|
||||
{{% set ln = states('sensor.archy_lightning_balance') %}}
|
||||
{{% if ln not in ['unknown', 'unavailable', 'None'] %}}
|
||||
Your Lightning balance is {{{{ ln }}}} sats.
|
||||
{{% else %}}
|
||||
I can't read the Lightning balance right now. Is Lightning set up on this node?
|
||||
{{% endif %}}
|
||||
{VOICE_END_MARKER}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
const VOICE_SENTENCES: &str = r#"language: "en"
|
||||
intents:
|
||||
ArchyBlockHeight:
|
||||
data:
|
||||
- sentences:
|
||||
- "what's the [current] block height"
|
||||
- "what is the [current] block height"
|
||||
- "[current] block height"
|
||||
- "how many blocks [are there]"
|
||||
ArchyPeers:
|
||||
data:
|
||||
- sentences:
|
||||
- "how many peers [do I have]"
|
||||
- "how many peers (is|are) [the node] connected to"
|
||||
- "[node] peer count"
|
||||
ArchySyncStatus:
|
||||
data:
|
||||
- sentences:
|
||||
- "is (the node|bitcoin) [fully] synced"
|
||||
- "[bitcoin] sync (status|progress|percent|percentage)"
|
||||
- "how synced is (the node|bitcoin)"
|
||||
ArchyLightningBalance:
|
||||
data:
|
||||
- sentences:
|
||||
- "what's my lightning balance"
|
||||
- "what is my lightning balance"
|
||||
- "lightning balance"
|
||||
- "how many sats (do I have|are in my wallet)"
|
||||
"#;
|
||||
|
||||
/// Seed "ask Archy" node-info voice intents: custom sentences + a bounded
|
||||
/// intent_script/rest block in configuration.yaml. Upgrades earlier seeded
|
||||
/// blocks in place (including the pre-endpoint legacy block that ended at
|
||||
/// EOF); skips cleanly when the user defines intent_script/rest themselves
|
||||
/// outside our markers.
|
||||
async fn seed_voice_intents() -> bool {
|
||||
let config_dir = std::path::Path::new("/var/lib/archipelago/home-assistant");
|
||||
let config_dir = std::path::Path::new(HA_CONFIG_DIR);
|
||||
let config_yaml = config_dir.join("configuration.yaml");
|
||||
|
||||
let existing = tokio::fs::read_to_string(&config_yaml)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if existing.contains(VOICE_MARKER) {
|
||||
let Some(token) = ensure_status_token().await else {
|
||||
return false;
|
||||
}
|
||||
if existing.contains("intent_script:") || existing.contains("\nrest:") {
|
||||
warn!("pine/HA seed: configuration.yaml already defines intent_script/rest — skipping voice intents");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let desired_block = voice_config_block(&token);
|
||||
|
||||
// Sentences file (its own dir, no key-collision risk).
|
||||
// Sentences file (its own dir, no key-collision risk) — keep in sync.
|
||||
let sentences_dir = config_dir.join("custom_sentences/en");
|
||||
if let Err(e) = tokio::fs::create_dir_all(&sentences_dir).await {
|
||||
warn!(
|
||||
@ -76,55 +259,52 @@ async fn seed_voice_intents() -> bool {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let sentences = r#"language: "en"
|
||||
intents:
|
||||
ArchyBlockHeight:
|
||||
data:
|
||||
- sentences:
|
||||
- "what's the [current] block height"
|
||||
- "what is the [current] block height"
|
||||
- "[current] block height"
|
||||
- "how many blocks [are there]"
|
||||
"#;
|
||||
if let Err(e) = tokio::fs::write(sentences_dir.join("archy.yaml"), sentences).await {
|
||||
let sentences_path = sentences_dir.join("archy.yaml");
|
||||
let sentences_changed = tokio::fs::read_to_string(&sentences_path)
|
||||
.await
|
||||
.map(|cur| cur != VOICE_SENTENCES)
|
||||
.unwrap_or(true);
|
||||
if sentences_changed {
|
||||
if let Err(e) = tokio::fs::write(&sentences_path, VOICE_SENTENCES).await {
|
||||
warn!("pine/HA seed: writing custom sentences failed: {e}");
|
||||
return false;
|
||||
}
|
||||
|
||||
let mempool_installed = tokio::fs::metadata("/var/lib/archipelago/mempool")
|
||||
.await
|
||||
.is_ok()
|
||||
|| tokio::fs::metadata("/var/lib/archipelago/data/mempool")
|
||||
.await
|
||||
.is_ok();
|
||||
let mut block = format!("\n{VOICE_MARKER}\n");
|
||||
if mempool_installed {
|
||||
block.push_str(
|
||||
r#"rest:
|
||||
- resource: http://host.containers.internal:8999/api/blocks/tip/height
|
||||
scan_interval: 60
|
||||
sensor:
|
||||
- name: "Archy Block Height"
|
||||
unique_id: archy_block_height
|
||||
value_template: "{{ value }}"
|
||||
"#,
|
||||
);
|
||||
}
|
||||
block.push_str(
|
||||
r#"intent_script:
|
||||
ArchyBlockHeight:
|
||||
speech:
|
||||
text: >-
|
||||
{% if states('sensor.archy_block_height') not in ['unknown', 'unavailable'] %}
|
||||
The block height is {{ states('sensor.archy_block_height') }}.
|
||||
{% else %}
|
||||
I can't read the block height right now.
|
||||
{% endif %}
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut merged = existing;
|
||||
merged.push_str(&block);
|
||||
let existing = tokio::fs::read_to_string(&config_yaml)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let merged = if let Some(begin) = existing.find(VOICE_MARKER) {
|
||||
if let Some(end) = existing.find(VOICE_END_MARKER) {
|
||||
// Bounded block — replace when stale.
|
||||
let after = end + VOICE_END_MARKER.len();
|
||||
// Consume the trailing newline of the old block, if present.
|
||||
let after = after + existing[after..].starts_with('\n') as usize;
|
||||
let current = &existing[begin..after];
|
||||
if current == desired_block {
|
||||
return sentences_changed;
|
||||
}
|
||||
format!(
|
||||
"{}{}{}",
|
||||
&existing[..begin],
|
||||
desired_block,
|
||||
&existing[after..]
|
||||
)
|
||||
} else {
|
||||
// Legacy block (no end marker) — it was always appended at EOF,
|
||||
// possibly hand-edited on the node (e.g. the interim bitcoind
|
||||
// socat sensor). Replace marker..EOF wholesale.
|
||||
format!("{}{}", &existing[..begin], desired_block)
|
||||
}
|
||||
} else {
|
||||
if existing.contains("intent_script:") || existing.contains("\nrest:") {
|
||||
warn!("pine/HA seed: configuration.yaml already defines intent_script/rest — skipping voice intents");
|
||||
return sentences_changed;
|
||||
}
|
||||
format!("{existing}\n{desired_block}")
|
||||
};
|
||||
|
||||
let tmp = config_yaml.with_extension("yaml.tmp-seed");
|
||||
if let Err(e) = tokio::fs::write(&tmp, &merged).await {
|
||||
warn!("pine/HA seed: writing configuration.yaml failed: {e}");
|
||||
@ -134,17 +314,190 @@ intents:
|
||||
warn!("pine/HA seed: replacing configuration.yaml failed: {e}");
|
||||
return false;
|
||||
}
|
||||
info!(
|
||||
"pine/HA seed: voice intents seeded (block height{})",
|
||||
if mempool_installed {
|
||||
" + mempool REST sensor"
|
||||
} else {
|
||||
", sensor pending mempool install"
|
||||
}
|
||||
);
|
||||
info!("pine/HA seed: voice intents + node-status sensors seeded");
|
||||
true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mesh-message announcements (automations.yaml)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MESH_ANNOUNCE_AUTOMATION_ID: &str = "archy_mesh_announce";
|
||||
|
||||
/// Announce new mesh messages on every Assist satellite. Only seeded into an
|
||||
/// empty/missing automations.yaml — merging into user-authored YAML risks
|
||||
/// mangling it, and anyone with existing automations can paste this one from
|
||||
/// the docs. Trigger conditions skip HA-restart transitions (unknown -> id).
|
||||
const MESH_ANNOUNCE_AUTOMATION: &str = r#"- id: archy_mesh_announce
|
||||
alias: Announce mesh messages on Pine
|
||||
description: Speak new mesh messages on the Pine speaker (seeded by Archipelago)
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: sensor.archy_mesh_message
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ trigger.from_state is not none
|
||||
and trigger.from_state.state not in ['unknown', 'unavailable', 'None']
|
||||
and trigger.to_state.state not in ['unknown', 'unavailable', 'None']
|
||||
and trigger.from_state.state != trigger.to_state.state }}
|
||||
actions:
|
||||
- variables:
|
||||
satellites: "{{ states.assist_satellite | map(attribute='entity_id') | list }}"
|
||||
- condition: template
|
||||
value_template: "{{ satellites | count > 0 }}"
|
||||
- action: assist_satellite.announce
|
||||
target:
|
||||
entity_id: "{{ satellites }}"
|
||||
data:
|
||||
message: >-
|
||||
New mesh message from {{ state_attr('sensor.archy_mesh_message', 'from') or 'unknown' }}:
|
||||
{{ state_attr('sensor.archy_mesh_message', 'text') or '' }}
|
||||
mode: queued
|
||||
max: 5
|
||||
"#;
|
||||
|
||||
async fn seed_mesh_announce_automation() -> bool {
|
||||
let path = std::path::Path::new(HA_CONFIG_DIR).join("automations.yaml");
|
||||
let existing = tokio::fs::read_to_string(&path).await.unwrap_or_default();
|
||||
if existing.contains(MESH_ANNOUNCE_AUTOMATION_ID) {
|
||||
return false;
|
||||
}
|
||||
let trimmed = existing.trim();
|
||||
if !(trimmed.is_empty() || trimmed == "[]") {
|
||||
warn!("pine/HA seed: automations.yaml has user content — skipping mesh announce seed");
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(&path, MESH_ANNOUNCE_AUTOMATION).await {
|
||||
warn!("pine/HA seed: writing automations.yaml failed: {e}");
|
||||
return false;
|
||||
}
|
||||
info!("pine/HA seed: mesh-message announce automation seeded");
|
||||
true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claude conversation agent (anthropic config entry)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ClaudeSeed {
|
||||
/// An anthropic entry exists (seeded now or already there) — the
|
||||
/// pipeline may point at the Claude conversation entity.
|
||||
available: bool,
|
||||
/// This call wrote the store.
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
/// Seed HA's `anthropic` integration from the node's shared Claude API key
|
||||
/// (`secrets/claude-api-key`, the same key the mesh `!ai` assistant uses).
|
||||
/// Skips when the key is absent or an anthropic entry already exists.
|
||||
async fn seed_claude_conversation(storage: &std::path::Path) -> ClaudeSeed {
|
||||
let none = ClaudeSeed {
|
||||
available: false,
|
||||
changed: false,
|
||||
};
|
||||
let key = match tokio::fs::read_to_string(
|
||||
std::path::Path::new(NODE_SECRETS_DIR).join("claude-api-key"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(k) if !k.trim().is_empty() => k.trim().to_string(),
|
||||
_ => {
|
||||
info!("pine/HA seed: no claude-api-key on node — skipping Claude conversation agent");
|
||||
return none;
|
||||
}
|
||||
};
|
||||
|
||||
let path = storage.join("core.config_entries");
|
||||
let mut store = if tokio::fs::metadata(&path).await.is_ok() {
|
||||
match read_store(&path).await {
|
||||
Some(v) => v,
|
||||
None => return none,
|
||||
}
|
||||
} else {
|
||||
json!({
|
||||
"version": 1,
|
||||
"minor_version": 1,
|
||||
"key": "core.config_entries",
|
||||
"data": { "entries": [] }
|
||||
})
|
||||
};
|
||||
|
||||
let Some(entries) = store
|
||||
.get_mut("data")
|
||||
.and_then(|d| d.get_mut("entries"))
|
||||
.and_then(|e| e.as_array_mut())
|
||||
else {
|
||||
warn!("pine/HA seed: core.config_entries has unexpected shape, leaving untouched");
|
||||
return none;
|
||||
};
|
||||
|
||||
if entries
|
||||
.iter()
|
||||
.any(|e| e.get("domain").and_then(Value::as_str) == Some("anthropic"))
|
||||
{
|
||||
return ClaudeSeed {
|
||||
available: true,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
let id = |raw: [u8; 16]| hex::encode(raw);
|
||||
// Shape mirrors what HA 2026.7's anthropic config flow creates (entry
|
||||
// version 2.4 with conversation + ai_task subentries); HA fills any
|
||||
// missing bookkeeping fields on load.
|
||||
entries.push(json!({
|
||||
"entry_id": id(rand::random()),
|
||||
"version": 2,
|
||||
"minor_version": 4,
|
||||
"domain": "anthropic",
|
||||
"title": "Claude",
|
||||
"data": { "api_key": key },
|
||||
"options": {},
|
||||
"pref_disable_new_entities": false,
|
||||
"pref_disable_polling": false,
|
||||
"source": "user",
|
||||
"unique_id": null,
|
||||
"disabled_by": null,
|
||||
"subentries": [
|
||||
{
|
||||
"subentry_id": id(rand::random()),
|
||||
"subentry_type": "conversation",
|
||||
"title": "Claude conversation",
|
||||
"unique_id": null,
|
||||
"data": {
|
||||
"recommended": true,
|
||||
"llm_hass_api": ["assist"],
|
||||
// Steers fuzzy phrasings onto the local Archy* intent
|
||||
// tools (cheap + exact) instead of free-form answers,
|
||||
// and keeps replies speaker-length.
|
||||
"prompt": "You are Archy, the voice of this Archipelago Bitcoin node, speaking through a smart speaker. Answers are spoken aloud: keep them to one or two short sentences, no markdown, no lists. When the user asks about the node — block height, sync status, peers, balances — call the matching Archy tool rather than answering from memory, even if the phrasing is loose. Only answer directly when no tool fits."
|
||||
}
|
||||
},
|
||||
{
|
||||
"subentry_id": id(rand::random()),
|
||||
"subentry_type": "ai_task_data",
|
||||
"title": "Claude AI Task",
|
||||
"unique_id": null,
|
||||
"data": { "recommended": true }
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
if !write_store(&path, &store).await {
|
||||
return none;
|
||||
}
|
||||
info!("pine/HA seed: added Claude conversation agent (anthropic config entry)");
|
||||
ClaudeSeed {
|
||||
available: true,
|
||||
changed: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wyoming config entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ensure `core.config_entries` has a `wyoming` entry per Pine engine.
|
||||
async fn seed_wyoming_config_entries(storage: &std::path::Path) -> bool {
|
||||
let path = storage.join("core.config_entries");
|
||||
@ -214,11 +567,17 @@ async fn seed_wyoming_config_entries(storage: &std::path::Path) -> bool {
|
||||
added
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assist pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ensure an Assist pipeline uses the Pine engines. Creates the store when
|
||||
/// missing; when it exists, only repairs the pre-2024.6 conversation-agent id
|
||||
/// (`homeassistant` -> `conversation.home_assistant`), which modern HA no
|
||||
/// longer resolves (pipelines error with `intent-not-supported` otherwise).
|
||||
async fn seed_assist_pipeline(storage: &std::path::Path) -> bool {
|
||||
/// missing; when it exists, repairs the pre-2024.6 conversation-agent id
|
||||
/// (`homeassistant` -> `conversation.home_assistant`) and — when a Claude
|
||||
/// agent is available — upgrades pipelines still on the default local agent
|
||||
/// to Claude with `prefer_local_intents: true` (node intents stay local/free,
|
||||
/// everything else goes to Claude).
|
||||
async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<&str>) -> bool {
|
||||
let path = storage.join("assist_pipeline.pipelines");
|
||||
|
||||
if let Some(mut store) = read_store(&path).await {
|
||||
@ -229,15 +588,25 @@ async fn seed_assist_pipeline(storage: &std::path::Path) -> bool {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut repaired = false;
|
||||
let mut changed = false;
|
||||
for item in items.iter_mut() {
|
||||
if item.get("conversation_engine").and_then(Value::as_str) == Some("homeassistant") {
|
||||
item["conversation_engine"] = json!("conversation.home_assistant");
|
||||
repaired = true;
|
||||
info!("pine/HA seed: repaired legacy conversation agent id in Assist pipeline");
|
||||
changed = true;
|
||||
}
|
||||
if let Some(engine) = claude_entity {
|
||||
if item.get("conversation_engine").and_then(Value::as_str)
|
||||
== Some("conversation.home_assistant")
|
||||
{
|
||||
item["conversation_engine"] = json!(engine);
|
||||
item["prefer_local_intents"] = json!(true);
|
||||
info!("pine/HA seed: pipeline upgraded to Claude (local intents preferred)");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if repaired {
|
||||
info!("pine/HA seed: repaired legacy conversation agent id in Assist pipelines");
|
||||
}
|
||||
if changed {
|
||||
return write_store(&path, &store).await;
|
||||
}
|
||||
return false;
|
||||
@ -257,11 +626,12 @@ async fn seed_assist_pipeline(storage: &std::path::Path) -> bool {
|
||||
"key": "assist_pipeline.pipelines",
|
||||
"data": {
|
||||
"items": [{
|
||||
"conversation_engine": "conversation.home_assistant",
|
||||
"conversation_engine": claude_entity.unwrap_or("conversation.home_assistant"),
|
||||
"conversation_language": "en",
|
||||
"id": id,
|
||||
"language": "en",
|
||||
"name": "Pine (local)",
|
||||
"prefer_local_intents": claude_entity.is_some(),
|
||||
"stt_engine": "stt.faster_whisper",
|
||||
"stt_language": "en",
|
||||
"tts_engine": "tts.piper",
|
||||
@ -280,6 +650,10 @@ async fn seed_assist_pipeline(storage: &std::path::Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Presence probes + HA restart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// True when the Pine Wyoming engines are installed on this node (their shared
|
||||
/// data dir is created by the stack install).
|
||||
pub(super) async fn pine_engines_installed() -> bool {
|
||||
@ -291,9 +665,7 @@ pub(super) async fn pine_engines_installed() -> bool {
|
||||
/// True when Home Assistant has a config dir on this node (installed at some
|
||||
/// point; .storage may not exist until first boot, which seeding handles).
|
||||
pub(super) async fn home_assistant_installed() -> bool {
|
||||
tokio::fs::metadata("/var/lib/archipelago/home-assistant")
|
||||
.await
|
||||
.is_ok()
|
||||
tokio::fs::metadata(HA_CONFIG_DIR).await.is_ok()
|
||||
}
|
||||
|
||||
/// Restart the HA container so it loads the seeded storage. Best-effort: when
|
||||
@ -368,3 +740,44 @@ async fn write_store(path: &std::path::Path, store: &Value) -> bool {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn voice_block_is_bounded_and_carries_token() {
|
||||
let block = voice_config_block("deadbeef");
|
||||
assert!(block.starts_with(VOICE_MARKER));
|
||||
assert!(block.trim_end().ends_with(VOICE_END_MARKER));
|
||||
assert!(block.contains("Bearer deadbeef"));
|
||||
// Every intent the sentences file declares has a script answer.
|
||||
for intent in [
|
||||
"ArchyBlockHeight",
|
||||
"ArchyPeers",
|
||||
"ArchySyncStatus",
|
||||
"ArchyLightningBalance",
|
||||
] {
|
||||
assert!(block.contains(intent), "missing intent {intent}");
|
||||
assert!(
|
||||
VOICE_SENTENCES.contains(intent),
|
||||
"missing sentences {intent}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_block_replacement_drops_old_content() {
|
||||
// Simulates the pre-endpoint node state: legacy marker + hand-edited
|
||||
// socat/bitcoind sensor block at EOF.
|
||||
let existing = format!(
|
||||
"default_config:\n\n{VOICE_MARKER}\nrest:\n - resource: http://host.containers.internal:18332/\n username: archipelago\n"
|
||||
);
|
||||
let begin = existing.find(VOICE_MARKER).unwrap();
|
||||
let desired = voice_config_block("tok");
|
||||
let merged = format!("{}{}", &existing[..begin], desired);
|
||||
assert!(!merged.contains("18332"));
|
||||
assert!(merged.contains("Bearer tok"));
|
||||
assert!(merged.starts_with("default_config:"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -750,7 +750,7 @@ fn pine_stack_app_ids() -> &'static [&'static str] {
|
||||
// then the user-facing launcher ("pine", the nginx that serves the setup /
|
||||
// status page + is the Open target). Mirrors the pine startup_order in
|
||||
// dependencies.rs + the stack member table in app_ops.rs.
|
||||
&["pine-whisper", "pine-piper", "pine"]
|
||||
&["pine-whisper", "pine-piper", "pine-openwakeword", "pine"]
|
||||
}
|
||||
|
||||
fn indeedhub_stack_app_ids() -> &'static [&'static str] {
|
||||
@ -1938,8 +1938,12 @@ impl RpcHandler {
|
||||
return Ok(orchestrated);
|
||||
}
|
||||
|
||||
if let Some(adopted) =
|
||||
adopt_stack_if_exists("pine", "pine", &["pine-whisper", "pine-piper", "pine"]).await?
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"pine",
|
||||
"pine",
|
||||
&["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Self::seed_pine_ha_defaults().await;
|
||||
return Ok(adopted);
|
||||
|
||||
156
core/archipelago/src/api/rpc/pine_status.rs
Normal file
156
core/archipelago/src/api/rpc/pine_status.rs
Normal file
@ -0,0 +1,156 @@
|
||||
//! Node-status JSON for the Pine voice stack (`GET /api/pine/status`).
|
||||
//!
|
||||
//! Two tiers in one endpoint:
|
||||
//! - **Public** (no credentials): version, uptime, bitcoin height / sync /
|
||||
//! peer count, mesh peer count. Feeds the Pine launcher page's live status
|
||||
//! card — nothing here a LAN visitor couldn't already infer from the
|
||||
//! existing unauthenticated `/bitcoin-status`.
|
||||
//! - **Token** (`Authorization: Bearer <pine-status-token>`): adds Lightning
|
||||
//! balances and the most recent received mesh text message. Feeds the
|
||||
//! Home Assistant REST sensors seeded by `package::pine_ha` — the seeder
|
||||
//! mints the token into `data_dir/secrets/pine-status-token` (0600) and
|
||||
//! embeds it in HA's configuration, so only HA (and the node owner) can
|
||||
//! read balances or message text.
|
||||
//!
|
||||
//! Replaces the interim per-node socat forwarder + bitcoind-RPC-credentials-
|
||||
//! in-configuration.yaml stopgap used before this endpoint existed.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::mesh::types::MessageDirection;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// File under `data_dir/secrets/` holding the bearer token that unlocks the
|
||||
/// sensitive tier. Written by the pine/HA seeder, read per-request here.
|
||||
pub(crate) const PINE_STATUS_TOKEN_FILE: &str = "pine-status-token";
|
||||
|
||||
/// Constant-time-ish equality — avoids early-exit timing on the token compare.
|
||||
fn token_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.bytes()
|
||||
.zip(b.bytes())
|
||||
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||
== 0
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// True when `presented` matches the on-disk pine status token. Missing or
|
||||
/// empty token file means the sensitive tier is locked (seeder not run).
|
||||
pub(crate) async fn pine_status_token_ok(&self, presented: &str) -> bool {
|
||||
let path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("secrets")
|
||||
.join(PINE_STATUS_TOKEN_FILE);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(tok) => {
|
||||
let tok = tok.trim();
|
||||
!tok.is_empty() && token_eq(tok, presented.trim())
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble the status document. `authorized` selects the token tier.
|
||||
/// Every sub-source is best-effort: a dead bitcoind/LND/mesh never turns
|
||||
/// the endpoint into an error — the field just reports what it can.
|
||||
pub(crate) async fn pine_status_json(&self, authorized: bool) -> Value {
|
||||
let bs = crate::bitcoin_status::get_bitcoin_status().await;
|
||||
let bitcoin = {
|
||||
let info = bs.blockchain_info.as_ref();
|
||||
let height = info.and_then(|i| i.get("blocks")).and_then(Value::as_u64);
|
||||
let progress = info
|
||||
.and_then(|i| i.get("verificationprogress"))
|
||||
.and_then(Value::as_f64);
|
||||
let ibd = info
|
||||
.and_then(|i| i.get("initialblockdownload"))
|
||||
.and_then(Value::as_bool);
|
||||
let peers = bs
|
||||
.network_info
|
||||
.as_ref()
|
||||
.and_then(|n| n.get("connections"))
|
||||
.and_then(Value::as_u64);
|
||||
json!({
|
||||
"ok": bs.ok,
|
||||
"height": height,
|
||||
"sync_percent": progress.map(|p| (p * 10000.0).round() / 100.0),
|
||||
"ibd": ibd,
|
||||
"peers": peers,
|
||||
})
|
||||
};
|
||||
|
||||
let (mesh, mesh_message) = {
|
||||
let guard = self.mesh_service.read().await;
|
||||
match guard.as_ref() {
|
||||
Some(svc) => {
|
||||
let status = svc.status().await;
|
||||
let latest = if authorized {
|
||||
svc.messages(None)
|
||||
.await
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| {
|
||||
m.direction == MessageDirection::Received
|
||||
&& m.message_type == "text"
|
||||
})
|
||||
.map(|m| {
|
||||
json!({
|
||||
"id": m.id,
|
||||
"from": m.peer_name.clone()
|
||||
.unwrap_or_else(|| format!("contact {}", m.peer_contact_id)),
|
||||
"text": m.plaintext,
|
||||
"timestamp": m.timestamp,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
json!({ "enabled": status.enabled, "peers": status.peer_count }),
|
||||
latest,
|
||||
)
|
||||
}
|
||||
None => (json!({ "enabled": false, "peers": 0 }), None),
|
||||
}
|
||||
};
|
||||
|
||||
let lightning = if authorized {
|
||||
match self.handle_lnd_getinfo().await {
|
||||
Ok(info) => json!({
|
||||
"balance_sats": info.get("balance_sats"),
|
||||
"channel_balance_sats": info.get("channel_balance_sats"),
|
||||
"active_channels": info.get("num_active_channels"),
|
||||
"synced_to_chain": info.get("synced_to_chain"),
|
||||
}),
|
||||
Err(_) => Value::Null,
|
||||
}
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
|
||||
json!({
|
||||
"ok": true,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": crate::crash_recovery::uptime_seconds(),
|
||||
"bitcoin": bitcoin,
|
||||
"mesh": mesh,
|
||||
"lightning": lightning,
|
||||
"mesh_message": mesh_message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::token_eq;
|
||||
|
||||
#[test]
|
||||
fn token_eq_matches_only_exact() {
|
||||
assert!(token_eq("abc123", "abc123"));
|
||||
assert!(!token_eq("abc123", "abc124"));
|
||||
assert!(!token_eq("abc123", "abc12"));
|
||||
assert!(!token_eq("", "x"));
|
||||
assert!(token_eq("", ""));
|
||||
}
|
||||
}
|
||||
@ -50,7 +50,7 @@ pub fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
|
||||
"pine" => &["pine-whisper", "pine-piper", "pine"],
|
||||
"pine" => &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
|
||||
// The legacy umbrella id maps to the split stack (the orchestrator's
|
||||
// umbrella alias handles this too; listing it here keeps the RPC
|
||||
// layer's fan-out explicit).
|
||||
|
||||
@ -80,6 +80,13 @@ const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles
|
||||
/// block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file.\n # Long read timeout: this path also serves full-file downloads of large\n # media (#38), which can take minutes over Tor; 120s aborted them.\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 900s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// Inserted into every server block lacking the Pine node-status proxy.
|
||||
/// `/api/pine/status` serves the Pine launcher page's live status card and
|
||||
/// the seeded Home Assistant REST sensors (the sensitive tier is gated by a
|
||||
/// bearer token at the backend, so nginx just forwards). Kept in sync with
|
||||
/// the canonical block in image-recipe/configs/nginx-archipelago.conf.
|
||||
const NGINX_PINE_STATUS_BLOCK: &str = "\n # Pine node status — live node facts for the Pine launcher page and the\n # seeded Home Assistant sensors. Sensitive fields are token-gated at the\n # backend; nginx only forwards.\n location /api/pine/status {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Authorization $http_authorization;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 10s;\n proxy_read_timeout 15s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
|
||||
|
||||
/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
|
||||
/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted
|
||||
/// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell
|
||||
@ -854,6 +861,7 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
&& !content.contains("location /bitcoin-status");
|
||||
let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/");
|
||||
let missing_peer_content = has_lnd_anchor && !content.contains("location /api/peer-content");
|
||||
let missing_pine_status = has_lnd_anchor && !content.contains("location /api/pine/status");
|
||||
let has_lnd_dup_cors = content.contains(NGINX_LND_DUP_CORS);
|
||||
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
|
||||
let needs_fedimint_css = content.contains("location /app/fedimint/")
|
||||
@ -862,6 +870,7 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
&& !missing_bitcoin_status
|
||||
&& !missing_lnd_proxy
|
||||
&& !missing_peer_content
|
||||
&& !missing_pine_status
|
||||
&& !has_lnd_dup_cors
|
||||
&& !needs_fedimint_css
|
||||
{
|
||||
@ -907,6 +916,22 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
if missing_pine_status {
|
||||
// Same anchoring as the LND proxy: prepend to every server block that
|
||||
// proxies to the backend.
|
||||
let anchor = if patched.contains(" location /lnd-connect-info {") {
|
||||
" location /lnd-connect-info {"
|
||||
} else {
|
||||
" location /electrs-status {"
|
||||
};
|
||||
if patched.contains(anchor) {
|
||||
let replacement = format!("{}{}", NGINX_PINE_STATUS_BLOCK, anchor);
|
||||
patched = patched.replace(anchor, &replacement);
|
||||
} else {
|
||||
warn!("nginx conf missing anchor — skipping /api/pine/status patch");
|
||||
}
|
||||
}
|
||||
|
||||
if missing_peer_content {
|
||||
// Same anchoring as the LND proxy: prepend the block to every server
|
||||
// block so /api/peer-content/* reaches the backend instead of the SPA.
|
||||
|
||||
@ -59,6 +59,7 @@ impl DockerPackageScanner {
|
||||
"netbird-dashboard",
|
||||
"pine-whisper",
|
||||
"pine-piper",
|
||||
"pine-openwakeword",
|
||||
"buildx_buildkit_default",
|
||||
];
|
||||
|
||||
|
||||
@ -768,6 +768,7 @@ fn should_auto_start_stopped_container(name: &str, include_stack_members: bool)
|
||||
| "netbird"
|
||||
| "pine-whisper"
|
||||
| "pine-piper"
|
||||
| "pine-openwakeword"
|
||||
| "pine"
|
||||
)
|
||||
}
|
||||
@ -836,9 +837,10 @@ fn stack_recovery_specs() -> &'static [StackRecoverySpec] {
|
||||
aliases: &[
|
||||
("pine-whisper", "pine-whisper"),
|
||||
("pine-piper", "pine-piper"),
|
||||
("pine-openwakeword", "pine-openwakeword"),
|
||||
("pine", "pine"),
|
||||
],
|
||||
containers: &["pine-whisper", "pine-piper", "pine"],
|
||||
containers: &["pine-whisper", "pine-piper", "pine-openwakeword", "pine"],
|
||||
// The launcher only depends on the two engines; whisper is the
|
||||
// heaviest/first member, so treat it as the stack anchor (its
|
||||
// presence means the stack was really installed, not orphan debris).
|
||||
|
||||
@ -236,6 +236,22 @@ server {
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
# Pine node status — live node facts for the Pine launcher page and the
|
||||
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
|
||||
# backend; nginx only forwards.
|
||||
location /api/pine/status {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 15s;
|
||||
proxy_send_timeout 5s;
|
||||
error_page 502 503 = @backend_unavailable;
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
location /lnd-connect-info {
|
||||
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
|
||||
proxy_http_version 1.1;
|
||||
@ -1022,6 +1038,22 @@ server {
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
# Pine node status — live node facts for the Pine launcher page and the
|
||||
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
|
||||
# backend; nginx only forwards.
|
||||
location /api/pine/status {
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 15s;
|
||||
proxy_send_timeout 5s;
|
||||
error_page 502 503 = @backend_unavailable;
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
location /lnd-connect-info {
|
||||
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user