Add headless Angor services and shared-index install guard
Demo images / Build & push demo images (push) Failing after 43s
Demo images / Build & push demo images (push) Failing after 43s
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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://<node-address>: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://<node-address>: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.
|
||||
@@ -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"]
|
||||
Executable
+12
@@ -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;'
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -573,6 +573,9 @@ impl RpcHandler {
|
||||
"message": format!("Package {} installed and started", package_id)
|
||||
}));
|
||||
}
|
||||
Err(e) if e.downcast_ref::<crate::container::prod_orchestrator::InstallPrerequisiteError>().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",
|
||||
|
||||
@@ -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<String> {
|
||||
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::<InstallPrerequisiteError>().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"
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 24 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()<end:
|
||||
try:
|
||||
if req('/health')[0]==200:return
|
||||
except OSError:pass
|
||||
time.sleep(1)
|
||||
raise RuntimeError('Gateway readiness did not recover')
|
||||
assert subprocess.run(['podman','network','exists',net]).returncode==1
|
||||
run('podman','network','create',net)
|
||||
try:
|
||||
start_backend()
|
||||
run('podman','run','-d','--name',gateway,'--network',net,'--read-only','--cap-drop=all','--security-opt=no-new-privileges','--memory','128m','-p','127.0.0.1:19098:8080','source.archipelago-foundation.org/chaum/angor-indexer:1.0.1')
|
||||
ready()
|
||||
for path in ['/api/v1/address/bc1fixture/txs?after_txid=abc','/api/v1/fees/recommended','/api/tx/fixture/hex']:
|
||||
status,headers,body=req(path,headers={'Cookie':'node-secret=do-not-forward','Authorization':'Bearer do-not-forward'})
|
||||
result=json.loads(body);assert status==200 and result['url']==(path if path.startswith('/api/v1/') else path.replace('/api/','/api/v1/',1)) and result['cookie'] is None and result['auth'] is None
|
||||
assert headers.get_all('Access-Control-Allow-Origin')==['*']
|
||||
assert req('/api/v1/tx',b'deadbeef')[0]==200
|
||||
assert json.loads(req('/api/v1/tx',b'deadbeef')[2])['body']=='deadbeef'
|
||||
assert req('/api/v1/fees/recommended',b'bad')[0]==403
|
||||
assert req('/api/v1/tx',b'bad',method='DELETE')[0]==403
|
||||
assert req('/api/v1/tx',method='OPTIONS')[0]==204
|
||||
assert req('/api/v1/tx',b'x'*(4*1024*1024+1))[0]==413
|
||||
assert req('/unknown')[0]==404
|
||||
d=json.loads(run('podman','inspect',gateway))[0];assert d['Config']['User']=='101:101' and not d['BoundingCaps']
|
||||
print('PASS API paths/query/body, transaction-only POST, method/size limits, CORS, credential stripping and unprivileged read-only image',flush=True)
|
||||
run('podman','stop',backend)
|
||||
status,headers,body=req('/health');assert status==503 and json.loads(body)['status']=='waiting'
|
||||
run('podman','rm',backend);start_backend();ready()
|
||||
print('PASS backend outage returns truthful 503; backend recreation recovers through runtime DNS without gateway restart',flush=True)
|
||||
except BaseException:
|
||||
subprocess.run(['podman','logs','--tail','15',gateway],check=False)
|
||||
raise
|
||||
finally:
|
||||
for name in [gateway,backend]:subprocess.run(['podman','rm','-f','--time','3',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
subprocess.run(['podman','network','rm',net],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headless store apps must be discoverable without acquiring a UI launcher."""
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import unittest
|
||||
import yaml
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location('catalog_generator', ROOT / 'scripts/generate-app-catalog.py')
|
||||
generator = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(generator)
|
||||
|
||||
class ServiceMetadata(unittest.TestCase):
|
||||
def test_headless_services_have_mesh_ports_but_no_browser_launcher(self):
|
||||
for name, port in [('angor-indexer', 8998), ('angor-relay', 8091)]:
|
||||
app = yaml.safe_load((ROOT / 'apps' / name / 'manifest.yml').read_text())['app']
|
||||
self.assertIsNone(generator.manifest_launch_port(app))
|
||||
self.assertEqual(generator.manifest_service_ports(app), [port])
|
||||
self.assertEqual(app['ports'][0]['bind'], '127.0.0.1')
|
||||
self.assertEqual(app['security']['capabilities'], [])
|
||||
|
||||
def test_host_local_api_never_opens_mesh_port(self):
|
||||
app = {'interfaces': {'main': {'type': 'api', 'port': 8999}},
|
||||
'ports': [{'host': 8999, 'auth': 'local'}],
|
||||
'health_check': {'type': 'http'}}
|
||||
self.assertIsNone(generator.manifest_launch_port(app))
|
||||
self.assertEqual(generator.manifest_service_ports(app), [])
|
||||
|
||||
def test_legacy_ui_fallback_retained(self):
|
||||
self.assertEqual(generator.manifest_launch_port({'ports': [{'host': 8080}],
|
||||
'health_check': {'type': 'http'}}), 8080)
|
||||
|
||||
def test_relay_storage_cannot_share_node_identity_or_database(self):
|
||||
node = yaml.safe_load((ROOT / 'apps/strfry/manifest.yml').read_text())['app']
|
||||
angor = yaml.safe_load((ROOT / 'apps/angor-relay/manifest.yml').read_text())['app']
|
||||
node_paths = {v['source'] for v in node['volumes']}
|
||||
self.assertTrue(node_paths.isdisjoint(v['source'] for v in angor['volumes']))
|
||||
self.assertTrue(all(not f['overwrite'] for f in angor['files']))
|
||||
self.assertEqual(angor['interfaces']['main']['type'], 'api')
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user