From 169bf77de67e36b3fa71ad66a1d75ca313a945c9 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 30 Sep 2026 11:52:19 -0400 Subject: [PATCH] Add headless Angor services and shared-index install guard --- app-catalog/catalog.json | 29 +++ apps/angor-indexer/README.md | 57 +++++ apps/angor-indexer/container/Dockerfile | 6 + apps/angor-indexer/container/entrypoint.sh | 12 + apps/angor-indexer/container/nginx.conf | 63 +++++ apps/angor-indexer/manifest.yml | 68 ++++++ apps/angor-relay/README.md | 21 ++ apps/angor-relay/manifest.yml | 223 ++++++++++++++++++ .../src/api/rpc/package/dependencies.rs | 11 + .../src/api/rpc/package/install.rs | 3 + .../src/container/prod_orchestrator.rs | 52 +++- core/archipelago/src/fips/app_ports.rs | 42 +++- core/container/src/manifest.rs | 25 ++ docs/app-developer-guide.md | 10 + docs/app-manifest-spec.md | 12 + docs/next-release-20260930.md | 29 ++- .../public/assets/img/app-icons/angor.svg | 7 + neode-ui/public/catalog.json | 29 +++ .../appSession/generatedAppSessionConfig.ts | 2 + .../views/apps/__tests__/appsConfig.test.ts | 9 + .../__tests__/curatedApps.portAuth.test.ts | 11 +- scripts/generate-app-catalog.py | 21 +- tests/lifecycle/angor-proxy-check.py | 55 +++++ tests/regression/angor-service-metadata.py | 42 ++++ 24 files changed, 828 insertions(+), 11 deletions(-) create mode 100644 apps/angor-indexer/README.md create mode 100644 apps/angor-indexer/container/Dockerfile create mode 100755 apps/angor-indexer/container/entrypoint.sh create mode 100644 apps/angor-indexer/container/nginx.conf create mode 100644 apps/angor-indexer/manifest.yml create mode 100644 apps/angor-relay/README.md create mode 100644 apps/angor-relay/manifest.yml create mode 100644 neode-ui/public/assets/img/app-icons/angor.svg create mode 100644 tests/lifecycle/angor-proxy-check.py create mode 100644 tests/regression/angor-service-metadata.py diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index 8b9bfeed..f07af908 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -644,6 +644,35 @@ "/var/lib/archipelago/vaultwarden:/data" ] } + }, + { + "id": "angor-indexer", + "title": "Angor Indexer", + "version": "1.0.1", + "description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.", + "dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1", + "author": "Angor / Archipelago", + "requires": [ + "Mempool API", + "Unpruned Bitcoin" + ], + "category": "money", + "tier": "optional", + "icon": "/assets/img/app-icons/angor.svg", + "repoUrl": "https://github.com/block-core/angor" + }, + { + "id": "angor-relay", + "title": "Angor Relay", + "version": "1.1.2", + "description": "Optional dedicated Nostr relay for Angor project metadata. Separate storage and access settings keep the node’s internal relay private. Add this service’s address to Angor’s relay settings; use WSS for browser clients.", + "dockerImage": "source.archipelago-foundation.org/chaum/angor-relay:1.1.2", + "author": "Angor / Archipelago", + "requires": [], + "category": "nostr", + "tier": "optional", + "icon": "/assets/img/app-icons/angor.svg", + "repoUrl": "https://github.com/hoytech/strfry" } ] } diff --git a/apps/angor-indexer/README.md b/apps/angor-indexer/README.md new file mode 100644 index 00000000..5db43976 --- /dev/null +++ b/apps/angor-indexer/README.md @@ -0,0 +1,57 @@ +# Angor Indexer + +Headless mainnet API endpoint for Angor. The service reuses this node's Mempool +backend and Electrum index instead of creating a second blockchain database. +An unpruned, fully synced Bitcoin node is required. Installing against a pruned +node must show the existing archival-node requirement; it must never silently +unprune or replace its Bitcoin data. + +## Connect Angor + +Install **Angor Indexer** in the store. Its API appears under **Services**. +In Angor settings, use `http://:8998/` as the custom indexer origin. +The `/health` endpoint reports readiness against Mempool's indexed block height; +it returns 503 while that backend is unavailable. Index building may take time. + +Browser clients require a reachable HTTPS origin with a trusted certificate. +Configure your HTTPS reverse proxy to forward to port 8998, then use that HTTPS +origin in Angor. Do not disable browser TLS checks. The API supports both +`/api/v1/` and `/api/` paths, transaction broadcast, and CORS without cookies. + +This endpoint intentionally exposes public blockchain queries and transaction +broadcast through the app gate without dashboard-cookie login. It has no Bitcoin +RPC password, wallet keys, or persistent wallet data. The backend stays on the +managed container network; its private port does not become publicly exposed. +You can change network access using the node's normal access controls. + +## Relay + +A relay is optional. Angor can continue using its configured external relays. +Install **Angor Relay** separately to host project metadata locally, then add +`ws://:8091/` in Angor, or a trusted `wss://` proxy origin for browser +clients. Its storage and configuration are separate from the node's internal +relay; installing or uninstalling it does not change the internal relay. + +## Packaging + +Build the pinned image with: + +``` +podman build -t source.archipelago-foundation.org/chaum/angor-indexer:1.0.1 apps/angor-indexer/container +``` + +The image runs as UID 101 with a read-only root filesystem and no capabilities. +Only temporary nginx state is writable. Runtime DNS is read from resolv.conf so +Mempool recreation does not require editing IP addresses or restarting this app. +No app-specific Rust installer is required. + +Source documentation: [Angor's official deployment guide](https://github.com/block-core/angor/blob/869dd43cf38332dd7128a284a6bf4c1cac44c1a7/docker/DEPLOY-INDEXER-AND-RELAY.md). +The unmodified icon comes from [angor.io/images/logo-text.svg](https://angor.io/images/logo-text.svg), retrieved 2026-09-30. + +Tests and release acceptance are recorded in the next-release checklist. The +health probe establishes backend availability, not a guarantee that every +address query is indexed at the latest Bitcoin tip. + +Install Mempool Explorer first. The declarative `install_prerequisites` check +refuses a new adapter installation if its Mempool API component is absent, before +creating an installed-app record. It does not install or resync Bitcoin for you. diff --git a/apps/angor-indexer/container/Dockerfile b/apps/angor-indexer/container/Dockerfile new file mode 100644 index 00000000..321e8ab1 --- /dev/null +++ b/apps/angor-indexer/container/Dockerfile @@ -0,0 +1,6 @@ +FROM docker.io/library/nginx:1.31.3-alpine@sha256:1d40e3eb3bf4f138de1d67193f2aa5309fcaf343eb5ffadbf5e9439de1eb1ebb +COPY nginx.conf /etc/angor-nginx.conf.template +COPY entrypoint.sh /usr/local/bin/angor-indexer +USER 101:101 +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/angor-indexer"] diff --git a/apps/angor-indexer/container/entrypoint.sh b/apps/angor-indexer/container/entrypoint.sh new file mode 100755 index 00000000..802de72b --- /dev/null +++ b/apps/angor-indexer/container/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu +# Resolve through the container runtime's DNS, including after dependency +# recreation. Never bake a container IP into the indexer endpoint. +DNS_RESOLVER=$(awk '/^nameserver[[:space:]]/ {print $2; exit}' /etc/resolv.conf) +case "$DNS_RESOLVER" in + ''|*[!0-9a-fA-F.:]*) echo 'Container DNS resolver is unavailable' >&2; exit 1 ;; +esac +case "$DNS_RESOLVER" in *:*) DNS_RESOLVER="[$DNS_RESOLVER]" ;; esac +export DNS_RESOLVER +envsubst '${DNS_RESOLVER}' < /etc/angor-nginx.conf.template > /tmp/nginx.conf +exec nginx -c /tmp/nginx.conf -g 'daemon off;' diff --git a/apps/angor-indexer/container/nginx.conf b/apps/angor-indexer/container/nginx.conf new file mode 100644 index 00000000..19a6a957 --- /dev/null +++ b/apps/angor-indexer/container/nginx.conf @@ -0,0 +1,63 @@ +worker_processes 1; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; +events { worker_connections 512; } +http { + access_log off; + server_tokens off; + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + resolver ${DNS_RESOLVER} valid=10s ipv6=off; + upstream mempool_backend { + zone mempool_backend 64k; + server mempool-api:8999 resolve; + } + server { + listen 8080; + client_max_body_size 4m; + proxy_connect_timeout 5s; + proxy_read_timeout 60s; + proxy_send_timeout 30s; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ""; + proxy_set_header Authorization ""; + proxy_set_header Cookie ""; + proxy_hide_header Access-Control-Allow-Origin; + add_header Access-Control-Allow-Origin '*' always; + add_header Access-Control-Allow-Methods 'GET, HEAD, POST, OPTIONS' always; + add_header Access-Control-Allow-Headers 'Content-Type' always; + add_header Cache-Control 'no-store' always; + if ($request_method = OPTIONS) { return 204; } + # Mempool's backend uses /api/v1. Match its frontend's shorter /api + # surface too, without doubling already-versioned Angor URLs. + rewrite ^/api/(?!v1/)(.*)$ /api/v1/$1 last; + location = / { + default_type application/json; + return 200 '{"service":"Angor Indexer","network":"mainnet","api":"/api/v1","health":"/health"}\n'; + } + # Readiness checks the indexing backend, not this gateway's process. + location = /health { + limit_except GET { deny all; } + proxy_pass http://mempool_backend/api/v1/blocks/tip/height; + proxy_intercept_errors on; + error_page 500 502 503 504 =503 @waiting; + } + location @waiting { + default_type application/json; + return 503 '{"status":"waiting","message":"Waiting for Bitcoin and Mempool indexing"}\n'; + } + location ~ ^/api/(v1/)?tx$ { + limit_except GET POST { deny all; } + proxy_pass http://mempool_backend; + } + location /api/ { + limit_except GET { deny all; } + proxy_pass http://mempool_backend; + } + location / { return 404; } + } +} diff --git a/apps/angor-indexer/manifest.yml b/apps/angor-indexer/manifest.yml new file mode 100644 index 00000000..f792e9a4 --- /dev/null +++ b/apps/angor-indexer/manifest.yml @@ -0,0 +1,68 @@ +app: + id: angor-indexer + name: Angor Indexer + version: 1.0.1 + description: Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool + and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s + address as the custom indexer in Angor settings. A relay is optional and installed + separately. + category: money + install_prerequisites: + - mempool-api + upstream: + kind: github + repo: block-core/angor + container: + image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.1 + pull_policy: if-not-present + network: archy-net + dependencies: + - app_id: mempool-api + version: '>=3.0.0' + - bitcoin:archival + resources: + cpu_limit: 1 + memory_limit: 128Mi + disk_limit: 128Mi + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 101 + network_policy: isolated + ports: + - host: 8998 + container: 8080 + protocol: tcp + bind: 127.0.0.1 + auth: open + auth_rationale: Public Bitcoin chain-data API and validated transaction broadcast for Angor clients; no wallet keys or node RPC credentials are exposed. Browser cookie login would break machine clients. + interfaces: + main: + name: Angor Indexer API + description: Use this origin as Angor’s custom mainnet indexer URL. HTTPS is + required for browser clients. + type: api + port: 8998 + protocol: http + path: / + health_check: + type: http + endpoint: http://localhost:8080 + path: /health + interval: 30s + timeout: 8s + retries: 3 + bitcoin_integration: + rpc_access: none + sync_required: true + pruning_support: false + metadata: + icon: /assets/img/app-icons/angor.svg + tier: optional + repo: https://github.com/block-core/angor + features: + - Angor mainnet API + - Reuses existing Mempool indexing + - No separate blockchain database + - Optional independent relay diff --git a/apps/angor-relay/README.md b/apps/angor-relay/README.md new file mode 100644 index 00000000..343f6330 --- /dev/null +++ b/apps/angor-relay/README.md @@ -0,0 +1,21 @@ +# Angor Relay + +Optional standalone strfry relay for Angor's public project metadata. See +[Angor Indexer setup](../angor-indexer/README.md) for client URLs and HTTPS/WSS. + +The gate exposes port 8091 for Nostr clients. strfry validates event signatures; +this is a public relay, not a private messaging archive. It mounts only +`/var/lib/archipelago/angor-relay` and its separate configuration directory. +It never opens, reconfigures or shares the node's internal strfry database. + +The configuration is seeded only when absent, preserving operator changes. +Stop the service before making a consistent backup of its event database. +Ordinary start/restart/recreation preserves both mounts. Use the standard app +lifecycle; do not manually recreate a systemd-managed container. + +## Image provenance + +Mirrored from `docker.io/dockurr/strfry:1.1.2`, upstream manifest digest +`sha256:e81d238db13507f6ef24c49d47cd0b0ea58ff207961f10581fa2a7c901054df4`. +The public Angor policy is supplied by this app's own configuration; it does not +reuse the internal relay's event whitelist. diff --git a/apps/angor-relay/manifest.yml b/apps/angor-relay/manifest.yml new file mode 100644 index 00000000..1b7055e6 --- /dev/null +++ b/apps/angor-relay/manifest.yml @@ -0,0 +1,223 @@ +app: + id: angor-relay + name: Angor Relay + version: 1.1.2 + upstream: + kind: github + repo: hoytech/strfry + description: Optional dedicated Nostr relay for Angor project metadata. Separate + storage and access settings keep the node’s internal relay private. Add this service’s + address to Angor’s relay settings; use WSS for browser clients. + container: + image: source.archipelago-foundation.org/chaum/angor-relay:1.1.2 + pull_policy: if-not-present + dependencies: + - storage: 5Gi + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 5Gi + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + seccomp_profile: default + network_policy: isolated + apparmor_profile: nostr-relay + ports: + - host: 8091 + container: 7777 + protocol: tcp + bind: 127.0.0.1 + auth: open + auth_rationale: Dedicated public Nostr relay for Angor project metadata; strfry verifies event signatures. It has separate storage from the private node relay and no wallet or node credentials. + volumes: + - type: bind + source: /var/lib/archipelago/angor-relay + target: /app/strfry-db + options: + - rw + - type: bind + source: /var/lib/archipelago/angor-relay-config/angor-relay.conf + target: /etc/strfry.conf + options: + - ro + files: + - path: /var/lib/archipelago/angor-relay-config/angor-relay.conf + overwrite: false + content: | + ## + ## Default strfry config + ## + + # Directory that contains the strfry LMDB database (restart required) + db = "./strfry-db/" + + dbParams { + # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required) + maxreaders = 256 + + # Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required) + mapsize = 10995116277760 + + # Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required) + noReadAhead = false + } + + events { + # Maximum size of normalised JSON, in bytes + maxEventSize = 65536 + + # Events newer than this will be rejected + rejectEventsNewerThanSeconds = 900 + + # Events older than this will be rejected + rejectEventsOlderThanSeconds = 94608000 + + # Ephemeral events older than this will be rejected + rejectEphemeralEventsOlderThanSeconds = 60 + + # Ephemeral events will be deleted from the DB when older than this + ephemeralEventsLifetimeSeconds = 300 + + # Maximum number of tags allowed + maxNumTags = 2000 + + # Maximum size for tag values, in bytes + maxTagValSize = 1024 + } + + relay { + # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required) + bind = "0.0.0.0" + + # Port to open for the nostr websocket protocol (restart required) + port = 7777 + + # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required) + nofiles = 0 + + # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case) + realIpHeader = "" + + info { + # NIP-11: Name of this server. Short/descriptive (< 30 characters) + name = "Angor Relay" + + # NIP-11: Detailed information about relay, free-form + description = "Dedicated public relay for Angor project metadata." + + # NIP-11: Administrative nostr pubkey, for contact purposes + pubkey = "" + + # NIP-11: Alternative administrative contact (email, website, etc) + contact = "" + + # NIP-11: URL pointing to an image to be used as an icon for the relay + icon = "" + + # List of supported lists as JSON array, or empty string to use default. Example: "[1,2]" + nips = "" + } + + # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required) + maxWebsocketPayloadSize = 131072 + + # Maximum number of filters allowed in a REQ + maxReqFilterSize = 200 + + # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required) + autoPingSeconds = 55 + + # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy) + enableTcpKeepalive = false + + # How much uninterrupted CPU time a REQ query should get during its DB scan + queryTimesliceBudgetMicroseconds = 10000 + + # Maximum records that can be returned per filter + maxFilterLimit = 500 + + # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time + maxSubsPerConnection = 20 + + writePolicy { + # If non-empty, path to an executable script that implements the writePolicy plugin logic + plugin = "" + } + + compression { + # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required) + enabled = true + + # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required) + slidingWindow = true + } + + logging { + # Dump all incoming messages + dumpInAll = false + + # Dump all incoming EVENT messages + dumpInEvents = false + + # Dump all incoming REQ/CLOSE messages + dumpInReqs = false + + # Log performance metrics for initial REQ database scans + dbScanPerf = false + + # Log reason for invalid event rejection? Can be disabled to silence excessive logging + invalidEvents = true + } + + numThreads { + # Ingester threads: route incoming requests, validate events/sigs (restart required) + ingester = 3 + + # reqWorker threads: Handle initial DB scan for events (restart required) + reqWorker = 3 + + # reqMonitor threads: Handle filtering of new events (restart required) + reqMonitor = 3 + + # negentropy threads: Handle negentropy protocol messages (restart required) + negentropy = 2 + } + + negentropy { + # Support negentropy protocol messages + enabled = true + + # Maximum records that sync will process before returning an error + maxSyncEvents = 1000000 + } + } + health_check: + type: http + endpoint: http://127.0.0.1:7777 + path: /health + interval: 30s + timeout: 5s + retries: 3 + nostr_integration: + relay_type: public + monetization_enabled: false + category: nostr + interfaces: + main: + name: Angor Relay + description: Nostr WebSocket endpoint; use ws:// for LAN or wss:// through your + HTTPS domain. + type: api + port: 8091 + protocol: http + path: / + metadata: + icon: /assets/img/app-icons/angor.svg + tier: optional + repo: https://github.com/hoytech/strfry + features: + - Angor project metadata + - Separate from the node relay + - Persistent Nostr event storage diff --git a/core/archipelago/src/api/rpc/package/dependencies.rs b/core/archipelago/src/api/rpc/package/dependencies.rs index 4c079b16..059d1a37 100644 --- a/core/archipelago/src/api/rpc/package/dependencies.rs +++ b/core/archipelago/src/api/rpc/package/dependencies.rs @@ -22,6 +22,14 @@ const ARCHIVAL_BITCOIN_DEPENDENCY: &str = "bitcoin:archival"; /// hardcoded id list below — a new app just declares the dependency instead /// of needing a code change here. fn manifest_declares_archival_bitcoin(package_id: &str) -> bool { + // Registry-only apps need the same guard as OTA-bundled manifests. Honor + // the verified catalog's effective manifest before the disk fallback. + if let Some((_, value)) = crate::container::app_catalog::catalog_manifest_values() + .into_iter().find(|(id, _)| id == package_id) { + if let Some(manifest) = crate::container::app_catalog::catalog_manifest_overlay(package_id, value) { + return dependency_list_declares_archival_bitcoin(&manifest.app.dependencies); + } + } for apps_dir in manifest_apps_dirs() { let path = apps_dir.join(package_id).join("manifest.yml"); let Ok(contents) = std::fs::read_to_string(&path) else { @@ -1055,6 +1063,9 @@ mod tests { // edit to `requires_unpruned_bitcoin`. assert!(manifest_declares_archival_bitcoin("electrumx")); assert!(manifest_declares_archival_bitcoin("mempool")); + let angor = archipelago_container::AppManifest::parse(include_str!(concat!(env!("CARGO_MANIFEST_DIR"), + "/../../apps/angor-indexer/manifest.yml"))).unwrap(); + assert!(dependency_list_declares_archival_bitcoin(&angor.app.dependencies)); // An app whose manifest exists but never declares the marker. assert!(!manifest_declares_archival_bitcoin("bitcoin-knots")); // An id with no manifest on disk at all. diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index a2bfd47e..126912d9 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -573,6 +573,9 @@ impl RpcHandler { "message": format!("Package {} installed and started", package_id) })); } + Err(e) if e.downcast_ref::().is_some() => { + return Err(super::dependencies::DependencyGateError(e.to_string()).into()); + } Err(e) if is_unknown_app_id_error(&e) => { info!( "Install {}: orchestrator has no manifest mapping yet, falling back to legacy installer", diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 4dd93ae5..df1cf2ac 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -36,6 +36,11 @@ use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::{Mutex, RwLock}; +/// Refusal before installation has created state or changed any dependency. +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct InstallPrerequisiteError(pub String); + use crate::config::{Config, ContainerRuntime as ConfigContainerRuntime}; use crate::container::bitcoin_ui; use crate::container::quadlet; @@ -3904,7 +3909,7 @@ impl ProdContainerOrchestrator { let exists = tokio::process::Command::new("podman") .args(["container", "exists", name]).status().await?; if exists.code() != Some(1) { - anyhow::bail!("cannot verify existing container before network migration backup"); + anyhow::bail!("cannot verify existing container before runtime migration backup"); } false }; @@ -3922,7 +3927,7 @@ impl ProdContainerOrchestrator { } match crate::container::migration_backup::snapshot(manifest, &self.data_dir, previous_unit.as_deref()).await { Ok(archive) => { - tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before network migration"); + tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before runtime migration"); Ok(()) } Err(error) => { @@ -4551,6 +4556,28 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { } async fn install(&self, app_id: &str) -> Result { + let lm = self.loaded(app_id).await?; + // Optional shared-service preconditions are checked before recording + // installation or creating anything. A headless adapter must not claim + // successful installation against a missing indexing stack. + if let Some(required) = lm.manifest.app.extensions.get("install_prerequisites") + .and_then(|value| value.as_sequence()) { + let present = self.runtime.list_containers().await + .context("check installed prerequisite services")?; + for id in required.iter().filter_map(|value| value.as_str()) { + let dependency = self.loaded(id).await.map_err(|_| InstallPrerequisiteError( + format!("Required app {id} is unavailable. Refresh the app catalog before installing {}.", + lm.manifest.app.name)))?; + let name = compute_container_name(&dependency.manifest); + if !present.iter().any(|container| container.name.trim_start_matches('/') == name) { + let owner = crate::app_ops::owning_package(id); + let title = self.loaded(owner).await.map(|app| app.manifest.app.name) + .unwrap_or(dependency.manifest.app.name); + return Err(InstallPrerequisiteError(format!( + "Install {title} first, then install {}.", lm.manifest.app.name)).into()); + } + } + } { let mut state = self.state.write().await; state.disabled.remove(app_id); @@ -4577,7 +4604,6 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { // health verification (the .228 "running but unreachable" failure // mode). Routing every install through here means the orchestrator // is the one source of truth for what "installed" means. - let lm = self.loaded(app_id).await?; let name = compute_container_name(&lm.manifest); // ensure_running takes the per-app lock itself; release the install // path lock first if we hold one (we don't — install is the entry @@ -5746,6 +5772,26 @@ app: orch } + #[tokio::test] + async fn missing_install_prerequisite_refuses_without_inventory_or_container_mutation() { + let rt = Arc::new(MockRuntime::default()); + let orch = orch_with(rt.clone()).await; + let mut app = pull_manifest("indexer-adapter", "docker.io/library/alpine:3.20"); + app.app.extensions.insert("install_prerequisites".into(), + serde_yaml::to_value(vec!["shared-index"]).unwrap()); + orch.insert_manifest_for_test(app, PathBuf::from("/tmp")).await; + orch.insert_manifest_for_test(pull_manifest("shared-index", "index:1"), PathBuf::from("/tmp")).await; + let error = orch.install("indexer-adapter").await.unwrap_err(); + assert!(error.downcast_ref::().is_some()); + assert!(!crate::crash_recovery::load_installed_apps(&orch.data_dir).await.contains("indexer-adapter")); + assert_eq!(rt.calls(), vec!["list_containers"]); + // An installed prerequisite satisfies the guard; it is never recreated + // or reconfigured as part of installing this adapter. + rt.set_state("shared-index", ContainerState::Running); + orch.install("indexer-adapter").await.unwrap(); + assert!(!rt.calls().iter().any(|c| c.starts_with("create_container:shared-index"))); + } + fn pull_manifest_with_dynamic_env(id: &str, image: &str) -> AppManifest { let yaml = format!( "app:\n id: {id}\n name: {id}\n version: 1.0.0\n container:\n image: {image}\n derived_env:\n - key: FM_API_URL\n template: \"ws://{{{{HOST_MDNS}}}}:8174\"\n secret_env:\n - key: FM_BITCOIND_PASSWORD\n secret_file: bitcoin-rpc-password\n environment:\n - STATIC=1\n" diff --git a/core/archipelago/src/fips/app_ports.rs b/core/archipelago/src/fips/app_ports.rs index 51567d01..233de6ed 100644 --- a/core/archipelago/src/fips/app_ports.rs +++ b/core/archipelago/src/fips/app_ports.rs @@ -6,7 +6,43 @@ //! no listener, so allowing them is inert. pub const APP_LAUNCH_PORTS: &[u16] = &[ - 2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090, - 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434, - 18081, 18083, 18091, 23000, 32838, 50002, + 2283, + 2342, + 3000, + 3001, + 3002, + 4080, + 5180, + 7778, + 8080, + 8081, + 8082, + 8083, + 8084, + 8085, + 8087, + 8090, + 8091, + 8096, + 8123, + 8175, + 8176, + 8187, + 8240, + 8334, + 8336, + 8337, + 8888, + 8998, + 8999, + 9000, + 9100, + 10380, + 11434, + 18081, + 18083, + 18091, + 23000, + 32838, + 50002, ]; diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index 479c2543..2701d97a 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -989,6 +989,18 @@ impl AppManifest { validate_security(&self.app.security)?; validate_ports(&self.app.ports)?; validate_interfaces(&self.app.interfaces)?; + if let Some(value) = self.app.extensions.get("install_prerequisites") { + let items = value.as_sequence().ok_or_else(|| ManifestError::Invalid( + "install_prerequisites must be a list of app ids".into()))?; + for item in items { + let id = item.as_str().unwrap_or_default(); + if id.is_empty() || id == self.app.id || !id.bytes().all(|b| + b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') { + return Err(ManifestError::Invalid( + "install_prerequisites must contain valid other app ids".into())); + } + } + } validate_environment(&self.app.environment)?; validate_devices(&self.app.devices)?; @@ -1805,9 +1817,14 @@ app: // nginx-proxy-manager 8081 (NPM admin accounts), tailscale 8240 // (tailnet login on the web console). Both enforce their own login, // and an operator can re-gate either from Settings → Access control. + // Angor's indexer exposes public chain data/transaction broadcast; + // its optional standalone relay accepts signed public Nostr events. + // Neither mounts credentials or the node's internal relay database. assert_eq!( open, vec![ + ("angor-indexer".to_string(), 8998u16), + ("angor-relay".to_string(), 8091u16), ("btcpay-server".to_string(), 23000u16), ("cuprate".to_string(), 18090u16), ("gitea".to_string(), 3001u16), @@ -1818,6 +1835,14 @@ app: ); } + #[test] + fn invalid_install_prerequisites_are_rejected() { + for value in ["not-a-list", "[demo]", "['../other']", "[false]", "['']"] { + let yaml = format!("app:\n id: demo\n name: Demo\n version: 1.0.0\n container:\n image: docker.io/library/alpine:3.20\n install_prerequisites: {value}\n"); + assert!(AppManifest::parse(&yaml).unwrap_err().to_string().contains("install_prerequisites")); + } + } + #[test] fn an_undeclared_port_classifies_as_session_but_is_not_declared() { // Two different questions, and conflating them caused both gate diff --git a/docs/app-developer-guide.md b/docs/app-developer-guide.md index 76f6b7f1..b364e509 100644 --- a/docs/app-developer-guide.md +++ b/docs/app-developer-guide.md @@ -765,3 +765,13 @@ Every supported app must satisfy the lifecycle contract: For apps with special dependencies, launch must explain dependency wait states instead of showing a dead iframe. Examples include Bitcoin sync/IBD, Lightning wallet readiness, Nostr signer bridge injection, Tailscale login/auth, and app-specific setup screens. Runtime changes should be validated with focused tests first, then the release lifecycle harness on the validation host when host access is intentionally resumed. + +### Adapters for shared services + +A service that reuses an installed stack can declare `install_prerequisites` +with the required component app ids and keep the runtime relationship in +`dependencies`. This refuses an incomplete installation before creating the +adapter instead of reporting a successful installation with no usable backend. +For example, Angor Indexer requires `mempool-api` (shown to users as its owning +Mempool app), shares that index and declares only an `api` interface. API-only +interfaces belong in Services and do not generate browser launch buttons. diff --git a/docs/app-manifest-spec.md b/docs/app-manifest-spec.md index 1d0b0ba9..68ce533b 100644 --- a/docs/app-manifest-spec.md +++ b/docs/app-manifest-spec.md @@ -310,3 +310,15 @@ requiring `runtime-migration-backup-v1`. New runtimes select only variants whose complete requirement list they support. Supply `BASE_CATALOG` when generating against a different reviewed pre-migration catalog. This keeps catalog refresh from applying a migration before the matching OTA code is installed. + +### Existing shared-service prerequisites + +`app.install_prerequisites` is an optional list of existing app ids, for example +`[mempool-api]` for a headless indexer adapter. The runtime checks their manifest +container names before recording installation or changing any dependency. If one +is missing, installation refuses with its owning app's title and removes the +optimistic install tile. Runtime observation errors fail closed. This does not +automatically install dependencies, alter Bitcoin pruning, or require a synced +backend merely to recognize an already-installed service. Declare ongoing +relationships separately in `dependencies`; use the app health check for actual +API readiness. Self-dependencies and malformed ids are invalid. diff --git a/docs/next-release-20260930.md b/docs/next-release-20260930.md index 17837eda..9a55ac45 100644 --- a/docs/next-release-20260930.md +++ b/docs/next-release-20260930.md @@ -35,8 +35,8 @@ See the Framework incident and 1.8.21 execution records for evidence/limits. | App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final lifecycle/reboot gate on candidate | | X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | Verify both artifact payloads contain all build contexts | | PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Candidate funded Tor-only purchase, change and Files acceptance | -| Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore and failure retry passed; still need reverse install order, reboot convergence, production Source API/UI, signed delivery | -| Angor headless store service | Current official guide reviewed: standard Mempool with optional strfry relay | Implement using app-development docs; safe dependency/relay integration; official logo; API and lifecycle acceptance | +| Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore, failed-start recovery and reverse installation order passed. Operator confirms production site works through Portainer; still need final candidate reboot convergence and signed delivery | +| Angor headless store service | Implemented standard Mempool adapter and separate optional relay, official logo, headless store entries and declarative dependency guard. API security/outage/DNS tests and five relay lifecycle cycles passed | Final candidate prerequisite/install acceptance, management restart/reboot checks and signed catalog delivery; real indexing on dev waits for Bitcoin sync | Durable payment receipts after a lost seller response remain a separately recorded design follow-up. Preserve the truthful unconfirmed-refund warning and @@ -59,5 +59,28 @@ completed. See PR review for the accepted scope and coverage limits. git and ngit; independently read back hashes and update discovery. - [ ] Provide LAN scp command for the new raw ISO. -Latest backend source verification: 1,605 passed, zero failed, four existing +Latest backend source verification: 1,606 passed, zero failed, four existing ignored tests. This is one layer of evidence, not a substitute for live gates. + +## Angor verification — 2026-09-30 + +- Isolated backend suite: 1,606 passed, four existing ignored; container suite: + 79 passed. Frontend: 140 files / 1,130 tests passed; production build passed. +- Disposable rootless API gateway: versioned and legacy API paths, query/body + forwarding, transaction-only POST, method/body limits, CORS, removal of + dashboard credentials, read-only non-root operation, truthful backend outage + and DNS recovery after backend recreation passed. No real transaction broadcast. +- Dedicated relay: NIP-11, signed event publish/read, invalid signature rejection + and event/config persistence across five managed stop/start/restart cycles + passed. Internal relay identity and start time stayed unchanged. Follow-up + acknowledgement samples were 2–9 ms through both backend and app gate. +- Published adapter 1.0.1 and relay 1.1.2 to the authenticated maintainer namespace. + Anonymous registry readback succeeded. Adapter digest: + `sha256:997be611700b55c521ad801fa92daaca2ae6951ac71407434c85eb9603f77c38`; + relay mirror digest: + `sha256:80444ad1304a0e504948b48ea1550c091b18b9f10757f07ce9a68fc261b8f6c1`. +- Delivery target is the development box, as clarified by the operator. Do not + install Angor on the separate Portainer node. Full indexer availability still + requires the dev box's Bitcoin sync and Mempool/Electrum indexing to finish. +- Funded PR acceptance remains pending spendable test ecash. No spent proofs + were reactivated and no native wallet funds were moved for these checks. diff --git a/neode-ui/public/assets/img/app-icons/angor.svg b/neode-ui/public/assets/img/app-icons/angor.svg new file mode 100644 index 00000000..c809966f --- /dev/null +++ b/neode-ui/public/assets/img/app-icons/angor.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index 8b9bfeed..f07af908 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -644,6 +644,35 @@ "/var/lib/archipelago/vaultwarden:/data" ] } + }, + { + "id": "angor-indexer", + "title": "Angor Indexer", + "version": "1.0.1", + "description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.", + "dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1", + "author": "Angor / Archipelago", + "requires": [ + "Mempool API", + "Unpruned Bitcoin" + ], + "category": "money", + "tier": "optional", + "icon": "/assets/img/app-icons/angor.svg", + "repoUrl": "https://github.com/block-core/angor" + }, + { + "id": "angor-relay", + "title": "Angor Relay", + "version": "1.1.2", + "description": "Optional dedicated Nostr relay for Angor project metadata. Separate storage and access settings keep the node’s internal relay private. Add this service’s address to Angor’s relay settings; use WSS for browser clients.", + "dockerImage": "source.archipelago-foundation.org/chaum/angor-relay:1.1.2", + "author": "Angor / Archipelago", + "requires": [], + "category": "nostr", + "tier": "optional", + "icon": "/assets/img/app-icons/angor.svg", + "repoUrl": "https://github.com/hoytech/strfry" } ] } diff --git a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts index 7051f1b4..b9f70e0e 100644 --- a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts +++ b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts @@ -42,6 +42,8 @@ export const GENERATED_APP_PORTS: Record = { export const GENERATED_APP_TITLES: Record = { "aiui": "AI Assistant", "alby-hub": "Alby Hub", + "angor-indexer": "Angor Indexer", + "angor-relay": "Angor Relay", "archipelago-source": "GitWorkshop", "archy-btcpay-db": "BTCPay Postgres", "archy-mempool-db": "Mempool MariaDB", diff --git a/neode-ui/src/views/apps/__tests__/appsConfig.test.ts b/neode-ui/src/views/apps/__tests__/appsConfig.test.ts index 9766736b..6443684f 100644 --- a/neode-ui/src/views/apps/__tests__/appsConfig.test.ts +++ b/neode-ui/src/views/apps/__tests__/appsConfig.test.ts @@ -25,6 +25,15 @@ function makePkg(id: string, title: string, category: string): PackageDataEntry } describe('appsConfig service filtering', () => { + it('keeps standalone Angor APIs in Services without a launch button', () => { + for (const id of ['angor-indexer', 'angor-relay']) { + const pkg = makePkg(id, id, 'money') + expect(filterEntriesForTab([[id, pkg]], 'services', 'all')).toHaveLength(1) + expect(filterEntriesForTab([[id, pkg]], 'apps', 'all')).toHaveLength(0) + expect(canLaunch(pkg)).toBe(false) + } + }) + it('treats bitcoin stack UI sidecars as services', () => { expect(isServiceContainer('bitcoin-ui')).toBe(true) expect(isServiceContainer('lnd-ui')).toBe(true) diff --git a/neode-ui/src/views/discover/__tests__/curatedApps.portAuth.test.ts b/neode-ui/src/views/discover/__tests__/curatedApps.portAuth.test.ts index 69248732..be2abea8 100644 --- a/neode-ui/src/views/discover/__tests__/curatedApps.portAuth.test.ts +++ b/neode-ui/src/views/discover/__tests__/curatedApps.portAuth.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { __setSignedCatalogForTests, portAuth, portIsGateFronted, type SignedAppCatalog } from '../curatedApps' +import { __setSignedCatalogForTests, signedCatalogToApps, portAuth, portIsGateFronted, type SignedAppCatalog } from '../curatedApps' /** Catalog fragments mirroring the live signed catalog's port declarations * (releases/app-catalog.json, 2026-09-01). */ @@ -77,3 +77,12 @@ describe('portAuth', () => { expect(portAuth('mempool-web', 4080)).toBeNull() }) }) + +describe('standalone headless services', () => { + it('lists Angor services while keeping shared Mempool and node relay internals hidden', () => { + const apps = signedCatalogToApps(catalog(Object.fromEntries( + ['angor-indexer', 'angor-relay', 'mempool-api', 'strfry'].map(id => [id, { version: '1' }]), + ))) + expect(apps.map(app => app.id)).toEqual(['angor-indexer', 'angor-relay']) + }) +}) diff --git a/scripts/generate-app-catalog.py b/scripts/generate-app-catalog.py index ac3f77e1..e8546d50 100644 --- a/scripts/generate-app-catalog.py +++ b/scripts/generate-app-catalog.py @@ -72,6 +72,10 @@ def manifest_launch_port(app: dict[str, Any]) -> int | None: return port if isinstance(port, str) and port.isdigit(): return int(port) + # An explicitly headless API/metrics declaration must not gain a + # browser launch button just because it has an HTTP health check. + if interfaces: + return None health_check = app.get("health_check") if not isinstance(health_check, dict) or str(health_check.get("type", "")).lower() != "http": @@ -95,6 +99,20 @@ def manifest_launch_port(app: dict[str, Any]) -> int | None: return None +def manifest_service_ports(app: dict[str, Any]) -> list[int]: + """Declared API endpoints served by the gate also need mesh reachability.""" + interfaces = app.get("interfaces") or {} + declared = { + int(i["port"]) for i in interfaces.values() + if isinstance(i, dict) and i.get("type") in ("api", "metrics") + and str(i.get("port", "")).isdigit() + } + return [int(p["host"]) for p in app.get("ports", []) + if str(p.get("host", "")).isdigit() and int(p["host"]) in declared + and p.get("auth") in ("open", "gated", "session") + and p.get("protocol", "tcp") == "tcp"] + + def manifest_opens_in_new_tab(app: dict[str, Any]) -> bool: """Return whether manifest launch metadata opts the app out of iframe launch.""" launch = metadata(app).get("launch") @@ -274,7 +292,8 @@ def main() -> int: if (port := manifest_launch_port(app)) } rust_path = Path(args.rust_app_ports) - rust_content = render_rust_ports(ports, RUST_EXTRA_PORTS) + service_ports = [p for app in manifests.values() for p in manifest_service_ports(app)] + rust_content = render_rust_ports(ports, RUST_EXTRA_PORTS + service_ports) rust_old = rust_path.read_text(encoding="utf-8") if rust_path.exists() else "" if rust_old != rust_content: rust_path.write_text(rust_content, encoding="utf-8") diff --git a/tests/lifecycle/angor-proxy-check.py b/tests/lifecycle/angor-proxy-check.py new file mode 100644 index 00000000..c3cf1d9a --- /dev/null +++ b/tests/lifecycle/angor-proxy-check.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Opt-in disposable rootless Angor gateway integration checks. No native app changes.""" +import subprocess,pathlib,json,urllib.request,urllib.error,time,tempfile,os,uuid +if os.environ.get('ARCHY_ALLOW_DISPOSABLE_CONTAINERS') != '1': + raise SystemExit('Set ARCHY_ALLOW_DISPOSABLE_CONTAINERS=1 to run isolated test containers') +run_id=uuid.uuid4().hex[:12] +net='archy-angor-test-'+run_id;backend='angor-test-backend-'+run_id;gateway='angor-test-gateway-'+run_id +def run(*a): + r=subprocess.run(a,capture_output=True,text=True) + if r.returncode:raise RuntimeError(r.stderr) + return r.stdout.strip() +def req(path,data=None,method=None,headers={}): + r=urllib.request.Request('http://127.0.0.1:19098'+path,data=data,method=method,headers=headers) + try: + with urllib.request.urlopen(r,timeout=10) as f:return f.status,f.headers,f.read() + except urllib.error.HTTPError as e:return e.code,e.headers,e.read() +script="""require('http').createServer((q,r)=>{let b='';q.on('data',x=>b+=x);q.on('end',()=>{r.setHeader('Access-Control-Allow-Origin','https://wrong.example');if(q.url==='/api/v1/blocks/tip/height'){r.end('900000');return}r.setHeader('Content-Type','application/json');r.end(JSON.stringify({url:q.url,method:q.method,body:b,cookie:q.headers.cookie||null,auth:q.headers.authorization||null}))})}).listen(8999,'0.0.0.0')""" +def start_backend():run('podman','run','-d','--name',backend,'--network',net,'--network-alias','mempool-api','--cap-drop=all','--security-opt=no-new-privileges','docker.io/library/node:24-alpine','node','-e',script) +def ready(seconds=40): + end=time.monotonic()+seconds + while time.monotonic()