Compare commits

..

No commits in common. "main" and "feat/mesh-archy-command" have entirely different histories.

233 changed files with 1635 additions and 11470 deletions

2
.gitmodules vendored
View File

@ -1,3 +1,3 @@
[submodule "indeedhub"] [submodule "indeedhub"]
path = indeedhub path = indeedhub
url = http://146.59.87.168:3000/lfg2025/indeehub.git url = https://git.tx1138.com/lfg2025/indeehub.git

View File

@ -23,31 +23,9 @@ Detailed sub-plans (all linked from the master):
- App platform / packaging phases + security model → `docs/APP-PACKAGING-MIGRATION-PLAN.md` - App platform / packaging phases + security model → `docs/APP-PACKAGING-MIGRATION-PLAN.md`
- Registry-distributed manifests (in progress) → `docs/registry-manifest-design.md` - Registry-distributed manifests (in progress) → `docs/registry-manifest-design.md`
- External/decentralized marketplace for devs → `docs/marketplace-protocol.md` - External/decentralized marketplace for devs → `docs/marketplace-protocol.md`
- Current per-app state → `docs/archive/app-registry-status-2026-06-21.md` - Current per-app state → `docs/app-registry-status-2026-06-21.md`
- Production test gate (exit criterion) → `tests/lifecycle/TESTING.md` - Production test gate (exit criterion) → `tests/lifecycle/TESTING.md`
## Commit & push every unit of work (never violate)
**The #1 process rule: work is not "done" until it is committed AND pushed.** This
exists because finished work has been lost/clobbered by sitting uncommitted in the
shared tree across agents and sessions. To prevent that:
- **Commit each feature/fix the moment it works** — one focused, self-contained
commit per logical change (it compiles and its targeted tests pass). Do not let
unrelated changes accumulate uncommitted.
- **Push immediately after committing** so nothing lives only on one machine. `main`
is protected → push via `git push gitea-ai main` (account `ai`, see the memory
note); feature branches push to their own remote.
- **Never leave a stack of finished work uncommitted** overnight or when handing off
between agents — if you must pause mid-change, commit a clearly-labelled WIP
checkpoint rather than leaving it dirty.
- **Stage explicitly by path** (`git add <paths>`) when another agent's uncommitted
work shares the tree — never `git add -A` / `git commit -a`, which clobbers or
entangles their changes.
- **Never commit or push secrets** (mnemonics, private keys, API tokens). Signing is
done offline; artifacts (catalog/manifest) are signed, not the keys.
- Commit messages end with the `Co-Authored-By: Claude …` trailer.
## Invariants (never violate) ## Invariants (never violate)
- **Rootless Podman only.** No rootful, no Docker-socket mounts, no privileged - **Rootless Podman only.** No rootful, no Docker-socket mounts, no privileged

View File

@ -8,9 +8,9 @@ Be respectful. We follow the [Contributor Covenant](https://www.contributor-cove
## Getting Started ## Getting Started
1. Fork the repository on the project's Gitea instance 1. Fork the repository
2. Clone your fork: `git clone <your-fork-url>/archy.git` 2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/archy.git`
3. Set up the dev environment (see `docs/developer-guide.md`) 3. Set up the dev environment (see `docs/development-setup.md`)
4. Create a feature branch: `git checkout -b feature/your-feature` 4. Create a feature branch: `git checkout -b feature/your-feature`
## Development Setup ## Development Setup

View File

@ -122,7 +122,7 @@ echo ""
# Install custom app dependencies # Install custom app dependencies
echo "Installing custom app dependencies..." echo "Installing custom app dependencies..."
for app in did-wallet morphos-server router; do for app in did-wallet endurain morphos-server router; do
if [ -d "apps/$app" ]; then if [ -d "apps/$app" ]; then
echo " - Installing $app dependencies..." echo " - Installing $app dependencies..."
cd "apps/$app" cd "apps/$app"
@ -161,6 +161,6 @@ echo " http://localhost:8100"
echo "" echo ""
echo "For more information, see:" echo "For more information, see:"
echo " - README.md" echo " - README.md"
echo " - docs/developer-guide.md" echo " - docs/development-setup.md"
echo " - apps/QUICKSTART.md" echo " - apps/QUICKSTART.md"
echo "" echo ""

151
README.md
View File

@ -2,99 +2,71 @@
> Self-Sovereign Bitcoin Node OS > Self-Sovereign Bitcoin Node OS
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, mesh communication, and decentralized identity through a glassmorphism web UI. **Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, and decentralized identity through a glassmorphism web UI.
[![Debian 13](https://img.shields.io/badge/Debian-13%20Trixie-a80030)](https://www.debian.org/) [![Debian 13](https://img.shields.io/badge/Debian-13%20Trixie-a80030)](https://www.debian.org/)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/) [![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/)
[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/) [![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/)
[![Version](https://img.shields.io/badge/version-1.8.0--alpha-blue)]() [![Version](https://img.shields.io/badge/version-1.3.1--beta-blue)]()
## Philosophy
Archipelago is being built as a **developer-ready app platform**, not a fixed appliance:
- **Manifest-driven apps.** Every app is declared in a single `manifest.yml` — image, ports, volumes, secrets, health checks, security policy. The orchestrator owns the entire lifecycle; there is no per-app installer code and no host-level provisioning.
- **Signed distribution.** App manifests ship inside an Ed25519-signed catalog verified against a pinned release-root key, not as loose files on disk. OTA release manifests are signed the same way.
- **Decentralized marketplace.** Third-party developers publish apps via Nostr-based discovery (NIP-78) with DID-signed manifests and federation-weighted trust scoring — no gatekept central store.
- **Rootless and secure by default.** Rootless Podman only. Read-only root, no-new-privileges, capability allow-list, secrets materialised 0600 and never logged. Never rootful, never a Docker socket mount.
- **100%-uptime-capable.** Every container is a systemd Quadlet unit under `user.slice` that survives backend restarts; a level-triggered reconciler self-heals drift every 30 seconds; migrations never destroy data.
## Features ## Features
### Bitcoin Infrastructure ### Bitcoin Infrastructure
- **Bitcoin Core and Bitcoin Knots** full nodes with per-app version pinning and bulletproof version switching, automatic prune/full mode based on disk size - **Bitcoin Knots** full node with pruning support
- **LND** and **Core Lightning** with channel management - **LND** Lightning Network daemon with channel management
- **ElectrumX** Electrum server for wallet connectivity - **ElectrumX** Electrum server for wallet connectivity
- **BTCPay Server** for accepting Bitcoin payments - **BTCPay Server** for accepting Bitcoin payments
- **Mempool** block explorer and fee estimator - **Mempool** block explorer and fee estimator
- **Fedimint** federation guardian, gateway, and client — plus Cashu ecash wallet support - **Fedimint** federation guardian and gateway
### Self-Hosted Apps (50+) ### Self-Hosted Apps (29)
Storage (FileBrowser, Immich, Nextcloud), Productivity (Vaultwarden), Media (Jellyfin, PhotoPrism, IndeeHub), Search (SearXNG), Network (NetBird, Tailscale), Home (Home Assistant), Nostr (nostr-rs-relay, strfry), Dev/Ops (Gitea, Grafana, Portainer, Uptime Kuma), and more — 27 curated in the store UI, 50+ packaged as manifests. Bitcoin, Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
### Mesh Networking (tri-protocol)
- **Meshtastic**, **MeshCore**, and **Reticulum (RNS/LXMF)** LoRa transports behind one mesh chat UI
- End-to-end encryption with X3DH key agreement + double-ratchet
- RNode radio support with an OS-level `archy-rnodeconf` tool; interop verified against Sideband
- Image/voice attachments, mesh AI assistant (`!ai`), Bitcoin balance relay over mesh
### Decentralized Identity ### Decentralized Identity
- Ed25519 node identity with DID Documents (did:key) - Ed25519 node identity with DID Documents (did:key)
- Multi-identity management (Personal/Business/Anonymous) - Multi-identity management (Personal/Business/Anonymous)
- W3C Verifiable Credentials issuance and verification - W3C Verifiable Credentials issuance and verification
- Nostr integration: NIP-33 node discovery, NIP-44/NIP-04 encryption, NIP-07 signer bridge for iframe apps, relay hosting - Decentralized Web Node (DWN) with bidirectional sync over Tor
- Decentralized Web Node (DWN) record sync between federated nodes over Tor - Nostr relay integration and NIP-07 signing for iframe apps
### Multi-Node Federation ### Multi-Node Federation
- Invite-based node joining over Tor hidden services - Invite-based node joining over Tor hidden services
- Trust levels (Trusted/Verified/Untrusted) with DID-based auth - Trust levels (Trusted/Verified/Untrusted) with DID-based auth
- State sync and app deployment across federated nodes - Bidirectional DWN state sync between federated nodes
- File sharing with access controls (free/peers-only/paid via Lightning, on-chain, or ecash) - File sharing with access controls (free/peers-only/paid)
### Mesh Networking
- LoRa radio communication via Meshcore protocol
- Device discovery and mesh routing
- Off-grid Bitcoin balance checks (planned)
### System Updates ### System Updates
- OTA updates from a self-hosted Gitea release server, Ed25519-signature-verified against a pinned release-root key - OTA updates from self-hosted Gitea (git.tx1138.com) with SHA256 verification
- Resumable downloads, automatic pre-update backup, rollback with a post-update self-verify window - Three update modes: Manual, Daily Check, Auto Apply (3 AM window)
- Manual, scheduled-check, and auto-apply modes (auto-apply refuses unsigned manifests) - Rollback support with automatic backup before applying
- Full UI for update management in Settings
### Security ### Security
- Argon2id password hashing (transparent upgrade from legacy hashes), ChaCha20-Poly1305 encrypted secrets at rest - ChaCha20-Poly1305 encrypted secrets at rest, Argon2id password hashing
- Rootless Podman: read-only root, cap-drop ALL with a reviewed allow-list, no-new-privileges - Rootless Podman: read-only root, cap-drop ALL, non-root user, no-new-privileges
- Signed release manifests and signed app catalog (Ed25519, pinned trust anchor) - TOTP two-factor authentication
- TOTP two-factor authentication, per-endpoint rate limiting, CSRF protection - Per-endpoint rate limiting, CSRF protection, input validation
- AppArmor profiles for container confinement; Tor hidden services for inter-node traffic - AppArmor profiles for container confinement
- Independent security audit of an early version archived in [`docs/archive/`](docs/archive/security-code-audit-2026-03.md); top findings since remediated - Tor hidden services for all inter-node communication
- All crypto and container dependencies pinned to exact versions
## Roadmap - Full penetration test completed (33 findings, all remediated)
**Done**
- Single-node production gate **green** — install / stop / start / restart / reinstall / reboot-survive / uninstall, 5 consecutive full runs with zero failures on real hardware
- Quadlet migration validated (all backends as `user.slice` services on the canary node)
- Release signing ceremony completed — release-root key pinned, catalog and OTA manifests signed
- Reticulum third mesh transport (real-RF LoRa gates passed), Bitcoin Core/Knots multi-version switching, decentralized marketplace backend, public demo
**In progress**
- Multinode pass: the same production gate across the whole test fleet ([`docs/multinode-testing-plan.md`](docs/multinode-testing-plan.md))
- Quadlet default flip fleet-wide + container-flapping elimination
- 1.8.0 release hardening tail ([`docs/1.8.0-RELEASE-HARDENING-PLAN.md`](docs/1.8.0-RELEASE-HARDENING-PLAN.md)): OTA upgrade soak on real hardware, ISO/image hardening (per-device keys, no default creds, signed ISO)
**Planned**
- Developer CLI (`archy app validate/render/install/test`) to open third-party app publishing
- External marketplace trust UX + publishing tooling ([`docs/marketplace-protocol.md`](docs/marketplace-protocol.md))
- DHT/P2P distribution of releases and app images ([`docs/dht-distribution-design.md`](docs/dht-distribution-design.md))
- P2P encrypted voice/video over Tor, dual-ecash (Fedimint + Cashu) phases, paid streaming, hardware signer support
The live, priority-ordered task list is [`docs/UNIFIED-TASK-TRACKER.md`](docs/UNIFIED-TASK-TRACKER.md); the full narrative plan is [`docs/PRODUCTION-MASTER-PLAN.md`](docs/PRODUCTION-MASTER-PLAN.md).
## Quick Start ## Quick Start
### Install from ISO ### Install from ISO
1. Build or download the ISO for your architecture (x86_64 or ARM64) — see [`image-recipe/`](image-recipe/) 1. Download the ISO for your architecture (x86_64 or ARM64)
2. Flash to USB drive with Balena Etcher or `dd` 2. Flash to USB drive with Balena Etcher or `dd`
3. Boot from USB on target hardware and follow the automated installer 3. Boot from USB on target hardware
4. Access the web UI at `http://<device-ip>` 4. Follow the automated installer
5. Set your password and complete the onboarding wizard (seed backup, DID identity) 5. Access the web UI at `http://<device-ip>`
6. Set your password and start the onboarding wizard
### Supported Hardware ### Supported Hardware
@ -103,7 +75,7 @@ The live, priority-ordered task list is [`docs/UNIFIED-TASK-TRACKER.md`](docs/UN
| **x86_64** | Intel NUC, mini PCs, any 64-bit PC | 4GB RAM, 32GB storage | | **x86_64** | Intel NUC, mini PCs, any 64-bit PC | 4GB RAM, 32GB storage |
| **ARM64** | Raspberry Pi 5, ARM64 SBCs | 4GB RAM, 32GB storage | | **ARM64** | Raspberry Pi 5, ARM64 SBCs | 4GB RAM, 32GB storage |
**Recommended**: 8GB+ RAM, 1TB+ NVMe SSD (for a full Bitcoin node). Optional: an RNode-compatible LoRa radio for mesh networking. **Recommended**: 8GB+ RAM, 1TB+ NVMe SSD (for full Bitcoin node)
## Development ## Development
@ -122,15 +94,7 @@ npm run type-check # TypeScript validation
npm run build # Production build → web/dist/neode-ui/ npm run build # Production build → web/dist/neode-ui/
``` ```
### Backend Development ### Deploy to Server
```bash
cd core # Rust workspace root (no Cargo.toml at repo root)
cargo build
cargo test
```
### Deploy to a Test Node
```bash ```bash
./scripts/deploy-to-target.sh --live # Deploy to primary dev server ./scripts/deploy-to-target.sh --live # Deploy to primary dev server
@ -140,57 +104,54 @@ cargo test
### Release (tarball-only) ### Release (tarball-only)
Releases ship as a backend binary and a frontend tarball referenced by Releases ship as a backend binary and a frontend tarball referenced by
`releases/manifest.json`, published to the self-hosted Gitea release server. `releases/manifest.json`. Nodes OTA-update via `scripts/self-update.sh`.
```bash ```bash
./scripts/create-release.sh 1.2.3 ./scripts/create-release.sh 1.2.3
git push origin main --tags git push gitea-local main --tags
git push gitea-vps2 main --tags
``` ```
ISO builds are archived under `image-recipe/_archived/` and not part of the
release deliverable.
## Architecture ## Architecture
``` ```
Debian 13 (Trixie) Debian 13 (Trixie)
├── Rootless Podman — every app a systemd Quadlet unit under user.slice ├── Rootless Podman (30 containers, archy-net DNS)
├── Nginx (reverse proxy, security headers, rate limiting) ├── Nginx (reverse proxy, security headers, rate limiting)
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678, ~380 RPC methods) ├── Rust Backend (JSON-RPC API on 127.0.0.1:5678)
│ ├── core/archipelago/ — API, orchestrator + reconciler, mesh, identity, │ ├── core/archipelago/ — RPC endpoints, auth, identity, federation, mesh
│ │ federation, wallet, updates, marketplace │ ├── core/container/ — PodmanClient (REST API socket), manifests, health
│ ├── core/container/ — Podman client, manifest schema, Quadlet compiler, │ ├── core/security/ — AppArmor, secrets, Cosign image verification
│ │ health monitor, signed app catalog │ └── 6 more crates — models, helpers, js-engine, performance, etc.
│ ├── core/security/ — AppArmor/seccomp policy, secrets manager ├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind)
│ ├── core/openwrt/ — TollGate gateway provisioning (SSH/UCI)
│ └── core/performance/ — resource limits
├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind, PWA)
│ └── Three UI modes (Pro/Easy/Chat) + gamepad navigation + i18n
├── Reticulum daemon (supervised Python/PyInstaller, one per LoRa radio)
└── System Tor (hidden services, SOCKS5 proxy) └── System Tor (hidden services, SOCKS5 proxy)
``` ```
~117,000 lines of Rust | ~69,000 lines of TypeScript/Vue | 51 packaged apps | Android companion app ~49,000 lines of Rust | ~47,000 lines of TypeScript/Vue | 78 shell scripts | 30 container apps
## Documentation ## Documentation
| Doc | Purpose | | Doc | Purpose |
|-----|---------| |-----|---------|
| [Architecture](docs/architecture.md) | System design, crate map, data paths | | [Architecture](docs/architecture.md) | System design, codebase stats, data paths |
| [Architecture Review (HTML)](docs/architecture-review.html) | Interactive guide with diagrams and learning path |
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions | | [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
| [API Reference](docs/api-reference.md) | RPC endpoint reference | | [API Reference](docs/api-reference.md) | Complete RPC endpoint reference |
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps | | [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps |
| [App Manifest Spec](docs/app-manifest-spec.md) | The `manifest.yml` schema |
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide | | [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide |
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions | | [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions |
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery | | [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery |
| [Production Master Plan](docs/PRODUCTION-MASTER-PLAN.md) | North star and workstream narrative | | [Security Audit](docs/security-code-audit-2026-03.md) | Penetration test findings |
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Live, priority-ordered open items | | [Master Plan](docs/MASTER_PLAN.md) | Phased roadmap and task tracking |
| [Test Gate](tests/lifecycle/TESTING.md) | Production lifecycle test gate (definition of done) |
| [Archive](docs/archive/) | Historical audits, session logs, shipped designs |
## Contributing ## Contributing
1. Fork the repository 1. Fork the repository
2. Create a feature branch (`feature/description`) 2. Create a feature branch (`feature/description`)
3. Follow the coding standards in [CONTRIBUTING.md](CONTRIBUTING.md) and [CLAUDE.md](CLAUDE.md) 3. Follow the coding standards in [CLAUDE.md](CLAUDE.md)
4. Submit a pull request 4. Submit a pull request
## License ## License
@ -199,4 +160,4 @@ Debian 13 (Trixie)
## Acknowledgments ## Acknowledgments
Built with: [Rust](https://www.rust-lang.org/), [Vue.js](https://vuejs.org/), [Podman](https://podman.io/), [Bitcoin Core](https://bitcoin.org/), [LND](https://lightning.engineering/), [Reticulum](https://reticulum.network/), [Debian](https://www.debian.org/) Built with: [Rust](https://www.rust-lang.org/), [Vue.js](https://vuejs.org/), [Podman](https://podman.io/), [Bitcoin Core](https://bitcoin.org/), [LND](https://lightning.engineering/), [Debian](https://www.debian.org/)

View File

@ -21,7 +21,7 @@ Add an entry to `catalog.json`:
"icon": "/assets/img/app-icons/my-app.svg", "icon": "/assets/img/app-icons/my-app.svg",
"author": "Author", "author": "Author",
"category": "data", "category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/my-app:1.0.0", "dockerImage": "git.tx1138.com/lfg2025/my-app:1.0.0",
"repoUrl": "https://github.com/...", "repoUrl": "https://github.com/...",
"containerConfig": { "containerConfig": {
"ports": ["8080:8080"], "ports": ["8080:8080"],

View File

@ -172,7 +172,7 @@
"author": "File Browser", "author": "File Browser",
"category": "data", "category": "data",
"tier": "core", "tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0", "dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser", "repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [
@ -285,7 +285,7 @@
"icon": "/assets/img/app-icons/fedimint.png", "icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint", "author": "Fedimint",
"category": "money", "category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/gatewayd:v0.10.0", "dockerImage": "git.tx1138.com/lfg2025/gatewayd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint", "repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": { "containerConfig": {
"ports": [ "ports": [

View File

@ -1,11 +1,11 @@
app: app:
id: archy-btcpay-db id: archy-btcpay-db
name: BTCPay Postgres name: BTCPay Postgres
version: "15.17" version: 15.17
description: Postgres backend for BTCPay and NBXplorer. description: Postgres backend for BTCPay and NBXplorer.
container: container:
image: 146.59.87.168:3000/lfg2025/postgres:15.17 image: git.tx1138.com/lfg2025/postgres:15.17
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
data_uid: "100998:100998" data_uid: "100998:100998"

View File

@ -5,7 +5,7 @@ app:
description: MariaDB backend for the mempool explorer stack. description: MariaDB backend for the mempool explorer stack.
container: container:
image: 146.59.87.168:3000/lfg2025/mariadb:11.4.10 image: git.tx1138.com/lfg2025/mariadb:11.4.10
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
data_uid: "100998:100998" data_uid: "100998:100998"

View File

@ -5,7 +5,7 @@ app:
description: BTCPay blockchain indexer service. description: BTCPay blockchain indexer service.
container: container:
image: 146.59.87.168:3000/lfg2025/nbxplorer:2.6.0 image: git.tx1138.com/lfg2025/nbxplorer:2.6.0
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
secret_env: secret_env:

View File

@ -17,13 +17,6 @@ app:
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container # the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
# --memory=8g (config.rs::get_memory_limit) leaves headroom for # --memory=8g (config.rs::get_memory_limit) leaves headroom for
# mempool + connections. # mempool + connections.
#
# -printtoconsole=0: foreground bitcoind defaults console logging ON,
# which pushed every IBD "UpdateTip" line through conmon into journald
# (>1 GB/day on a fresh node). bitcoind still writes debug.log in the
# datadir (/var/lib/archipelago/bitcoin/debug.log, self-shrunk on
# restart) — use that for deep debugging; podman logs only carries
# entrypoint/startup errors.
- >- - >-
BITCOIND="$(command -v bitcoind || true)"; BITCOIND="$(command -v bitcoind || true)";
if [ -z "$BITCOIND" ]; then if [ -z "$BITCOIND" ]; then
@ -43,9 +36,9 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi; fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS"; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
else else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS"; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
fi fi
derived_env: derived_env:
- key: DISK_GB - key: DISK_GB
@ -71,17 +64,9 @@ app:
network_policy: isolated network_policy: isolated
ports: ports:
# RPC is auth-only: publish host-local ONLY - the LAN cannot reach
# nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api)
# dial the container's archy-net alias directly (bitcoin-core:8332),
# which needs no publish at all. Do NOT bind the archy-net gateway
# (10.89.0.1): rootlessport binds in the HOST netns where that address
# does not exist, and the whole unit crash-loops (2026-07-09, .228).
# P2P 8333 stays public.
- host: 8332 - host: 8332
container: 8332 container: 8332
protocol: tcp protocol: tcp
bind: 127.0.0.1
- host: 8333 - host: 8333
container: 8333 container: 8333
protocol: tcp protocol: tcp

View File

@ -17,13 +17,6 @@ app:
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container # the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
# --memory=8g (config.rs::get_memory_limit) leaves headroom for # --memory=8g (config.rs::get_memory_limit) leaves headroom for
# mempool + connections. # mempool + connections.
#
# -printtoconsole=0: foreground bitcoind defaults console logging ON,
# which pushed every IBD "UpdateTip" line through conmon into journald
# (>1 GB/day on a fresh node). bitcoind still writes debug.log in the
# datadir (/var/lib/archipelago/bitcoin/debug.log, self-shrunk on
# restart) — use that for deep debugging; podman logs only carries
# entrypoint/startup errors.
- >- - >-
BITCOIND="$(command -v bitcoind || true)"; BITCOIND="$(command -v bitcoind || true)";
if [ -z "$BITCOIND" ]; then if [ -z "$BITCOIND" ]; then
@ -43,9 +36,9 @@ app:
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi; fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS"; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
else else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS"; exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
fi fi
derived_env: derived_env:
- key: DISK_GB - key: DISK_GB
@ -71,17 +64,9 @@ app:
network_policy: isolated network_policy: isolated
ports: ports:
# RPC is auth-only: publish host-local ONLY - the LAN cannot reach
# nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api)
# dial the container's archy-net alias directly (bitcoin-knots:8332),
# which needs no publish at all. Do NOT bind the archy-net gateway
# (10.89.0.1): rootlessport binds in the HOST netns where that address
# does not exist, and the whole unit crash-loops (2026-07-09, .228).
# P2P 8333 stays public.
- host: 8332 - host: 8332
container: 8332 container: 8332
protocol: tcp protocol: tcp
bind: 127.0.0.1
- host: 8333 - host: 8333
container: 8333 container: 8333
protocol: tcp protocol: tcp

View File

@ -13,12 +13,6 @@ app:
secret_file: bitcoin-rpc-password secret_file: bitcoin-rpc-password
- key: BTCPAY_DB_PASS - key: BTCPAY_DB_PASS
secret_file: btcpay-db-password secret_file: btcpay-db-password
# Internal LND node. Generated by the daemon (lnd macaroon as hex +
# tls.cert thumbprint) — see container::lnd::ensure_btcpay_lnd_connection_secret.
# Optional: nodes without LND run btcpay without an internal node.
- key: BTCPAY_BTCLIGHTNING
secret_file: btcpay-lnd-connection
optional: true
derived_env: derived_env:
- key: BTCPAY_HOST - key: BTCPAY_HOST
template: "{{HOST_IP}}:23000" template: "{{HOST_IP}}:23000"
@ -56,10 +50,6 @@ app:
- ASPNETCORE_URLS=http://0.0.0.0:49392 - ASPNETCORE_URLS=http://0.0.0.0:49392
- BTCPAY_PROTOCOL=http - BTCPAY_PROTOCOL=http
- BTCPAY_CHAINS=btc - BTCPAY_CHAINS=btc
# Plugins must live on the persistent volume: the image default
# (/root/.btcpayserver/Plugins) is container-local, so every recreate
# silently wiped installed plugins.
- BTCPAY_PLUGINDIR=/datadir/Plugins
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 - BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 - BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
- BTCPAY_BTCRPCUSER=archipelago - BTCPAY_BTCRPCUSER=archipelago

View File

@ -27,7 +27,7 @@ app:
apparmor_profile: did-wallet apparmor_profile: did-wallet
ports: ports:
- host: 8088 - host: 8083
container: 8080 container: 8080
protocol: tcp # Web UI protocol: tcp # Web UI
@ -42,7 +42,7 @@ app:
health_check: health_check:
type: http type: http
endpoint: http://127.0.0.1:8080 endpoint: http://localhost:8083
path: /health path: /health
interval: 30s interval: 30s
timeout: 5s timeout: 5s

View File

@ -5,7 +5,7 @@ app:
description: Fedimint gateway service with automatic LND-or-LDK backend selection. description: Fedimint gateway service with automatic LND-or-LDK backend selection.
container: container:
image: 146.59.87.168:3000/lfg2025/gatewayd:v0.10.0 image: git.tx1138.com/lfg2025/gatewayd:v0.10.0
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
entrypoint: ["sh", "-lc"] entrypoint: ["sh", "-lc"]

View File

@ -5,7 +5,7 @@ app:
description: Baseline Archipelago file manager service. description: Baseline Archipelago file manager service.
container: container:
image: 146.59.87.168:3000/lfg2025/filebrowser:v2.27.0 image: git.tx1138.com/lfg2025/filebrowser:v2.27.0
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
custom_args: ["--config", "/data/.filebrowser.json"] custom_args: ["--config", "/data/.filebrowser.json"]

View File

@ -30,13 +30,7 @@ app:
disk_limit: 200Gi disk_limit: 200Gi
security: security:
# Runs as container root over a data tree the legacy installer chowned capabilities: []
# to the subuid range (host 100000 = container uid 1). Without
# DAC_OVERRIDE the server EACCESes writing upload/encoded-video the
# moment the container is recreated against this manifest (latent until
# the 2026-07-05 secret-env migration recreated it). Same cap set as
# immich-postgres minus the setuid pair it doesn't use.
capabilities: [CHOWN, DAC_OVERRIDE, FOWNER]
readonly_root: false readonly_root: false
network_policy: isolated network_policy: isolated

View File

@ -12,7 +12,7 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}" FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}"
VERSION="${1:-latest}" VERSION="${1:-latest}"
REGISTRY="${REGISTRY:-146.59.87.168:3000}" REGISTRY="${REGISTRY:-git.tx1138.com}"
NAMESPACE="${NAMESPACE:-lfg2025}" NAMESPACE="${NAMESPACE:-lfg2025}"
IMAGE_NAME="indeedhub" IMAGE_NAME="indeedhub"
RUNTIME="${RUNTIME:-podman}" RUNTIME="${RUNTIME:-podman}"

View File

@ -29,13 +29,13 @@ app:
apparmor_profile: lightning-stack apparmor_profile: lightning-stack
ports: ports:
- host: 9738 - host: 9737
container: 9735 container: 9735
protocol: tcp # P2P protocol: tcp # P2P
- host: 10010 - host: 10010
container: 10009 container: 10009
protocol: tcp # gRPC protocol: tcp # gRPC
- host: 8091 - host: 8087
container: 8080 container: 8080
protocol: tcp # REST/Web UI protocol: tcp # REST/Web UI
@ -53,7 +53,7 @@ app:
health_check: health_check:
type: http type: http
endpoint: http://127.0.0.1:8080 endpoint: http://localhost:8087
path: /v1/getinfo path: /v1/getinfo
interval: 30s interval: 30s
timeout: 5s timeout: 5s

View File

@ -8,13 +8,6 @@ app:
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
# BITCOIND_HOST must follow the node's actual Bitcoin container — Knots or
# Core — resolved at apply time from host facts. Hardcoding either breaks
# LND's chain backend connection on the other (lnd.conf is likewise
# resolved in lnd::ensure_config).
derived_env:
- key: BITCOIND_HOST
template: "{{BITCOIN_HOST}}"
secret_env: secret_env:
- key: BITCOIND_RPCPASS - key: BITCOIND_RPCPASS
secret_file: bitcoin-rpc-password secret_file: bitcoin-rpc-password
@ -52,6 +45,7 @@ app:
options: [rw] options: [rw]
environment: environment:
- BITCOIND_HOST=bitcoin-knots
- BITCOIND_RPCUSER=archipelago - BITCOIND_RPCUSER=archipelago
- NETWORK=mainnet - NETWORK=mainnet

View File

@ -5,7 +5,7 @@ app:
description: Backend API for mempool explorer. description: Backend API for mempool explorer.
container: container:
image: 146.59.87.168:3000/lfg2025/mempool-backend:v3.0.0 image: git.tx1138.com/lfg2025/mempool-backend:v3.0.0
pull_policy: if-not-present pull_policy: if-not-present
network: archy-net network: archy-net
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or # CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or

View File

@ -27,7 +27,7 @@ app:
apparmor_profile: morphos-server apparmor_profile: morphos-server
ports: ports:
- host: 8089 - host: 8086
container: 8080 container: 8080
protocol: tcp # Web UI protocol: tcp # Web UI
@ -43,7 +43,7 @@ app:
health_check: health_check:
type: http type: http
endpoint: http://127.0.0.1:8080 endpoint: http://localhost:8086
path: /health path: /health
interval: 30s interval: 30s
timeout: 5s timeout: 5s

View File

@ -21,188 +21,28 @@ app:
capabilities: [] capabilities: []
readonly_root: true readonly_root: true
no_new_privileges: true no_new_privileges: true
user: 1000
seccomp_profile: default seccomp_profile: default
network_policy: isolated network_policy: isolated
apparmor_profile: nostr-relay apparmor_profile: nostr-relay
ports: ports:
- host: 8090 - host: 8082
container: 7777 container: 8080
protocol: tcp # HTTP/WebSocket (strfry listens on 7777) protocol: tcp # HTTP/WebSocket
volumes: volumes:
- type: bind - type: bind
source: /var/lib/archipelago/strfry source: /var/lib/archipelago/strfry
target: /app/strfry-db target: /strfry
options: [rw] options: [rw]
# Image default config demands a 1M NOFILES rlimit, above the rootless
# user-manager hard cap (524288) — ship the config with nofiles = 0.
# Mounting it also skips the entrypoint's copy into /etc, which a
# readonly_root container cannot do.
- type: bind
source: /var/lib/archipelago/strfry-config/strfry.conf
target: /etc/strfry.conf
options: [ro]
files: environment:
- path: /var/lib/archipelago/strfry-config/strfry.conf - RELAY_NAME=Archipelago Strfry Relay
overwrite: true
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 = "Archipelago Strfry Relay"
# NIP-11: Detailed information about relay, free-form
description = "Self-hosted strfry Nostr relay on Archipelago."
# 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 = "/app/write-policy.py"
}
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: health_check:
type: http type: http
# In-container probe: must target the CONTAINER port (7777), not the host endpoint: http://localhost:8082
# mapping (8090), and 127.0.0.1 explicitly — `localhost` resolves to ::1
# inside the image while strfry binds IPv4 0.0.0.0 only (verified on .228:
# localhost:7777 refused, 127.0.0.1:7777/health = 200).
endpoint: http://127.0.0.1:7777
path: /health path: /health
interval: 30s interval: 30s
timeout: 5s timeout: 5s

82
core/Cargo.lock generated
View File

@ -99,7 +99,6 @@ version = "1.7.99-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
"archipelago-openwrt",
"archipelago-performance", "archipelago-performance",
"archipelago-security", "archipelago-security",
"argon2", "argon2",
@ -140,7 +139,6 @@ dependencies = [
"reqwest 0.11.27", "reqwest 0.11.27",
"sd-notify", "sd-notify",
"serde", "serde",
"serde_bytes",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"serial2-tokio", "serial2-tokio",
@ -170,7 +168,6 @@ dependencies = [
"async-trait", "async-trait",
"chrono", "chrono",
"futures", "futures",
"hex",
"hyper 0.14.32", "hyper 0.14.32",
"indexmap", "indexmap",
"log", "log",
@ -178,29 +175,12 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"sha2 0.10.9",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tracing", "tracing",
"uuid", "uuid",
] ]
[[package]]
name = "archipelago-openwrt"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"reqwest 0.11.27",
"serde",
"serde_json",
"ssh2",
"thiserror 1.0.69",
"tokio",
"tokio-test",
"tracing",
]
[[package]] [[package]]
name = "archipelago-performance" name = "archipelago-performance"
version = "0.1.0" version = "0.1.0"
@ -2860,32 +2840,6 @@ dependencies = [
"redox_syscall 0.7.3", "redox_syscall 0.7.3",
] ]
[[package]]
name = "libssh2-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libz-sys"
version = "1.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.11.0" version = "0.11.0"
@ -3627,18 +3581,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "papaya" name = "papaya"
version = "0.2.4" version = "0.2.4"
@ -3817,12 +3759,6 @@ dependencies = [
"spki 0.8.0", "spki 0.8.0",
] ]
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]] [[package]]
name = "plain" name = "plain"
version = "0.2.3" version = "0.2.3"
@ -5053,18 +4989,6 @@ dependencies = [
"der 0.8.0", "der 0.8.0",
] ]
[[package]]
name = "ssh2"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8"
dependencies = [
"bitflags 2.13.0",
"libc",
"libssh2-sys",
"parking_lot 0.12.5",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@ -5852,12 +5776,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "vergen" name = "vergen"
version = "9.1.0" version = "9.1.0"

View File

@ -4,7 +4,6 @@ resolver = "2"
members = [ members = [
"archipelago", "archipelago",
"container", "container",
"openwrt",
"performance", "performance",
"security", "security",
] ]

View File

@ -43,7 +43,6 @@ futures-util = "0.3"
# Our modules # Our modules
archipelago-container = { path = "../container" } archipelago-container = { path = "../container" }
archipelago-openwrt = { path = "../openwrt" }
archipelago-security = { path = "../security" } archipelago-security = { path = "../security" }
archipelago-performance = { path = "../performance" } archipelago-performance = { path = "../performance" }
@ -110,7 +109,6 @@ hkdf = "0.12.4"
# Transport abstraction (Phase 2: mesh as federation transport) # Transport abstraction (Phase 2: mesh as federation transport)
ciborium = "0.2.2" ciborium = "0.2.2"
serde_bytes = "0.11"
reed-solomon-erasure = "6.0" reed-solomon-erasure = "6.0"
mdns-sd = "0.18" mdns-sd = "0.18"

View File

@ -126,15 +126,15 @@ impl ApiHandler {
} }
/// Server-side fetch of the upstream app catalog so the browser can /// Server-side fetch of the upstream app catalog so the browser can
/// load it without fighting CORS (upstream Gitea emits no ACAO) or /// load it without fighting CORS (git.tx1138.com emits no ACAO) or
/// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream /// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream
/// list is derived from the operator's configured container registries /// list is derived from the operator's configured container registries
/// so switching mirrors in Settings changes the App Store source too — /// so switching mirrors in Settings changes the App Store source too —
/// each active registry contributes one Gitea `raw/branch/main/catalog.json` /// each active registry contributes one Gitea `raw/branch/main/catalog.json`
/// URL (http or https per `tls_verify`), tried in priority order. /// URL (http or https per `tls_verify`), tried in priority order.
/// If registry config can't be loaded, falls back to the hardcoded OVH /// If registry config can't be loaded, falls back to the legacy
/// URL so the App Store still renders on nodes that haven't persisted /// hardcoded pair so the App Store still renders on nodes that haven't
/// a registry config yet. 15s total timeout. /// persisted a registry config yet. 15s total timeout.
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> { async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
let mut upstreams: Vec<String> = Vec::new(); let mut upstreams: Vec<String> = Vec::new();
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
@ -155,6 +155,10 @@ impl ApiHandler {
"http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json" "http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
.to_string(), .to_string(),
); );
upstreams.push(
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json"
.to_string(),
);
} }
let client = match reqwest::Client::builder() let client = match reqwest::Client::builder()
@ -523,7 +527,7 @@ impl ApiHandler {
// App-catalog proxy — fetches catalog.json from the configured // App-catalog proxy — fetches catalog.json from the configured
// upstream URLs server-side so the browser doesn't hit CORS // upstream URLs server-side so the browser doesn't hit CORS
// (upstream Gitea has no ACAO header) or CSP (IP-port upstream // (git.tx1138.com has no ACAO header) or CSP (IP-port upstream
// falls outside `connect-src`). Session-authenticated so only // falls outside `connect-src`). Session-authenticated so only
// the logged-in node owner can spin up fetches. // the logged-in node owner can spin up fetches.
(Method::GET, "/api/app-catalog") => { (Method::GET, "/api/app-catalog") => {

View File

@ -39,17 +39,6 @@ impl ApiHandler {
let (mut tx, mut rx) = ws_stream.split(); let (mut tx, mut rx) = ws_stream.split();
// Subscribe BEFORE taking the initial snapshot. Messages are full
// data dumps keyed by a monotonic revision, so a broadcast that
// races the snapshot is at worst a harmless duplicate/newer dump
// delivered right after — but subscribing after the snapshot send
// (the old order) let any update in that window vanish forever,
// since a tokio broadcast channel never delivers sends that
// predate subscribe(). That silently stuck clients (e.g. a fresh
// install's post-boot container scan) on a stale initial snapshot
// until a full page reload opened a new connection past the race.
let mut state_rx = state_manager.subscribe();
let initial_msg = state_manager.get_initial_message().await; let initial_msg = state_manager.get_initial_message().await;
if let Ok(json_msg) = serde_json::to_string(&initial_msg) { if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
if let Err(e) = tx.send(Message::Text(json_msg)).await { if let Err(e) = tx.send(Message::Text(json_msg)).await {
@ -58,6 +47,8 @@ impl ApiHandler {
} }
debug!("Sent initial data dump at revision {}", initial_msg.rev); debug!("Sent initial data dump at revision {}", initial_msg.rev);
} }
let mut state_rx = state_manager.subscribe();
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30)); let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
tokio::pin!(ping_interval); tokio::pin!(ping_interval);
let mut last_client_activity = Instant::now(); let mut last_client_activity = Instant::now();

View File

@ -1,6 +1,4 @@
use super::RpcHandler; use super::{RpcHandler, DEV_DEFAULT_PASSWORD};
#[cfg(debug_assertions)]
use super::DEV_DEFAULT_PASSWORD;
use anyhow::Result; use anyhow::Result;
impl RpcHandler { impl RpcHandler {
@ -16,10 +14,7 @@ impl RpcHandler {
let is_setup = self.auth_manager.is_setup().await?; let is_setup = self.auth_manager.is_setup().await?;
if !is_setup { if !is_setup {
// Dev BUILDS only: allow the default password so the UI can log // Dev mode: allow default password so UI can log in without running setup
// in without running setup. cfg-gated so no release binary can
// carry the bypass, whatever its runtime config says.
#[cfg(debug_assertions)]
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD { if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
tracing::info!("[onboarding] login via dev default password"); tracing::info!("[onboarding] login via dev default password");
return Ok(serde_json::Value::Null); return Ok(serde_json::Value::Null);
@ -146,19 +141,6 @@ impl RpcHandler {
self.auth_manager.setup_user(password).await?; self.auth_manager.setup_user(password).await?;
tracing::info!("[onboarding] user setup complete"); tracing::info!("[onboarding] user setup complete");
// Persist the pending onboarding seed as the encrypted backup now that
// a passphrase (the login password) finally exists — otherwise "Reveal
// recovery phrase" has nothing to decrypt on this node, ever.
// Best-effort: a failure here must not break password setup.
match super::seed_rpc::save_pending_seed_encrypted(&self.config.data_dir, password).await {
Ok(true) => tracing::info!("[onboarding] encrypted seed backup saved"),
Ok(false) => tracing::info!(
"[onboarding] no pending mnemonic to back up (restored earlier or legacy node)"
),
Err(e) => tracing::warn!("[onboarding] encrypted seed backup failed: {e:#}"),
}
Ok(serde_json::json!(true)) Ok(serde_json::json!(true))
} }

View File

@ -862,9 +862,7 @@ async fn hydrate_tor_endpoint(data_dir: &Path, state: &mut BitcoinRelayState) {
let onion = onion.trim().trim_end_matches('/').to_string(); let onion = onion.trim().trim_end_matches('/').to_string();
if !onion.is_empty() { if !onion.is_empty() {
state.settings.tor_endpoint = Some(format!("http://{onion}/")); state.settings.tor_endpoint = Some(format!("http://{onion}/"));
if let Err(e) = save_relay_state(data_dir, state).await { let _ = save_relay_state(data_dir, state).await;
tracing::warn!("Failed to persist relay tor endpoint: {e:#}");
}
} }
} }
} }

View File

@ -267,16 +267,13 @@ impl RpcHandler {
.context("Failed to connect to peer")?; .context("Failed to connect to peer")?;
// Record which transport actually reached the peer (B14) so the UI // Record which transport actually reached the peer (B14) so the UI
// reflects FIPS vs Tor truthfully instead of always showing Tor/none. // reflects FIPS vs Tor truthfully instead of always showing Tor/none.
if let Err(e) = crate::federation::record_peer_transport( let _ = crate::federation::record_peer_transport(
&self.config.data_dir, &self.config.data_dir,
None, None,
Some(onion), Some(onion),
&transport.to_string(), &transport.to_string(),
) )
.await .await;
{
tracing::warn!("Failed to persist peer transport badge: {e:#}");
}
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
let body: serde_json::Value = response.json().await.unwrap_or_default(); let body: serde_json::Value = response.json().await.unwrap_or_default();
@ -351,16 +348,13 @@ impl RpcHandler {
.await .await
.context("Failed to connect to peer")?; .context("Failed to connect to peer")?;
// Record which transport actually reached the peer (B14). // Record which transport actually reached the peer (B14).
if let Err(e) = crate::federation::record_peer_transport( let _ = crate::federation::record_peer_transport(
&self.config.data_dir, &self.config.data_dir,
None, None,
Some(onion), Some(onion),
&transport.to_string(), &transport.to_string(),
) )
.await .await;
{
tracing::warn!("Failed to persist peer transport badge: {e:#}");
}
if !response.status().is_success() { if !response.status().is_success() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
@ -503,16 +497,13 @@ impl RpcHandler {
} }
}; };
// Record which transport actually reached the peer (B14). // Record which transport actually reached the peer (B14).
if let Err(e) = crate::federation::record_peer_transport( let _ = crate::federation::record_peer_transport(
&self.config.data_dir, &self.config.data_dir,
None, None,
Some(onion), Some(onion),
&transport.to_string(), &transport.to_string(),
) )
.await .await;
{
tracing::warn!("Failed to persist peer transport badge: {e:#}");
}
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
// Payment was rejected by the seller. Surface the most likely cause // Payment was rejected by the seller. Surface the most likely cause
@ -772,16 +763,13 @@ impl RpcHandler {
})); }));
} }
}; };
if let Err(e) = crate::federation::record_peer_transport( let _ = crate::federation::record_peer_transport(
&self.config.data_dir, &self.config.data_dir,
None, None,
Some(onion), Some(onion),
&transport.to_string(), &transport.to_string(),
) )
.await .await;
{
tracing::warn!("Failed to persist peer transport badge: {e:#}");
}
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
return Ok(serde_json::json!({ return Ok(serde_json::json!({
@ -963,16 +951,13 @@ impl RpcHandler {
})); }));
} }
}; };
if let Err(e) = crate::federation::record_peer_transport( let _ = crate::federation::record_peer_transport(
&self.config.data_dir, &self.config.data_dir,
None, None,
Some(onion), Some(onion),
&transport.to_string(), &transport.to_string(),
) )
.await .await;
{
tracing::warn!("Failed to persist peer transport badge: {e:#}");
}
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
return Ok(serde_json::json!({ return Ok(serde_json::json!({
@ -1034,16 +1019,13 @@ impl RpcHandler {
.await .await
.context("Failed to connect to peer for preview")?; .context("Failed to connect to peer for preview")?;
// Record which transport actually reached the peer (B14). // Record which transport actually reached the peer (B14).
if let Err(e) = crate::federation::record_peer_transport( let _ = crate::federation::record_peer_transport(
&self.config.data_dir, &self.config.data_dir,
None, None,
Some(onion), Some(onion),
&transport.to_string(), &transport.to_string(),
) )
.await .await;
{
tracing::warn!("Failed to persist peer transport badge: {e:#}");
}
if !response.status().is_success() { if !response.status().is_success() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(

View File

@ -223,7 +223,6 @@ impl RpcHandler {
"network.list-interfaces" => self.handle_network_list_interfaces().await, "network.list-interfaces" => self.handle_network_list_interfaces().await,
"network.scan-wifi" => self.handle_network_scan_wifi().await, "network.scan-wifi" => self.handle_network_scan_wifi().await,
"network.configure-wifi" => self.handle_network_configure_wifi(params).await, "network.configure-wifi" => self.handle_network_configure_wifi(params).await,
"network.set-wifi-radio" => self.handle_network_set_wifi_radio(params).await,
"network.configure-ethernet" => self.handle_network_configure_ethernet(params).await, "network.configure-ethernet" => self.handle_network_configure_ethernet(params).await,
"network.dns-status" => self.handle_network_dns_status().await, "network.dns-status" => self.handle_network_dns_status().await,
"network.configure-dns" => self.handle_network_configure_dns(params).await, "network.configure-dns" => self.handle_network_configure_dns(params).await,
@ -231,13 +230,6 @@ impl RpcHandler {
"router.info" => self.handle_router_info().await, "router.info" => self.handle_router_info().await,
"router.configure" => self.handle_router_configure(params).await, "router.configure" => self.handle_router_configure(params).await,
// OpenWrt / TollGate
"openwrt.scan" => self.handle_openwrt_scan(params).await,
"openwrt.get-status" => self.handle_openwrt_get_status(params).await,
"openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(params).await,
"openwrt.scan-wifi" => self.handle_openwrt_scan_wifi(params).await,
"openwrt.configure-wan" => self.handle_openwrt_configure_wan(params).await,
// Ecash wallet // Ecash wallet
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await, "wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
"wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await, "wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await,

View File

@ -18,24 +18,6 @@ impl RpcHandler {
Ok(serde_json::json!({ "networks": networks })) Ok(serde_json::json!({ "networks": networks }))
} }
/// network.set-wifi-radio — turn the wifi adapter fully on or off (not just
/// disconnect from a network). Params: `{ "enabled": bool }`.
pub(super) async fn handle_network_set_wifi_radio(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: enabled"))?;
tracing::info!(enabled, "Setting wifi radio state");
set_wifi_radio(enabled).await?;
Ok(serde_json::json!({ "ok": true, "enabled": enabled }))
}
/// network.configure-wifi — connect to a WiFi network. /// network.configure-wifi — connect to a WiFi network.
pub(super) async fn handle_network_configure_wifi( pub(super) async fn handle_network_configure_wifi(
&self, &self,
@ -345,27 +327,6 @@ fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
fields fields
} }
/// Turn the wifi radio fully on or off using nmcli (a rfkill-level toggle, not
/// just disconnecting from the current network — the adapter stops scanning/
/// associating entirely until switched back on).
async fn set_wifi_radio(enabled: bool) -> Result<()> {
let state = if enabled { "on" } else { "off" };
let output = tokio::process::Command::new("nmcli")
.args(["radio", "wifi", state])
.output()
.await
.context("Failed to run nmcli radio wifi")?;
if !output.status.success() {
anyhow::bail!(
"nmcli radio wifi {} failed: {}",
state,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
/// Connect to a WiFi network using nmcli. /// Connect to a WiFi network using nmcli.
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> { async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
let conn_name = format!("archipelago-wifi-{ssid}"); let conn_name = format!("archipelago-wifi-{ssid}");

View File

@ -1,7 +1,6 @@
use super::super::RpcHandler; use super::super::RpcHandler;
use crate::mesh; use crate::mesh;
use anyhow::Result; use anyhow::Result;
use tracing::warn;
impl RpcHandler { impl RpcHandler {
/// mesh.status — Get mesh radio status, device info, and peer count. /// mesh.status — Get mesh radio status, device info, and peer count.
@ -284,9 +283,7 @@ impl RpcHandler {
let mut set = state.radio_contact_blocklist.write().await; let mut set = state.radio_contact_blocklist.write().await;
set.clear(); set.clear();
} }
if let Err(e) = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await { let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await;
warn!("Failed to persist cleared radio-contact blocklist: {e:#}");
}
// Actually DELETE each radio contact from the firmware table (via // Actually DELETE each radio contact from the firmware table (via
// CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next // CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next

View File

@ -67,32 +67,6 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Container", "Container",
"Image", "Image",
"Bitcoin address", "Bitcoin address",
"No router",
"No OpenWrt",
"No space left",
"Not enough flash",
"Not enough space",
"TollGate installation failed",
"No pre-built TollGate",
"opkg not found",
"apk update failed",
"No wireless interface",
"No wireless radio",
"WiFi radio enabled but",
"Missing required field",
// seed.reveal / auth flows — user-actionable, no internals to leak.
// Without these the sanitizer collapsed every reveal failure into
// "Operation failed. Check server logs." (which isn't even a crash).
"Incorrect",
"This node has no encrypted seed",
"A 2FA code is required",
"2FA is enabled but",
"Could not decrypt the saved seed",
"Could not unlock 2FA",
"No mnemonic available",
"No pending seed generation",
"Submitted words",
"Already set up",
]; ];
for prefix in &user_facing_prefixes { for prefix in &user_facing_prefixes {
if msg.starts_with(prefix) { if msg.starts_with(prefix) {
@ -112,43 +86,6 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Operation failed. Check server logs for details.".to_string() "Operation failed. Check server logs for details.".to_string()
} }
#[cfg(test)]
mod sanitize_tests {
use super::sanitize_error_message;
#[test]
fn seed_reveal_errors_pass_through() {
// Every user-actionable seed.reveal failure must reach the user —
// masking them as "Check server logs" sent a real user hunting a
// crash that never happened.
for msg in [
"Incorrect password",
"This node has no encrypted seed backup, so the recovery phrase cannot be shown. It was only displayed once during setup.",
"A 2FA code is required to reveal the recovery phrase",
"2FA is enabled but no TOTP data found",
"Could not decrypt the saved seed. If you set a separate backup passphrase during setup, enter that passphrase.",
"Could not unlock 2FA with this password",
"No mnemonic available. Generate or restore a seed first.",
"Submitted words do not match generated seed",
"Already set up. Use auth.changePassword to change.",
] {
assert_ne!(
sanitize_error_message(msg),
"Operation failed. Check server logs for details.",
"masked: {msg}"
);
}
}
#[test]
fn internal_errors_stay_generic() {
assert_eq!(
sanitize_error_message("thread panicked at src/foo.rs:42"),
"Operation failed. Check server logs for details."
);
}
}
/// Derive a CSRF token from the session token via HMAC. /// Derive a CSRF token from the session token via HMAC.
/// Deterministic: same session token always produces the same CSRF token. /// Deterministic: same session token always produces the same CSRF token.
/// Survives backend restarts because it depends only on the session token /// Survives backend restarts because it depends only on the session token
@ -182,91 +119,13 @@ pub(super) fn extract_cookie(headers: &hyper::HeaderMap, name: &str) -> Option<S
None None
} }
/// The TCP peer address of the connection a request arrived on, injected /// Extract the client IP from request headers (X-Real-IP or X-Forwarded-For).
/// into request extensions by the server accept loop. pub(super) fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr {
#[derive(Debug, Clone, Copy)]
pub struct PeerAddr(pub std::net::SocketAddr);
/// Extract the client IP for rate limiting.
///
/// `X-Real-IP`/`X-Forwarded-For` are only honored when the connection
/// itself comes from loopback — i.e. from our local nginx, which sets
/// `X-Real-IP $remote_addr`. On a direct connection (the FIPS peer
/// listener, or anything that isn't the local proxy) the headers are
/// client-supplied, so trusting them let an attacker rotate per-request
/// "IPs" and defeat the login rate limiter; there we use the socket
/// address instead.
pub(super) fn extract_client_ip(parts: &hyper::http::request::Parts) -> IpAddr {
let socket_ip = parts.extensions.get::<PeerAddr>().map(|p| p.0.ip());
match socket_ip {
Some(ip) if ip.is_loopback() => forwarded_client_ip(&parts.headers).unwrap_or(ip),
Some(ip) => ip,
// No socket info recorded (shouldn't happen in the server path);
// fall back to the pre-extension behavior.
None => forwarded_client_ip(&parts.headers)
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
}
}
/// The proxy-reported client IP, if a forwarded header carries one.
fn forwarded_client_ip(headers: &hyper::HeaderMap) -> Option<IpAddr> {
headers headers
.get("x-real-ip") .get("x-real-ip")
.or_else(|| headers.get("x-forwarded-for")) .or_else(|| headers.get("x-forwarded-for"))
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next()) .and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse::<IpAddr>().ok()) .and_then(|s| s.trim().parse::<IpAddr>().ok())
} .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
#[cfg(test)]
mod client_ip_tests {
use super::*;
use std::net::SocketAddr;
fn parts_with(
peer: Option<&str>,
real_ip: Option<&str>,
) -> hyper::http::request::Parts {
let mut builder = hyper::Request::builder().uri("/rpc/v1");
if let Some(ip) = real_ip {
builder = builder.header("x-real-ip", ip);
}
let (mut parts, _) = builder.body(()).unwrap().into_parts();
if let Some(addr) = peer {
parts
.extensions
.insert(PeerAddr(addr.parse::<SocketAddr>().unwrap()));
}
parts
}
#[test]
fn loopback_connection_trusts_forwarded_header() {
// nginx on loopback forwards the real client IP — use it.
let parts = parts_with(Some("127.0.0.1:44412"), Some("192.168.1.50"));
assert_eq!(
extract_client_ip(&parts),
"192.168.1.50".parse::<IpAddr>().unwrap()
);
}
#[test]
fn direct_connection_ignores_spoofed_header() {
// A direct (non-proxy) client rotating X-Real-IP per request must
// still bucket under its socket address.
let parts = parts_with(Some("203.0.113.9:9999"), Some("10.0.0.1"));
assert_eq!(
extract_client_ip(&parts),
"203.0.113.9".parse::<IpAddr>().unwrap()
);
}
#[test]
fn loopback_connection_without_header_uses_socket_ip() {
let parts = parts_with(Some("127.0.0.1:5000"), None);
assert_eq!(
extract_client_ip(&parts),
"127.0.0.1".parse::<IpAddr>().unwrap()
);
}
} }

View File

@ -23,7 +23,6 @@ mod names;
mod network; mod network;
mod node; mod node;
mod nostr; mod nostr;
mod openwrt;
mod package; mod package;
mod peers; mod peers;
mod response; mod response;
@ -58,13 +57,9 @@ use middleware::{
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message, derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS, CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
}; };
pub use middleware::PeerAddr;
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse}; use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
/// Default dev password when no user is set up (matches mock-backend). /// Default dev password when no user is set up (matches mock-backend).
/// Dev builds only — the pre-setup login bypass that reads this is
/// cfg-gated out of release binaries.
#[cfg(debug_assertions)]
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123"; pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
pub struct RpcHandler { pub struct RpcHandler {
@ -373,7 +368,7 @@ impl RpcHandler {
// Rate limit login attempts // Rate limit login attempts
if rpc_req.method == "auth.login" { if rpc_req.method == "auth.login" {
let client_ip = extract_client_ip(&parts); let client_ip = extract_client_ip(&parts.headers);
if !self.login_rate_limiter.check(client_ip).await { if !self.login_rate_limiter.check(client_ip).await {
return Ok(self.rate_limit_response()); return Ok(self.rate_limit_response());
} }
@ -381,7 +376,7 @@ impl RpcHandler {
// Rate limit sensitive endpoints // Rate limit sensitive endpoints
{ {
let client_ip = extract_client_ip(&parts); let client_ip = extract_client_ip(&parts.headers);
if !self if !self
.endpoint_rate_limiter .endpoint_rate_limiter
.check(&rpc_req.method, client_ip) .check(&rpc_req.method, client_ip)
@ -455,7 +450,7 @@ impl RpcHandler {
let mut response = json_response(StatusCode::OK, &resp_body); let mut response = json_response(StatusCode::OK, &resp_body);
// Post-dispatch: set cookies for auth-related methods // Post-dispatch: set cookies for auth-related methods
let client_ip = extract_client_ip(&parts); let client_ip = extract_client_ip(&parts.headers);
self.apply_auth_cookies( self.apply_auth_cookies(
&rpc_req.method, &rpc_req.method,
&mut rpc_resp, &mut rpc_resp,

View File

@ -1,353 +0,0 @@
use super::RpcHandler;
use anyhow::Result;
use archipelago_openwrt::{
detect,
router::Router,
tollgate::{self, TollGateConfig},
wan,
wifi_scan,
};
use crate::network::router as net_router;
/// Default port for the local Cashu mint (nutshell / cashu-mint app).
const LOCAL_MINT_PORT: u16 = 3338;
impl RpcHandler {
/// Scan the local subnet for OpenWrt routers.
///
/// Params: `{ "subnet": "192.168.1.0", "prefix": 24,
/// "ssh_user": "root", "ssh_password": "" }`
pub(super) async fn handle_openwrt_scan(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let p = params.unwrap_or_default();
let subnet: [u8; 4] = parse_ipv4(
p.get("subnet").and_then(|v| v.as_str()).unwrap_or("192.168.1.0"),
)?;
let prefix = p.get("prefix").and_then(|v| v.as_u64()).unwrap_or(24) as u8;
let ssh_user = p
.get("ssh_user")
.and_then(|v| v.as_str())
.unwrap_or("root")
.to_string();
let ssh_password = p
.get("ssh_password")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let routers = detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password).await;
let ips: Vec<String> = routers.iter().map(|ip| ip.to_string()).collect();
Ok(serde_json::json!({ "routers": ips }))
}
/// Read current settings from a saved or ad-hoc OpenWrt router via SSH/UCI.
///
/// Params (all optional): `{ "host": "...", "ssh_user": "root", "ssh_password": "" }`
/// If params are omitted the saved `router_config.json` credentials are used.
pub(super) async fn handle_openwrt_get_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let saved = net_router::load_router_config(&self.config.data_dir).await?;
let p = params.unwrap_or_default();
let host_from_params = p.get("host").and_then(|v| v.as_str()).is_some();
let host = p
.get("host")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
let ssh_user = p
.get("ssh_user")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| saved.username.clone())
.unwrap_or_else(|| "root".to_string());
let ssh_password = p
.get("ssh_password")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| saved.password.clone())
.unwrap_or_default();
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
// Persist the connection so other views (e.g. the Home dashboard's
// Network tile) can poll `openwrt.get-status` with no params instead
// of every caller needing to carry host/credentials around. Only do
// this when the host actually came from params — otherwise every
// no-args poll would re-save the same thing it just read.
if host_from_params {
let _ = net_router::configure_router(
&self.config.data_dir,
net_router::RouterType::OpenWrt,
&host,
None,
Some(&ssh_user),
Some(&ssh_password),
).await;
}
// System info
let release = router.run_ok("cat /etc/openwrt_release").unwrap_or_default();
let hostname = router
.uci_get("system.@system[0].hostname")
.unwrap_or_else(|_| "unknown".into());
let uptime_secs: u64 = router
.run_ok("cat /proc/uptime")
.unwrap_or_default()
.split_whitespace()
.next()
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// TollGate — check via opkg (≤24.x) or binary presence (25.x apk-native).
// The service binary is /usr/bin/tollgate-wrt (per its init.d script),
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
// *package* name, never an on-disk filename.
let tollgate_installed = router
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
test -f /usr/bin/tollgate-wrt 2>/dev/null")
.map(|(_, code)| code == 0)
.unwrap_or(false);
let tollgate = if tollgate_installed {
serde_json::json!({
"installed": true,
"enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false),
"metric": router.uci_get("tollgate.main.metric").unwrap_or_default(),
"step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
})
} else {
serde_json::json!({ "installed": false })
};
// WiFi interfaces
let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default();
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
let wan_status = wan::get_wan_status(&router);
Ok(serde_json::json!({
"host": host,
"hostname": hostname,
"uptime_secs": uptime_secs,
"release": parse_release(&release),
"tollgate": tollgate,
"wifi_interfaces": wifi_interfaces,
"wan": wan_status,
}))
}
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
///
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
/// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1,
/// "mint_url": "<optional override>" }`
///
/// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu
/// mint that must be running as an Archy app before calling this endpoint.
pub(super) async fn handle_openwrt_provision_tollgate(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let saved = net_router::load_router_config(&self.config.data_dir).await?;
let p = params.unwrap_or_default();
let host = p
.get("host")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
let ssh_user = p
.get("ssh_user")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| saved.username.clone())
.unwrap_or_else(|| "root".to_string());
let ssh_password = p
.get("ssh_password")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| saved.password.clone())
.unwrap_or_default();
let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT);
let mint_url = p
.get("mint_url")
.and_then(|v| v.as_str())
.unwrap_or(&default_mint_url)
.to_string();
let config = TollGateConfig {
ssid: "archipelago".to_string(),
mint_url,
price_sats: p.get("price_sats").and_then(|v| v.as_u64()).unwrap_or(10),
step_size_ms: p
.get("step_size_ms")
.and_then(|v| v.as_u64())
.unwrap_or(60_000),
min_steps: p
.get("min_steps")
.and_then(|v| v.as_u64())
.unwrap_or(1) as u32,
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
};
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
tollgate::provision(&router, &config).await?;
Ok(serde_json::json!({
"ok": true,
"host": host,
"ssid": config.ssid,
"mint_url": config.mint_url,
}))
}
/// Scan for visible WiFi networks from the router's radio.
///
/// Params: same host/credentials as other openwrt methods.
pub(super) async fn handle_openwrt_scan_wifi(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let saved = net_router::load_router_config(&self.config.data_dir).await?;
let p = params.unwrap_or_default();
let host = p.get("host").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
let ssh_user = p.get("ssh_user").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| saved.username.clone()).unwrap_or_else(|| "root".to_string());
let ssh_password = p.get("ssh_password").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| saved.password.clone()).unwrap_or_default();
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
let networks = wifi_scan::scan_networks(&router)?;
let result: Vec<serde_json::Value> = networks
.iter()
.map(|n| serde_json::json!({
"ssid": n.ssid,
"bssid": n.bssid,
"signal": n.signal,
"channel": n.channel,
"encryption": n.encryption,
}))
.collect();
Ok(serde_json::json!({ "networks": result }))
}
/// Configure WAN/WISP — connect the router to an upstream WiFi network.
///
/// Params: host/credentials + `{ "ssid": "...", "password": "...", "encryption": "psk2" }`
pub(super) async fn handle_openwrt_configure_wan(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let saved = net_router::load_router_config(&self.config.data_dir).await?;
let p = params.unwrap_or_default();
let host = p.get("host").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
let ssh_user = p.get("ssh_user").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| saved.username.clone()).unwrap_or_else(|| "root".to_string());
let ssh_password = p.get("ssh_password").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| saved.password.clone()).unwrap_or_default();
let ssid = p.get("ssid").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?.to_string();
let password = p.get("password").and_then(|v| v.as_str()).unwrap_or("").to_string();
let encryption = p.get("encryption").and_then(|v| v.as_str()).unwrap_or("psk2").to_string();
let dhcp_start = p.get("dhcp_start").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32;
let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true);
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
router.verify_openwrt()?;
let config = wan::WispConfig { ssid: ssid.clone(), password, encryption, dhcp_start, dhcp_limit, masq };
wan::configure_wisp(&router, &config)?;
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
}
}
/// Parse /etc/openwrt_release key=value pairs into a JSON object.
fn parse_release(raw: &str) -> serde_json::Value {
let mut m = serde_json::Map::new();
for line in raw.lines() {
if let Some((k, v)) = line.split_once('=') {
m.insert(
k.to_lowercase(),
serde_json::Value::String(v.trim_matches('"').to_string()),
);
}
}
serde_json::Value::Object(m)
}
/// Extract AP wifi-iface sections from `uci show wireless` output.
fn parse_wifi_interfaces(raw: &str) -> Vec<serde_json::Value> {
use std::collections::HashMap;
let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
for line in raw.lines() {
if let Some((lhs, rhs)) = line.trim().split_once('=') {
let parts: Vec<&str> = lhs.splitn(3, '.').collect();
if parts.len() == 3 && parts[0] == "wireless" {
sections
.entry(parts[1].to_string())
.or_default()
.insert(parts[2].to_string(), rhs.trim_matches('\'').to_string());
}
}
}
let mut ifaces: Vec<serde_json::Value> = sections
.into_iter()
.filter(|(_, f)| f.get("mode").map(|m| m == "ap").unwrap_or(false))
.map(|(name, f)| serde_json::json!({
"section": name,
"ssid": f.get("ssid").cloned().unwrap_or_default(),
"device": f.get("device").cloned().unwrap_or_default(),
"encryption": f.get("encryption").cloned().unwrap_or_else(|| "none".into()),
"network": f.get("network").cloned().unwrap_or_default(),
"disabled": f.get("disabled").map(|v| v == "1").unwrap_or(false),
}))
.collect();
ifaces.sort_by_key(|v| v["section"].as_str().unwrap_or("").to_string());
ifaces
}
fn parse_ipv4(s: &str) -> Result<[u8; 4]> {
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 4 {
anyhow::bail!("Invalid IPv4: {}", s);
}
Ok([
parts[0].parse()?,
parts[1].parse()?,
parts[2].parse()?,
parts[3].parse()?,
])
}

View File

@ -114,31 +114,6 @@ impl RpcHandler {
Err(e) => { Err(e) => {
error!("package.install {} failed: {:#}", package_id_spawn, e); error!("package.install {} failed: {:#}", package_id_spawn, e);
install_log(&format!("INSTALL FAIL: {}{:#}", package_id_spawn, e)).await; install_log(&format!("INSTALL FAIL: {}{:#}", package_id_spawn, e)).await;
// Dependency-gate rejections happen BEFORE any resource
// (container/image/data dir) exists for this package, so
// keeping the optimistic entry would leave a phantom
// "Stopped" tile whose Start fails with `no such object`
// (the log-confirmed LND fresh-install failure). Remove
// the entry so the card reverts to installable, and
// surface the reason as a notification instead.
if let Some(gate) = e.downcast_ref::<super::dependencies::DependencyGateError>()
{
let (mut data, _) = handler.state_manager.get_snapshot().await;
data.package_data.remove(&package_id_spawn);
data.notifications.push(crate::data_model::Notification {
id: format!("install-deps-{package_id_spawn}"),
level: crate::data_model::NotificationLevel::Error,
title: format!("Could not install {package_id_spawn}"),
message: gate.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: Some(package_id_spawn.clone()),
});
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
handler.state_manager.update_data(data).await;
return;
}
// Don't remove the entry — that's what made the card // Don't remove the entry — that's what made the card
// vanish from My Apps mid-install / between retry-loop // vanish from My Apps mid-install / between retry-loop
// attempts (e.g. tailscale's entrypoint failure). Leave // attempts (e.g. tailscale's entrypoint failure). Leave

View File

@ -94,11 +94,35 @@ async fn dynamic_app_config(
)) ))
} }
/// Validate a Docker image reference. Delegates to the shared policy in /// Trusted Docker registries. Only images from these sources are allowed.
/// `container::image_policy` — the same rules the orchestrator enforces at #[allow(dead_code)]
/// its pull sites, so the two layers can't drift apart. pub(super) const TRUSTED_REGISTRIES: &[&str] = &[
"docker.io/",
"ghcr.io/",
"localhost/",
"git.tx1138.com/",
"146.59.87.168:3000/",
];
/// Validate Docker image against trusted registry allowlist.
pub(super) fn is_valid_docker_image(image: &str) -> bool { pub(super) fn is_valid_docker_image(image: &str) -> bool {
crate::container::image_policy::is_valid_docker_image(image) if image.is_empty() || image.len() > 256 {
return false;
}
// Reject shell metacharacters
let dangerous_chars = ['&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r'];
if image.chars().any(|c| dangerous_chars.contains(&c)) {
return false;
}
// Must come from a trusted registry — match the exact domain, not just prefix
let registry = match image.split('/').next() {
Some(r) => r,
None => return false,
};
matches!(
registry,
"docker.io" | "ghcr.io" | "localhost" | "git.tx1138.com" | "146.59.87.168:3000"
)
} }
/// Per-app Linux capabilities needed beyond the default cap-drop=ALL. /// Per-app Linux capabilities needed beyond the default cap-drop=ALL.
@ -660,15 +684,10 @@ pub(super) async fn get_app_config(
), ),
"bitcoin-core" => ( "bitcoin-core" => (
vec![ vec![
// RPC + ZMQ are auth-only/unauthenticated: host-local ONLY — "8332:8332".to_string(),
// never the LAN. In-node consumers dial the container's
// archy-net alias directly; never bind the archy-net gateway
// (10.89.0.1) — rootlessport can't hold it and podman run
// fails outright (2026-07-09, .228). P2P 8333 stays public.
"127.0.0.1:8332:8332".to_string(),
"8333:8333".to_string(), "8333:8333".to_string(),
"127.0.0.1:28332:28332".to_string(), "28332:28332".to_string(),
"127.0.0.1:28333:28333".to_string(), "28333:28333".to_string(),
], ],
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()], vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
vec![], vec![],
@ -688,17 +707,12 @@ pub(super) async fn get_app_config(
// effectively pinned at 2 by --cpus=2 (now removed). // effectively pinned at 2 by --cpus=2 (now removed).
// -maxconnections=125 — default but explicit, so ops can // -maxconnections=125 — default but explicit, so ops can
// tune downward on bandwidth-constrained nodes. // tune downward on bandwidth-constrained nodes.
// Log volume: -printtoconsole=0 — bitcoind already writes
// debug.log in the datadir (self-shrunk on restart); echoing it
// to stdout too pushed every IBD "UpdateTip" line through
// conmon into journald (>1 GB/day on a fresh node). Deep
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
Some(vec![ Some(vec![
"-server=1".to_string(), "-server=1".to_string(),
"-rpcbind=0.0.0.0".to_string(), "-rpcbind=0.0.0.0".to_string(),
"-rpcallowip=0.0.0.0/0".to_string(), "-rpcallowip=0.0.0.0/0".to_string(),
"-rpcport=8332".to_string(), "-rpcport=8332".to_string(),
"-printtoconsole=0".to_string(), "-printtoconsole=1".to_string(),
"-datadir=/home/bitcoin/.bitcoin".to_string(), "-datadir=/home/bitcoin/.bitcoin".to_string(),
format!("-dbcache={}", bitcoin_dbcache_mb()), format!("-dbcache={}", bitcoin_dbcache_mb()),
"-par=0".to_string(), "-par=0".to_string(),
@ -707,15 +721,10 @@ pub(super) async fn get_app_config(
), ),
"bitcoin" | "bitcoin-knots" => ( "bitcoin" | "bitcoin-knots" => (
vec![ vec![
// RPC + ZMQ are auth-only/unauthenticated: host-local ONLY — "8332:8332".to_string(),
// never the LAN. In-node consumers dial the container's
// archy-net alias directly; never bind the archy-net gateway
// (10.89.0.1) — rootlessport can't hold it and podman run
// fails outright (2026-07-09, .228). P2P 8333 stays public.
"127.0.0.1:8332:8332".to_string(),
"8333:8333".to_string(), "8333:8333".to_string(),
"127.0.0.1:28332:28332".to_string(), "28332:28332".to_string(),
"127.0.0.1:28333:28333".to_string(), "28333:28333".to_string(),
], ],
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()], vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
vec![], vec![],

View File

@ -58,7 +58,6 @@ fn archival_bitcoin_required_message(package_id: &str) -> String {
} }
/// Snapshot of which dependency services are currently running. /// Snapshot of which dependency services are currently running.
#[derive(Debug)]
pub(super) struct RunningDeps { pub(super) struct RunningDeps {
pub has_bitcoin: bool, pub has_bitcoin: bool,
pub has_electrumx: bool, pub has_electrumx: bool,
@ -228,190 +227,6 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
} }
} }
// ---------------------------------------------------------------------------
// Bounded dependency wait (install race fix)
// ---------------------------------------------------------------------------
//
// Confirmed race on fresh nodes: the user clicks "Install LND" while
// bitcoin-knots is itself still installing/starting. `check_install_deps`
// rejected instantly ("LND requires a running Bitcoin node…") even though
// Bitcoin came up 55s later. The fix: when the dependency is INSTALLED
// (container exists in `podman ps -a`, or the package state knows about it)
// but not Running yet, poll for up to DEP_WAIT_MAX_ATTEMPTS × DEP_WAIT_INTERVAL
// (~3 minutes) before failing, surfacing "Waiting for X to start…" via the
// install-progress message. If the dependency is not installed at all, fail
// fast with the canonical `check_install_deps` message — waiting can't help.
/// Poll interval while waiting for an installed dependency to start.
pub(super) const DEP_WAIT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
/// 36 × 5s = 3 minutes of bounded waiting.
pub(super) const DEP_WAIT_MAX_ATTEMPTS: u32 = 36;
/// Marker error: the install was rejected by the dependency gate BEFORE any
/// resource (container, image, data dir) was created for the package. The
/// async install wrapper (`async_lifecycle.rs`) downcasts to this to remove
/// the optimistic `Installing` state entry instead of leaving a phantom
/// "Stopped" tile whose Start fails with `no such object`.
#[derive(Debug)]
pub(in crate::api::rpc) struct DependencyGateError(pub String);
impl std::fmt::Display for DependencyGateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for DependencyGateError {}
/// One unsatisfied install dependency: a user-facing label plus the container
/// name variants that would satisfy it.
struct MissingDep {
label: &'static str,
containers: &'static [&'static str],
}
/// Which dependencies `check_install_deps` would reject `package_id` over.
/// Must stay in lockstep with the match arms in `check_install_deps` (the
/// wait loop re-runs `check_install_deps` for the canonical error message).
fn missing_install_deps(package_id: &str, deps: &RunningDeps) -> Vec<MissingDep> {
const BITCOIN: MissingDep = MissingDep {
label: "Bitcoin",
containers: BITCOIN_NAMES,
};
const ELECTRUM: MissingDep = MissingDep {
label: "ElectrumX",
containers: ELECTRUM_NAMES,
};
let mut missing = Vec::new();
match package_id {
"electrumx" | "mempool-electrs" | "electrs" | "lnd" | "btcpay-server" | "btcpayserver" => {
if !deps.has_bitcoin {
missing.push(BITCOIN);
}
}
"mempool" | "mempool-web" => {
if !deps.has_bitcoin {
missing.push(BITCOIN);
}
if !deps.has_electrumx {
missing.push(ELECTRUM);
}
}
// fedimint deliberately absent: check_install_deps allows it without
// a local Bitcoin node (remote RPC configured in guardian setup).
_ => {}
}
missing
}
fn join_dep_labels(missing: &[MissingDep]) -> String {
missing
.iter()
.map(|d| d.label)
.collect::<Vec<_>>()
.join(" and ")
}
/// One snapshot of the dependency world, fed to [`wait_for_install_deps`].
pub(super) struct DepProbe {
/// Which dependency services are currently Running.
pub running: RunningDeps,
/// Container/package names that EXIST in any state — installed, but
/// possibly not running yet (`podman ps -a` package-state entries).
pub existing: Vec<String>,
}
/// All container names known to podman in any state (`podman ps -a`).
/// Conservative on probe failure: returns an empty list, which makes the
/// wait loop fall back to the pre-fix fail-fast behavior.
pub(super) async fn detect_existing_containers() -> Vec<String> {
let out = tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::process::Command::new("podman")
.args(["ps", "-a", "--format", "{{.Names}}"])
.output(),
)
.await;
match out {
Ok(Ok(o)) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect(),
_ => Vec::new(),
}
}
/// Bounded dependency gate. Returns the (satisfied) `RunningDeps` snapshot,
/// or a [`DependencyGateError`]:
/// - immediately, when a missing dependency is not installed at all
/// (canonical `check_install_deps` message), or
/// - after `max_attempts × interval`, when an installed dependency never
/// reached Running.
///
/// `probe` and `on_waiting` are injected so unit tests can drive the loop
/// without a podman runtime; production wires them to
/// `RpcHandler::dep_probe_for_install` / `set_install_message`.
pub(super) async fn wait_for_install_deps<P, PF, L, LF>(
package_id: &str,
mut probe: P,
mut on_waiting: L,
max_attempts: u32,
interval: std::time::Duration,
) -> Result<RunningDeps>
where
P: FnMut() -> PF,
PF: std::future::Future<Output = Result<DepProbe>>,
L: FnMut(String) -> LF,
LF: std::future::Future<Output = ()>,
{
let mut waited_attempts = 0u32;
loop {
let DepProbe { running, existing } = probe().await?;
let missing = missing_install_deps(package_id, &running);
if missing.is_empty() {
// Keep behavior in lockstep with the canonical gate (covers any
// future arm added there but not mirrored in missing_install_deps).
check_install_deps(package_id, &running)?;
return Ok(running);
}
// Fail fast if any missing dependency has no installed container
// under any name variant — waiting cannot satisfy it.
let some_dep_not_installed = missing
.iter()
.any(|dep| !dep.containers.iter().any(|c| existing.iter().any(|e| e == c)));
if some_dep_not_installed {
let msg = match check_install_deps(package_id, &running) {
Err(e) => e.to_string(),
Ok(()) => format!("{package_id} dependencies are not running"),
};
return Err(anyhow::Error::new(DependencyGateError(msg)));
}
if waited_attempts >= max_attempts {
let labels = join_dep_labels(&missing);
return Err(anyhow::Error::new(DependencyGateError(format!(
"{labels} is installed but did not reach the running state within \
{} seconds. Start {labels}, then install {package_id} again.",
u64::from(max_attempts) * interval.as_secs()
))));
}
waited_attempts += 1;
let labels = join_dep_labels(&missing);
if waited_attempts == 1 {
info!(
"Install {package_id}: dependency {labels} installed but not running yet — \
waiting up to {}s for it to start",
u64::from(max_attempts) * interval.as_secs()
);
}
on_waiting(format!("Waiting for {labels} to start…")).await;
tokio::time::sleep(interval).await;
}
}
/// ElectrumX and Mempool's Electrum backend need historical blocks from an /// ElectrumX and Mempool's Electrum backend need historical blocks from an
/// unpruned node while building their indexes. A pruned Bitcoin node can be /// unpruned node while building their indexes. A pruned Bitcoin node can be
/// running and RPC-reachable but still leave them stuck with closed ports. /// running and RPC-reachable but still leave them stuck with closed ports.
@ -472,7 +287,7 @@ pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Res
tokio::time::sleep(std::time::Duration::from_secs(2)).await; tokio::time::sleep(std::time::Duration::from_secs(2)).await;
} }
if detect_disk_gb().await < ARCHIVAL_BITCOIN_DISK_GB { if detect_disk_gb() < ARCHIVAL_BITCOIN_DISK_GB {
anyhow::bail!(archival_bitcoin_required_message(package_id)); anyhow::bail!(archival_bitcoin_required_message(package_id));
} }
@ -497,11 +312,10 @@ fn check_blockchain_info_for_pruning(package_id: &str, json: &serde_json::Value)
Ok(()) Ok(())
} }
async fn detect_disk_gb() -> u64 { fn detect_disk_gb() -> u64 {
let output = tokio::process::Command::new("df") let output = std::process::Command::new("df")
.args(["-BG", "/var/lib/archipelago"]) .args(["-BG", "/var/lib/archipelago"])
.output() .output();
.await;
let Ok(output) = output else { let Ok(output) = output else {
return u64::MAX; return u64::MAX;
}; };
@ -611,33 +425,12 @@ pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec
/// error via `?`, every later member (the api + frontend) is then skipped, /// error via `?`, every later member (the api + frontend) is then skipped,
/// leaving the stack down until the health monitor recovers it minutes later. /// leaving the stack down until the health monitor recovers it minutes later.
/// That was the source of mempool gate flakes #73 (frontend) / #74 (api). /// That was the source of mempool gate flakes #73 (frontend) / #74 (api).
/// Orchestrator APP ids for a multi-container stack, in dependency-start
/// order. Unlike `startup_order` (a union of CONTAINER-name variants across
/// install generations, sometimes including foreign dependencies), every
/// entry here is a real manifest app id the orchestrator can `start()` to
/// recreate the member from scratch.
///
/// Used when a stack has NO live containers: under quadlet, `package.stop`
/// removes every member container, so a subsequent `package.start` must
/// resurrect member-by-member. Falling back to just the package id left the
/// other members absent AND user-stopped (.228 indeedhub, 2026-07-09 — the
/// reconciler then correctly refused to revive them).
pub(super) fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
// Canonical table moved to crate::app_ops (shared with the reconciler's
// in-flight-op guard).
crate::app_ops::stack_member_app_ids(package_id)
}
fn order_present_containers(package_id: &str, containers: Vec<String>) -> Vec<String> { fn order_present_containers(package_id: &str, containers: Vec<String>) -> Vec<String> {
if containers.is_empty() { if containers.is_empty() {
// Nothing is live under any known name. For known stacks, resurrect // Nothing is live under any known name. Fall back to the package id so
// every member via its app id (see stack_member_app_ids). Otherwise // a single-container app whose container matches its id still gets one
// fall back to the package id so a single-container app whose // start attempt; multi-container stacks with no live members are
// container matches its id still gets one start attempt. // surfaced as "no containers" by the caller's emptiness check.
let members = stack_member_app_ids(package_id);
if !members.is_empty() {
return members.iter().map(|s| s.to_string()).collect();
}
return vec![package_id.to_string()]; return vec![package_id.to_string()];
} }
let order = startup_order(package_id); let order = startup_order(package_id);
@ -753,35 +546,10 @@ mod tests {
} }
#[test] #[test]
fn order_present_containers_empty_resurrects_stack_members() { fn order_present_containers_empty_falls_back_to_package_id() {
// Under quadlet, package.stop removes every member container; the
// start fallback must name each member APP id so the orchestrator can
// recreate the whole stack (.228 indeedhub, 2026-07-09) — not just
// the umbrella/package id.
assert_eq!( assert_eq!(
order_present_containers("mempool", vec![]), order_present_containers("mempool", vec![]),
vec!["archy-mempool-db", "mempool-api", "archy-mempool-web"] vec!["mempool".to_string()]
);
assert_eq!(
order_present_containers("indeedhub", vec![]),
vec![
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
]
);
assert_eq!(
order_present_containers("immich", vec![]),
vec!["immich-postgres", "immich-redis", "immich"]
);
// Single-container apps keep the package-id fallback.
assert_eq!(
order_present_containers("vaultwarden", vec![]),
vec!["vaultwarden".to_string()]
); );
} }
@ -857,218 +625,6 @@ mod tests {
assert!(!manifest_declares_archival_bitcoin("does-not-exist")); assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
} }
mod dep_wait {
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
fn deps(has_bitcoin: bool, has_electrumx: bool) -> RunningDeps {
RunningDeps {
has_bitcoin,
has_electrumx,
has_lnd: false,
}
}
fn probe(has_bitcoin: bool, has_electrumx: bool, existing: &[&str]) -> DepProbe {
DepProbe {
running: deps(has_bitcoin, has_electrumx),
existing: existing.iter().map(|s| s.to_string()).collect(),
}
}
/// Collects "Waiting for X to start…" labels emitted during the wait.
fn label_sink() -> (Arc<Mutex<Vec<String>>>, impl FnMut(String) -> std::future::Ready<()>)
{
let labels = Arc::new(Mutex::new(Vec::new()));
let sink = {
let labels = Arc::clone(&labels);
move |msg: String| {
labels.lock().unwrap().push(msg);
std::future::ready(())
}
};
(labels, sink)
}
#[tokio::test]
async fn passes_immediately_when_dependency_is_running() {
let (labels, sink) = label_sink();
let result = wait_for_install_deps(
"lnd",
|| async { Ok(probe(true, false, &["bitcoin-knots"])) },
sink,
3,
Duration::ZERO,
)
.await;
assert!(result.is_ok());
assert!(labels.lock().unwrap().is_empty(), "no waiting expected");
}
#[tokio::test]
async fn fails_fast_when_dependency_not_installed_at_all() {
let calls = AtomicU32::new(0);
let (labels, sink) = label_sink();
let err = wait_for_install_deps(
"lnd",
|| {
calls.fetch_add(1, Ordering::SeqCst);
async { Ok(probe(false, false, &["uptime-kuma"])) }
},
sink,
36,
Duration::ZERO,
)
.await
.unwrap_err();
// Single probe — no polling when waiting cannot help.
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(labels.lock().unwrap().is_empty());
// Canonical check_install_deps message, wrapped in the gate marker
// so async_lifecycle removes the optimistic Installing entry.
assert!(err.downcast_ref::<DependencyGateError>().is_some());
assert!(
err.to_string().contains("LND requires a running Bitcoin node"),
"unexpected message: {err}"
);
}
#[tokio::test]
async fn waits_while_installed_dependency_starts_then_passes() {
// Bitcoin container exists (installing/starting) but only reports
// Running from the 3rd probe onward — the log-confirmed LND race.
let calls = Arc::new(AtomicU32::new(0));
let (labels, sink) = label_sink();
let probe_calls = Arc::clone(&calls);
let result = wait_for_install_deps(
"lnd",
move || {
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
async move { Ok(probe(n >= 2, false, &["bitcoin-knots"])) }
},
sink,
36,
Duration::ZERO,
)
.await;
assert!(result.is_ok(), "{result:?}");
assert_eq!(calls.load(Ordering::SeqCst), 3);
let labels = labels.lock().unwrap();
assert_eq!(labels.len(), 2, "one waiting label per polling attempt");
assert!(labels.iter().all(|l| l == "Waiting for Bitcoin to start…"));
}
#[tokio::test]
async fn times_out_when_installed_dependency_never_runs() {
let (labels, sink) = label_sink();
let err = wait_for_install_deps(
"lnd",
|| async { Ok(probe(false, false, &["bitcoin-knots"])) },
sink,
4,
Duration::ZERO,
)
.await
.unwrap_err();
assert!(err.downcast_ref::<DependencyGateError>().is_some());
assert!(
err.to_string()
.contains("did not reach the running state within 0 seconds"),
"unexpected message: {err}"
);
assert_eq!(labels.lock().unwrap().len(), 4);
}
#[tokio::test]
async fn mempool_waits_on_both_bitcoin_and_electrumx() {
let calls = Arc::new(AtomicU32::new(0));
let (labels, sink) = label_sink();
let probe_calls = Arc::clone(&calls);
let result = wait_for_install_deps(
"mempool",
move || {
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
// Bitcoin comes up on probe 2, electrumx on probe 3.
async move { Ok(probe(n >= 1, n >= 2, &["bitcoin-knots", "electrumx"])) }
},
sink,
36,
Duration::ZERO,
)
.await;
assert!(result.is_ok(), "{result:?}");
let labels = labels.lock().unwrap();
assert_eq!(
labels.as_slice(),
&[
"Waiting for Bitcoin and ElectrumX to start…".to_string(),
"Waiting for ElectrumX to start…".to_string(),
]
);
}
#[tokio::test]
async fn mempool_fails_fast_when_one_dep_is_not_installed() {
// Bitcoin is installed (waiting could help) but ElectrumX is not
// installed at all — waiting can never satisfy the gate, so fail
// fast with the canonical message.
let (labels, sink) = label_sink();
let err = wait_for_install_deps(
"mempool",
|| async { Ok(probe(false, false, &["bitcoin-knots"])) },
sink,
36,
Duration::ZERO,
)
.await
.unwrap_err();
assert!(err.downcast_ref::<DependencyGateError>().is_some());
assert!(labels.lock().unwrap().is_empty());
assert!(
err.to_string().contains("Mempool requires"),
"unexpected message: {err}"
);
}
#[tokio::test]
async fn variant_container_names_count_as_installed() {
// bitcoin-core (not just bitcoin-knots) satisfies the "installed"
// check for the wait path.
let calls = Arc::new(AtomicU32::new(0));
let (_labels, sink) = label_sink();
let probe_calls = Arc::clone(&calls);
let result = wait_for_install_deps(
"electrumx",
move || {
let n = probe_calls.fetch_add(1, Ordering::SeqCst);
async move { Ok(probe(n >= 1, false, &["bitcoin-core"])) }
},
sink,
36,
Duration::ZERO,
)
.await;
assert!(result.is_ok(), "{result:?}");
}
#[tokio::test]
async fn apps_without_dependency_gate_pass_untouched() {
let (labels, sink) = label_sink();
let result = wait_for_install_deps(
"uptime-kuma",
|| async { Ok(probe(false, false, &[])) },
sink,
36,
Duration::ZERO,
)
.await;
assert!(result.is_ok());
assert!(labels.lock().unwrap().is_empty());
}
}
#[test] #[test]
fn mempool_api_is_directly_installable_and_covered_by_the_archival_gate() { fn mempool_api_is_directly_installable_and_covered_by_the_archival_gate() {
// `mempool-api` is a legitimate direct `package.install` target // `mempool-api` is a legitimate direct `package.install` target

View File

@ -3,10 +3,9 @@ use super::config::{
is_readonly_compatible, is_valid_docker_image, is_readonly_compatible, is_valid_docker_image,
}; };
use super::dependencies::{ use super::dependencies::{
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers, check_bitcoin_pruning_compatibility, check_install_deps, configure_fedimint_lnd,
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info, detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL, needs_archy_net, RunningDeps,
DEP_WAIT_MAX_ATTEMPTS,
}; };
use super::progress::parse_pull_progress; use super::progress::parse_pull_progress;
use super::validation::validate_app_id; use super::validation::validate_app_id;
@ -24,12 +23,6 @@ const IMAGE_INSPECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Append a timestamped line to the persistent install log. /// Append a timestamped line to the persistent install log.
pub(in crate::api::rpc) async fn install_log(msg: &str) { pub(in crate::api::rpc) async fn install_log(msg: &str) {
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
// Always mirror to tracing/journald: the file append below has been
// silently failing under the service sandbox (ProtectSystem=strict
// leaves /var/log/archipelago read-only — container-installs.log has
// been 0 bytes since April 2026), and these lifecycle breadcrumbs are
// the primary forensic trail for gate failures.
info!(target: "install_log", "{msg}");
let ts = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); let ts = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
let line = format!("[{}] {}\n", ts, msg); let line = format!("[{}] {}\n", ts, msg);
if let Ok(mut f) = tokio::fs::OpenOptions::new() if let Ok(mut f) = tokio::fs::OpenOptions::new()
@ -38,8 +31,6 @@ pub(in crate::api::rpc) async fn install_log(msg: &str) {
.open(INSTALL_LOG) .open(INSTALL_LOG)
.await .await
{ {
// Fire-and-forget by design: install-log lines are advisory, and a
// per-line warn would spam the very log stream that's failing.
let _ = f.write_all(line.as_bytes()).await; let _ = f.write_all(line.as_bytes()).await;
} }
} }
@ -274,7 +265,8 @@ impl RpcHandler {
.await; .await;
if matches!(package_id, "mempool" | "mempool-web") { if matches!(package_id, "mempool" | "mempool-web") {
self.gate_install_deps(package_id).await?; let deps = self.running_deps_for_install(package_id).await?;
check_install_deps(package_id, &deps)?;
check_bitcoin_pruning_compatibility(package_id).await?; check_bitcoin_pruning_compatibility(package_id).await?;
} }
@ -297,11 +289,9 @@ impl RpcHandler {
// Dependency checks. Prefer the scanner's cached package state so a // Dependency checks. Prefer the scanner's cached package state so a
// congested Podman API does not turn an already-running dependency into // congested Podman API does not turn an already-running dependency into
// a false install failure. Fall back to a bounded direct Podman probe // a false install failure. Fall back to a bounded direct Podman probe
// only when the cache does not show the dependency. When the dependency // only when the cache does not show the dependency.
// is installed but not Running yet (the "clicked Install LND 55s before let deps = self.running_deps_for_install(package_id).await?;
// Bitcoin was up" race), wait up to ~3 minutes for it instead of check_install_deps(package_id, &deps)?;
// failing instantly.
let deps = self.gate_install_deps(package_id).await?;
check_bitcoin_pruning_compatibility(package_id).await?; check_bitcoin_pruning_compatibility(package_id).await?;
log_optional_dep_info(package_id, &deps); log_optional_dep_info(package_id, &deps);
let repaired_bitcoin_conf = let repaired_bitcoin_conf =
@ -330,20 +320,8 @@ impl RpcHandler {
// mode). // mode).
// The adoption block is being phased out as apps move to the // The adoption block is being phased out as apps move to the
// orchestrator path. Non-orchestrator apps still hit it. // orchestrator path. Non-orchestrator apps still hit it.
let orchestrator_managed = match self.orchestrator.as_ref() { let orchestrator_managed =
// Migration allowlist OR any app whose manifest the orchestrator should_try_orchestrator_install(package_id, self.orchestrator.is_some());
// knows (disk or signed-catalog overlay). A manifest-driven app
// must never fall through to the legacy flow — it ignores the
// manifest entirely and creates a bare container with no ports or
// volumes (strfry, 2026-07-09).
Some(orch) => {
should_try_orchestrator_install(package_id, true)
|| orch
.knows_app(orchestrator_install_app_id(package_id))
.await
}
None => false,
};
// Check if container already exists (legacy adoption — non-orchestrator // Check if container already exists (legacy adoption — non-orchestrator
// apps only). // apps only).
@ -738,24 +716,6 @@ impl RpcHandler {
self.create_data_dirs(package_id, &volumes).await; self.create_data_dirs(package_id, &volumes).await;
for port in &ports { for port in &ports {
// Drop publishes whose bind address the host can't hold (e.g. the
// archy-net gateway 10.89.0.1 under rootless podman) — passing
// them through makes `podman run` itself fail and takes the app
// down. "ip:host:container" has 2 colons; plain "host:container"
// has 1 (no IPv6 binds in the legacy string table).
let bind = if port.matches(':').count() == 2 {
port.split(':').next().unwrap_or("")
} else {
""
};
if !archipelago_container::manifest::host_can_bind_publish_ip(bind) {
warn!(
app = %package_id,
publish = %port,
"dropping publish: bind address not assignable on this host"
);
continue;
}
run_args.push("-p"); run_args.push("-p");
run_args.push(port); run_args.push(port);
} }
@ -985,27 +945,6 @@ impl RpcHandler {
} }
} }
/// Bounded dependency gate for installs: passes immediately when deps are
/// running, fails fast (with the phantom-tile marker) when a dependency
/// isn't installed at all, and otherwise waits up to
/// `DEP_WAIT_MAX_ATTEMPTS × DEP_WAIT_INTERVAL` for an installed-but-
/// starting dependency, surfacing "Waiting for X to start…" on the card.
pub(super) async fn gate_install_deps(&self, package_id: &str) -> Result<RunningDeps> {
wait_for_install_deps(
package_id,
|| async {
Ok(DepProbe {
running: self.running_deps_for_install(package_id).await?,
existing: detect_existing_containers().await,
})
},
|msg| async move { self.set_install_message(package_id, &msg).await },
DEP_WAIT_MAX_ATTEMPTS,
DEP_WAIT_INTERVAL,
)
.await
}
// -- Private helpers for install -- // -- Private helpers for install --
/// Pull the image from a registry or verify a local image exists. /// Pull the image from a registry or verify a local image exists.
@ -1356,11 +1295,6 @@ impl RpcHandler {
// Default to full archive — operators with 2TB+ drives shouldn't be // Default to full archive — operators with 2TB+ drives shouldn't be
// silently pruned down to 550 MB. Users who want a pruned node can // silently pruned down to 550 MB. Users who want a pruned node can
// set `prune=N` in bitcoin.conf themselves after install. // set `prune=N` in bitcoin.conf themselves after install.
//
// printtoconsole=0: bitcoind already writes debug.log in the datadir
// (self-shrunk on restart); duplicating it to stdout pushed every IBD
// "UpdateTip" line through conmon into journald (>1 GB/day). Deep
// debugging uses /var/lib/archipelago/bitcoin/debug.log.
let bitcoin_conf = format!( let bitcoin_conf = format!(
"\ "\
# rpcauth: salted hash only - no plaintext password in config or CLI\n\ # rpcauth: salted hash only - no plaintext password in config or CLI\n\
@ -1370,7 +1304,7 @@ rpcallowip=0.0.0.0/0\n\
listen=1\n\ listen=1\n\
rpcthreads=16\n\ rpcthreads=16\n\
rpcworkqueue=256\n\ rpcworkqueue=256\n\
printtoconsole=0\n", printtoconsole=1\n",
rpcauth_line rpcauth_line
); );
tokio::fs::create_dir_all(bitcoin_dir) tokio::fs::create_dir_all(bitcoin_dir)
@ -2232,14 +2166,13 @@ async fn ensure_host_port_listener(
container_name: &str, container_name: &str,
runtime_ports: &[String], runtime_ports: &[String],
) -> Result<()> { ) -> Result<()> {
let mut port = runtime_ports let Some(port) = runtime_ports
.first() .first()
.and_then(|p| p.split(':').next()) .and_then(|p| p.split(':').next())
.and_then(|p| p.parse::<u16>().ok()); .and_then(|p| p.parse::<u16>().ok())
if port.is_none() { .or_else(|| published_host_port(container_name))
port = published_host_port(container_name).await; .or_else(|| required_host_port(package_id))
} else {
let Some(port) = port.or_else(|| required_host_port(package_id)) else {
return Ok(()); return Ok(());
}; };
@ -2285,11 +2218,10 @@ async fn ensure_host_port_listener(
)) ))
} }
async fn published_host_port(container_name: &str) -> Option<u16> { fn published_host_port(container_name: &str) -> Option<u16> {
let output = tokio::process::Command::new("podman") let output = std::process::Command::new("podman")
.args(["port", container_name]) .args(["port", container_name])
.output() .output()
.await
.ok()?; .ok()?;
if !output.status.success() { if !output.status.success() {
return None; return None;

View File

@ -61,31 +61,6 @@ impl RpcHandler {
self.state_manager.update_data(data).await; self.state_manager.update_data(data).await;
} }
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
/// to start…") without disturbing the current phase/byte counters.
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
if entry.state != PackageState::Updating {
entry.state = PackageState::Installing;
}
let (size, downloaded, phase) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded, p.phase))
.unwrap_or((0, 0, None));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase,
message: Some(message.to_string()),
});
self.state_manager.update_data(data).await;
}
/// Clear install progress after pull completes or fails. /// Clear install progress after pull completes or fails.
pub(super) async fn clear_install_progress(&self, package_id: &str) { pub(super) async fn clear_install_progress(&self, package_id: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await; let (mut data, _rev) = self.state_manager.get_snapshot().await;

View File

@ -63,8 +63,6 @@ impl RpcHandler {
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) { let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
vec![orchestrator_app_id(package_id).to_string()] vec![orchestrator_app_id(package_id).to_string()]
} else if let Some(members) = orchestrator_stack_members(self.orchestrator.is_some(), package_id) {
members
} else { } else {
ordered_containers_for_start(package_id).await? ordered_containers_for_start(package_id).await?
}; };
@ -93,10 +91,7 @@ impl RpcHandler {
)) ))
.await; .await;
let op_lock = app_op_lock(package_id);
let data_dir = self.config.data_dir.clone();
tokio::spawn(async move { tokio::spawn(async move {
let _op_guard = op_lock.lock().await;
let result = if let Some(orchestrator) = orchestrator.as_ref() { let result = if let Some(orchestrator) = orchestrator.as_ref() {
do_orchestrator_package_start(orchestrator.as_ref(), &to_start).await do_orchestrator_package_start(orchestrator.as_ref(), &to_start).await
} else { } else {
@ -107,13 +102,6 @@ impl RpcHandler {
reconcile_companions_for(&companion_app_id).await; reconcile_companions_for(&companion_app_id).await;
set_package_state(&state_manager, &package_id_owned, PackageState::Running) set_package_state(&state_manager, &package_id_owned, PackageState::Running)
.await; .await;
cascade_restart_address_caching_dependents(
orchestrator.as_ref(),
&state_manager,
&data_dir,
&package_id_owned,
)
.await;
} }
Err(e) => { Err(e) => {
tracing::error!("package.start {} failed: {:#}", package_id_owned, e); tracing::error!("package.start {} failed: {:#}", package_id_owned, e);
@ -163,22 +151,6 @@ impl RpcHandler {
return Err(anyhow::anyhow!("No containers found for {}", package_id)); return Err(anyhow::anyhow!("No containers found for {}", package_id));
} }
// Orchestrator-managed stacks are stopped via member APP ids (reverse
// start order), never live container names: orchestrator.stop(app_id)
// stops the member's quadlet .service (systemd removes the --rm
// container), while a container NAME falls through the unknown-app-id
// fallback to a raw `podman stop` that races systemd over the unit
// (immich, gate 2026-07-09).
let to_stop_ids = if !single_orchestrator_app {
orchestrator_stack_members(self.orchestrator.is_some(), package_id)
.map(|mut members| {
members.reverse();
members
})
} else {
None
};
// Mark as user-stopped BEFORE the spawn so health monitor and // Mark as user-stopped BEFORE the spawn so health monitor and
// crash recovery don't auto-restart mid-flight. Ordering is // crash recovery don't auto-restart mid-flight. Ordering is
// load-bearing — see runtime.rs:145-148 original note. // load-bearing — see runtime.rs:145-148 original note.
@ -186,17 +158,9 @@ impl RpcHandler {
for name in &containers { for name in &containers {
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await; crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
} }
// Stack members are marked under their app ids too, so the reconcile
// guard holds for members whose container is currently absent (a live
// container list can't name those).
if let Some(ids) = &to_stop_ids {
for id in ids {
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, id).await;
}
}
let package_id_owned = package_id.to_string(); let package_id_owned = package_id.to_string();
let to_stop = to_stop_ids.unwrap_or_else(|| containers.clone()); let to_stop = containers.clone();
let orchestrator = self.orchestrator.clone(); let orchestrator = self.orchestrator.clone();
let state_manager = Arc::clone(&self.state_manager); let state_manager = Arc::clone(&self.state_manager);
let pre_state = let pre_state =
@ -208,9 +172,7 @@ impl RpcHandler {
)) ))
.await; .await;
let op_lock = app_op_lock(package_id);
tokio::spawn(async move { tokio::spawn(async move {
let _op_guard = op_lock.lock().await;
let result = if let Some(orchestrator) = orchestrator.as_ref() { let result = if let Some(orchestrator) = orchestrator.as_ref() {
do_orchestrator_package_stop(orchestrator.as_ref(), &to_stop).await do_orchestrator_package_stop(orchestrator.as_ref(), &to_stop).await
} else { } else {
@ -251,22 +213,15 @@ impl RpcHandler {
let single_orchestrator_app = let single_orchestrator_app =
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id); self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
let mut containers = if single_orchestrator_app { let containers = if single_orchestrator_app {
vec![orchestrator_app_id(package_id).to_string()] vec![orchestrator_app_id(package_id).to_string()]
} else { } else {
get_containers_for_app(package_id).await? get_containers_for_app(package_id).await?
}; };
if containers.is_empty() { if containers.is_empty() {
// A stack whose containers were all removed (quadlet stop) can
// still be restarted: resurrect it member-by-member, same as
// package.start's no-live-containers fallback.
let members = super::dependencies::stack_member_app_ids(package_id);
if members.is_empty() {
tracing::warn!("package.restart {}: no containers found", package_id); tracing::warn!("package.restart {}: no containers found", package_id);
return Err(anyhow::anyhow!("No containers found for {}", package_id)); return Err(anyhow::anyhow!("No containers found for {}", package_id));
} }
containers = members.iter().map(|s| s.to_string()).collect();
}
// Restart does not mark user-stopped; user wants the app to keep // Restart does not mark user-stopped; user wants the app to keep
// running. Clear any lingering marker so downstream layers don't // running. Clear any lingering marker so downstream layers don't
@ -280,12 +235,6 @@ impl RpcHandler {
let companion_app_id = package_id_owned.clone(); let companion_app_id = package_id_owned.clone();
let to_restart = if single_orchestrator_app { let to_restart = if single_orchestrator_app {
vec![orchestrator_app_id(package_id).to_string()] vec![orchestrator_app_id(package_id).to_string()]
} else if let Some(members) = orchestrator_stack_members(self.orchestrator.is_some(), package_id) {
// Restart stacks via member APP ids: restarting by live container
// name podman-stops the quadlet container (systemd --rm removes
// it) and the start half then finds no such container — a 5-min
// outage + RESTART FAIL on immich (gate 2026-07-09).
members
} else { } else {
ordered_containers_for_start(package_id).await? ordered_containers_for_start(package_id).await?
}; };
@ -300,10 +249,7 @@ impl RpcHandler {
)) ))
.await; .await;
let op_lock = app_op_lock(package_id);
let data_dir = self.config.data_dir.clone();
tokio::spawn(async move { tokio::spawn(async move {
let _op_guard = op_lock.lock().await;
let result = if let Some(orchestrator) = orchestrator.as_ref() { let result = if let Some(orchestrator) = orchestrator.as_ref() {
do_orchestrator_package_restart(orchestrator.as_ref(), &to_restart).await do_orchestrator_package_restart(orchestrator.as_ref(), &to_restart).await
} else { } else {
@ -314,13 +260,6 @@ impl RpcHandler {
reconcile_companions_for(&companion_app_id).await; reconcile_companions_for(&companion_app_id).await;
set_package_state(&state_manager, &package_id_owned, PackageState::Running) set_package_state(&state_manager, &package_id_owned, PackageState::Running)
.await; .await;
cascade_restart_address_caching_dependents(
orchestrator.as_ref(),
&state_manager,
&data_dir,
&package_id_owned,
)
.await;
} }
Err(e) => { Err(e) => {
tracing::error!("package.restart {} failed: {:#}", package_id_owned, e); tracing::error!("package.restart {} failed: {:#}", package_id_owned, e);
@ -826,13 +765,7 @@ async fn do_orchestrator_package_start(
match orchestrator.start(name).await { match orchestrator.start(name).await {
Ok(()) => wait_after_orchestrator_start(name).await, Ok(()) => wait_after_orchestrator_start(name).await,
Err(e) if is_unknown_app_id_error(&e) => { Err(e) if is_unknown_app_id_error(&e) => {
// Collect instead of `?`: aborting here skipped every later do_package_start(&[name.clone()]).await?;
// stack member (mempool gate flakes #73/#74 — see
// order_present_containers).
if let Err(e) = do_package_start(&[name.clone()]).await {
tracing::error!(container = %name, error = %e, "fallback container start failed");
errors.push(format!("{}: {:#}", name, e));
}
} }
Err(e) => { Err(e) => {
tracing::error!(container = %name, error = %e, "orchestrator start failed"); tracing::error!(container = %name, error = %e, "orchestrator start failed");
@ -993,8 +926,6 @@ async fn inspect_runtime_container_state(container_name: &str) -> Result<Option<
fn is_missing_container_error(stderr: &str) -> bool { fn is_missing_container_error(stderr: &str) -> bool {
stderr.contains("no such container") stderr.contains("no such container")
|| stderr.contains("no container with name") || stderr.contains("no container with name")
// podman 5.x `inspect` phrasing: `Error: no such object: "name"`
|| stderr.contains("no such object")
|| stderr.contains("does not exist") || stderr.contains("does not exist")
|| stderr.contains("not found") || stderr.contains("not found")
} }
@ -1084,13 +1015,6 @@ async fn do_orchestrator_package_stop(
errors.push(format!("{}: {:#}", name, e)); errors.push(format!("{}: {:#}", name, e));
} }
} }
// A member whose container is already gone IS stopped: quadlet
// stop removes the --rm container before the orchestrator's
// defensive `podman stop` fallback probes it, and a stack member
// can be absent outright (stopped earlier, or never revived).
Err(e) if is_missing_container_error(&format!("{:#}", e)) => {
tracing::debug!(container = %name, error = %e, "stop: container already absent — treating as stopped");
}
Err(e) => { Err(e) => {
tracing::error!(container = %name, error = %e, "orchestrator stop failed"); tracing::error!(container = %name, error = %e, "orchestrator stop failed");
errors.push(format!("{}: {:#}", name, e)); errors.push(format!("{}: {:#}", name, e));
@ -1112,103 +1036,6 @@ fn orchestrator_app_id(package_id: &str) -> &str {
} }
} }
/// Member APP ids (start order) for an orchestrator-managed stack, or None
/// when there's no orchestrator / the id isn't a known stack. Lifecycle ops
/// on stacks must address members by app id so the orchestrator drives the
/// quadlet .service; addressing live container names falls through the
/// unknown-app-id fallback to raw podman stop/start, which races systemd's
/// --rm cleanup (the container is removed on stop, the start half then finds
/// nothing — immich restart, gate 2026-07-09).
fn orchestrator_stack_members(has_orchestrator: bool, package_id: &str) -> Option<Vec<String>> {
if !has_orchestrator {
return None;
}
let members = super::dependencies::stack_member_app_ids(package_id);
if members.is_empty() {
None
} else {
Some(members.iter().map(|s| s.to_string()).collect())
}
}
/// See crate::app_ops::address_caching_dependents — shared with the
/// reconciler, which cascades after its own recreates.
fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
crate::app_ops::address_caching_dependents(package_id)
}
/// After a backend start/restart lands, bounce its address-caching dependents
/// (see above). Runs inside the backend worker's op-lock scope and takes each
/// dependent's own op lock — lock order is always backend → dependent, and no
/// dependent op takes a backend lock, so the nesting cannot deadlock.
async fn cascade_restart_address_caching_dependents(
orchestrator: Option<&Arc<dyn crate::container::traits::ContainerOrchestrator>>,
state_manager: &crate::state::StateManager,
data_dir: &std::path::Path,
backend_id: &str,
) {
for dep in address_caching_dependents(backend_id) {
// A user-stopped or not-running dependent holds no live connection —
// starting it here would override an explicit operator decision.
if crate::crash_recovery::load_user_stopped(data_dir)
.await
.contains(*dep)
{
continue;
}
let running = match podman_control(&["ps", "--format", "{{.Names}}"]).await {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.any(|l| l.trim() == *dep),
_ => false,
};
if !running {
continue;
}
install_log(&format!(
"CASCADE RESTART: {dep} (backend {backend_id} restarted; {dep} caches its resolved address)"
))
.await;
let op_lock = app_op_lock(dep);
let _op_guard = op_lock.lock().await;
let prev = flip_package_state(state_manager, dep, PackageState::Restarting).await;
let target = vec![dep.to_string()];
let result = if let Some(orch) = orchestrator {
do_orchestrator_package_restart(orch.as_ref(), &target).await
} else {
do_package_restart(&target).await
};
match result {
Ok(()) => {
reconcile_companions_for(dep).await;
set_package_state(state_manager, dep, PackageState::Running).await;
}
Err(e) => {
tracing::error!(dependent = %dep, backend = %backend_id, error = %e, "cascade restart failed");
install_log(&format!("CASCADE RESTART FAIL: {dep}{e:#}")).await;
if let Some(prev) = prev {
set_package_state(state_manager, dep, prev).await;
}
}
}
}
}
/// Per-app lifecycle-operation locks. package.start/stop/restart reply
/// immediately and run their multi-container sequences in spawned tasks;
/// unserialized, back-to-back RPCs interleave those sequences (gate run D,
/// 2026-07-09: package.start's member bring-up raced the still-running
/// package.stop's member shutdown — archy-mempool-db was stopped 2 seconds
/// after it started and the mempool stack finished with zero containers).
/// Workers take the app's lock as their first await; tokio's Mutex is fair
/// (FIFO), so queued operations run in RPC arrival order and the final
/// state matches the last request.
/// Registry lives in crate::app_ops so background actors (reconciler) can
/// probe in-flight ops without an api ↔ container dependency cycle.
fn app_op_lock(package_id: &str) -> Arc<tokio::sync::Mutex<()>> {
crate::app_ops::op_lock(orchestrator_app_id(package_id))
}
fn uses_single_orchestrator_app(package_id: &str) -> bool { fn uses_single_orchestrator_app(package_id: &str) -> bool {
startup_order(package_id).is_empty() startup_order(package_id).is_empty()
&& matches!( && matches!(
@ -2120,18 +1947,6 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
"archy-btcpay-db".into(), "archy-btcpay-db".into(),
], ],
"fedimint" => vec!["fedimint".into(), "fedimint-gateway".into()], "fedimint" => vec!["fedimint".into(), "fedimint-gateway".into()],
// IndeedHub: 7-container stack; same rationale as immich below —
// without this, uninstalling "indeedhub" leaves the six companions
// enabled for the reconciler to resurrect forever.
"indeedhub" => vec![
"indeedhub-postgres".into(),
"indeedhub-redis".into(),
"indeedhub-minio".into(),
"indeedhub-relay".into(),
"indeedhub-api".into(),
"indeedhub-ffmpeg".into(),
"indeedhub".into(),
],
// Immich: multi-container stack, mirrors `immich_stack_app_ids` in // Immich: multi-container stack, mirrors `immich_stack_app_ids` in
// stacks.rs. Without this, uninstalling "immich" only disabled the // stacks.rs. Without this, uninstalling "immich" only disabled the
// orchestrator-tracked "immich" app_id — "immich-postgres" and // orchestrator-tracked "immich" app_id — "immich-postgres" and
@ -2151,22 +1966,6 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn missing_container_classifier_covers_podman5_phrasings() {
// Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing
// container says `Error: no such object: "mempool"` — the classifier
// missed it, so a plain missing container surfaced as a hard inspect
// error and package.start aborted instead of proceeding to recreate.
assert!(is_missing_container_error(
"Error: no such object: \"mempool\""
));
assert!(is_missing_container_error("Error: no such container mempool"));
assert!(is_missing_container_error(
"Error: no container with name or id \"x\" found"
));
assert!(!is_missing_container_error("Error: OCI runtime error"));
}
#[test] #[test]
fn runtime_host_ports_are_manifest_derived_for_public_apps() { fn runtime_host_ports_are_manifest_derived_for_public_apps() {
assert_eq!(runtime_host_ports("photoprism"), vec![2342]); assert_eq!(runtime_host_ports("photoprism"), vec![2342]);
@ -2179,25 +1978,6 @@ mod tests {
assert_eq!(runtime_host_ports("gitea"), vec![3001, 2222, 3000]); assert_eq!(runtime_host_ports("gitea"), vec![3001, 2222, 3000]);
} }
#[test]
fn indeedhub_uninstall_covers_every_sibling_orchestrator_app_id() {
let ids = orchestrator_uninstall_app_ids("indeedhub");
for expected in [
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
] {
assert!(
ids.iter().any(|id| id == expected),
"missing {expected} in {ids:?}"
);
}
}
#[test] #[test]
fn immich_uninstall_covers_every_sibling_orchestrator_app_id() { fn immich_uninstall_covers_every_sibling_orchestrator_app_id() {
// Regression: uninstalling "immich" used to only disable the // Regression: uninstalling "immich" used to only disable the

View File

@ -130,8 +130,6 @@ async fn wait_for_stack_container_absent(container_name: &str, timeout: Duration
fn is_missing_container_error(stderr: &str) -> bool { fn is_missing_container_error(stderr: &str) -> bool {
stderr.contains("no such container") stderr.contains("no such container")
|| stderr.contains("no container with name") || stderr.contains("no container with name")
// podman 5.x `inspect` phrasing: `Error: no such object: "name"`
|| stderr.contains("no such object")
|| stderr.contains("does not exist") || stderr.contains("does not exist")
|| stderr.contains("not found") || stderr.contains("not found")
} }
@ -1011,9 +1009,9 @@ impl RpcHandler {
return Ok(adopted); return Ok(adopted);
} }
// Dependency check: Bitcoin must be running. Bounded wait covers the // Dependency check: Bitcoin must be running
// "installed but still starting" race instead of failing instantly. let deps = super::dependencies::detect_running_deps().await?;
self.gate_install_deps("btcpay-server").await?; super::dependencies::check_install_deps("btcpay-server", &deps)?;
install_log("INSTALL START: btcpay-server (stack: postgres + nbxplorer + btcpay)").await; install_log("INSTALL START: btcpay-server (stack: postgres + nbxplorer + btcpay)").await;

View File

@ -172,36 +172,18 @@ impl RpcHandler {
/// Manual "check for updates": refresh the remote app catalog now. The /// Manual "check for updates": refresh the remote app catalog now. The
/// package scanner recomputes each app's `available-update` from the fresh /// package scanner recomputes each app's `available-update` from the fresh
/// catalog on its next cycle and pushes it to the UI. When the catalog /// catalog on its next cycle and pushes it to the UI. Best-effort — a fetch
/// bytes changed, the orchestrator's manifest overlay is reloaded in the /// failure leaves the cached catalog in place and reports `refreshed: false`.
/// same call so catalog-shipped manifest fixes apply without a service
/// restart. Best-effort — a fetch failure leaves the cached catalog in
/// place and reports `refreshed: false`.
pub(in crate::api::rpc) async fn handle_package_check_updates( pub(in crate::api::rpc) async fn handle_package_check_updates(
&self, &self,
_params: Option<serde_json::Value>, _params: Option<serde_json::Value>,
) -> Result<serde_json::Value> { ) -> Result<serde_json::Value> {
match crate::container::app_catalog::refresh_catalog(&self.config.data_dir).await { match crate::container::app_catalog::refresh_catalog(&self.config.data_dir).await {
Ok(refresh) => { Ok(count) => Ok(serde_json::json!({
let mut manifests_reloaded = serde_json::Value::Null;
if refresh.changed {
if let Some(orch) = &self.orchestrator {
match orch.reload_manifests().await {
Ok(n) => manifests_reloaded = serde_json::json!(n),
Err(e) => tracing::warn!(
"check-updates: manifest reload after catalog change failed: {e}"
),
}
}
}
Ok(serde_json::json!({
"status": "ok", "status": "ok",
"refreshed": true, "refreshed": true,
"catalog_apps": refresh.apps, "catalog_apps": count,
"catalog_changed": refresh.changed, })),
"manifests_reloaded": manifests_reloaded,
}))
}
Err(e) => Ok(serde_json::json!({ Err(e) => Ok(serde_json::json!({
"status": "ok", "status": "ok",
"refreshed": false, "refreshed": false,

View File

@ -26,36 +26,6 @@ impl Drop for OnboardingMnemonicState {
const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes
/// Persist the pending onboarding mnemonic as `identity/master_seed.enc`,
/// encrypted with `passphrase`. Called from `auth.setup` — the first moment a
/// user password exists — so "Reveal recovery phrase" works after onboarding
/// without the frontend having to remember a separate save step (it never
/// did, which left every onboarded node with no encrypted seed backup).
///
/// Deliberately ignores MNEMONIC_TTL: the mnemonic stays in memory until
/// overwritten regardless, so using it here widens nothing, and onboarding
/// legitimately takes longer than 10 minutes when the user carefully writes
/// down 24 words. Clears the in-memory copy on success — password setup is
/// the end of onboarding, so the plaintext no longer needs to linger.
///
/// Returns Ok(true) if a seed was saved, Ok(false) if none was pending.
pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
data_dir: &std::path::Path,
passphrase: &str,
) -> Result<bool> {
let mut state = ONBOARDING_MNEMONIC.lock().await;
let Some(pending) = state.as_ref() else {
return Ok(false);
};
let mnemonic: bip39::Mnemonic = pending
.words
.parse()
.context("Invalid mnemonic in memory")?;
crate::seed::save_seed_encrypted(data_dir, &mnemonic, passphrase).await?;
*state = None;
Ok(true)
}
/// Best-effort: install fips.yaml + start archipelago-fips.service after the /// Best-effort: install fips.yaml + start archipelago-fips.service after the
/// seed onboarding has written the fips_key to disk. Runs in a detached task /// seed onboarding has written the fips_key to disk. Runs in a detached task
/// so the user-facing RPC returns immediately — the systemctl calls can take /// so the user-facing RPC returns immediately — the systemctl calls can take
@ -238,17 +208,6 @@ impl RpcHandler {
let phrase = words.join(" "); let phrase = words.join(" ");
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?; let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
// Stash the restored words like seed.generate does, so auth.setup can
// persist the encrypted backup once the user's password exists and
// "Reveal recovery phrase" works on restored nodes too.
{
let mut state = ONBOARDING_MNEMONIC.lock().await;
*state = Some(OnboardingMnemonicState {
words: phrase.clone(),
created_at: std::time::Instant::now(),
});
}
// Derive and write node Ed25519 key. // Derive and write node Ed25519 key.
let identity_dir = self.config.data_dir.join("identity"); let identity_dir = self.config.data_dir.join("identity");
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?; crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;

View File

@ -575,7 +575,7 @@ impl RpcHandler {
// Restart the service via systemd // Restart the service via systemd
tokio::spawn(async { tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_secs(2)).await; tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let _ = tokio::process::Command::new("sudo") let _ = std::process::Command::new("sudo")
.args(["systemctl", "restart", "archipelago"]) .args(["systemctl", "restart", "archipelago"])
.spawn(); .spawn();
}); });

View File

@ -1,128 +0,0 @@
//! Cross-layer registry of per-app lifecycle-operation locks and stack
//! membership.
//!
//! The RPC layer's package.start/stop/restart workers serialize through
//! these locks (FIFO, see api::rpc::package::runtime). Background actors
//! (the reconciler; eventually the health monitor) must NOT act on an app
//! while a lifecycle op is mid-sequence: the reconciler once saw a stack
//! member "missing" between a restart worker's stop and start halves and
//! repair-recreated it behind systemd's back, killing the worker's fresh
//! container and leaving the unit down for minutes (.228 mempool frontend,
//! gate 2026-07-09). This module lives outside both layers so each can
//! consult the same state without an api ↔ container dependency cycle.
use std::sync::Arc;
static APP_OP_LOCKS: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
> = std::sync::LazyLock::new(Default::default);
/// The per-app lifecycle-operation lock for a (normalized) app key. Workers
/// take this as their first await; tokio's Mutex is fair (FIFO), so queued
/// operations run in RPC arrival order and the final state matches the last
/// request.
pub fn op_lock(app_key: &str) -> Arc<tokio::sync::Mutex<()>> {
APP_OP_LOCKS
.lock()
.expect("APP_OP_LOCKS poisoned")
.entry(app_key.to_string())
.or_default()
.clone()
}
/// Member APP ids (start order) for orchestrator-managed stacks. Every entry
/// is a real manifest app id the orchestrator can `start()`/`stop()` so the
/// quadlet .service is driven instead of raw podman racing systemd's --rm
/// cleanup. Single source of truth — the RPC layer re-exports this.
pub fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
match package_id {
"immich" => &["immich-postgres", "immich-redis", "immich"],
"indeedhub" => &[
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
],
"btcpay-server" | "btcpayserver" | "btcpay" => {
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
}
"netbird" => &["netbird-server", "netbird-dashboard", "netbird"],
// The legacy umbrella id maps to the split stack (the orchestrator's
// umbrella alias handles this too; listing it here keeps the RPC
// layer's fan-out explicit).
"mempool" | "mempool-web" => {
&["archy-mempool-db", "mempool-api", "archy-mempool-web"]
}
_ => &[],
}
}
/// Dependents that resolve a backend's container address once at startup and
/// hold it: moving the backend's IP (restart OR recreate) strands them until
/// they restart too. lnd dials the bitcoin RPC address it resolved at boot
/// and never re-resolves (gate lnd getinfo test, .228 2026-07-09; hardening
/// plan §C). The RPC start/restart workers and the reconciler both consult
/// this — single source of truth, like the stack table above.
pub fn address_caching_dependents(package_id: &str) -> &'static [&'static str] {
match package_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => &["lnd"],
_ => &[],
}
}
/// The package whose lifecycle lock covers `app_id`: the stack package when
/// `app_id` is a member (RPC ops on "mempool" hold the "mempool" lock while
/// they drive archy-mempool-web), otherwise the app itself.
fn owning_package(app_id: &str) -> &str {
const STACKS: &[&str] = &["immich", "indeedhub", "btcpay-server", "netbird", "mempool"];
for stack in STACKS {
if stack_member_app_ids(stack).contains(&app_id) {
return stack;
}
}
app_id
}
/// True when a package.start/stop/restart worker currently holds the
/// lifecycle lock covering `app_id` (under its own key or its owning stack
/// package's key). Background actors use this to skip the app for a cycle
/// instead of interleaving with the worker's multi-step sequence. try_lock
/// on a fair tokio Mutex is non-blocking and does not queue.
pub fn lifecycle_op_in_flight(app_id: &str) -> bool {
let keys = [app_id, owning_package(app_id)];
for key in keys {
let lock = op_lock(key);
let held = lock.try_lock().is_err();
if held {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn owning_package_maps_members_to_stack() {
assert_eq!(owning_package("archy-mempool-web"), "mempool");
assert_eq!(owning_package("immich-postgres"), "immich");
assert_eq!(owning_package("indeedhub-relay"), "indeedhub");
assert_eq!(owning_package("archy-nbxplorer"), "btcpay-server");
assert_eq!(owning_package("lnd"), "lnd");
}
#[tokio::test]
async fn in_flight_reflects_held_package_lock() {
assert!(!lifecycle_op_in_flight("archy-mempool-web"));
let lock = op_lock("mempool");
let _guard = lock.lock().await;
assert!(lifecycle_op_in_flight("archy-mempool-web"));
assert!(lifecycle_op_in_flight("mempool"));
assert!(!lifecycle_op_in_flight("jellyfin"));
}
}

View File

@ -101,45 +101,19 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
.trim_end_matches('.'); .trim_end_matches('.');
let lower = detail.to_lowercase(); let lower = detail.to_lowercase();
let state = if lower.contains("verifying blocks") { let state = if lower.contains("verifying blocks") {
Some("verifying blocks after restart") "verifying blocks after restart"
} else if lower.contains("connection reset") {
Some("starting up and not yet accepting RPC connections")
} else if lower.contains("connection refused") || lower.contains("tcp connect error") { } else if lower.contains("connection refused") || lower.contains("tcp connect error") {
Some("waiting for the Bitcoin RPC listener") "waiting for the Bitcoin RPC listener"
} else if lower.contains("timed out") || lower.contains("timeout") { } else if lower.contains("timed out") || lower.contains("timeout") {
Some("busy and not answering RPC before the timeout") "busy and not answering RPC before the timeout"
} else { } else {
None "starting or busy syncing"
}; };
// Recognized transient causes get a clean human sentence only — the raw if has_cached_state {
// transport error (URLs, repeated "os error 104" chains) is operator format!("Bitcoin node is {state}; showing last known state and retrying. Detail: {detail}")
// noise that was ending up verbatim on the app card. Unrecognized errors
// keep a bounded detail so a genuinely new failure stays diagnosable.
let (state, detail) = match state {
Some(state) => (state, None),
None => (
"starting or busy syncing",
Some(if detail.len() > 120 {
let mut cut = 120;
while !detail.is_char_boundary(cut) {
cut -= 1;
}
format!("{}", &detail[..cut])
} else { } else {
detail.to_string() format!("Bitcoin node is {state}; retrying automatically. Detail: {detail}")
}),
),
};
let base = if has_cached_state {
format!("Bitcoin node is {state}; showing last known state and retrying.")
} else {
format!("Bitcoin node is {state}; retrying automatically.")
};
match detail {
Some(detail) => format!("{base} Detail: {detail}"),
None => base,
} }
} }
@ -304,39 +278,4 @@ mod tests {
assert!(msg.contains("busy and not answering RPC before the timeout")); assert!(msg.contains("busy and not answering RPC before the timeout"));
} }
#[test]
fn connection_reset_gets_clean_message_without_raw_detail() {
// The exact string a fresh install showed on the app card: the raw
// reqwest chain (URL + repeated "os error 104") must not surface.
let msg = friendly_transient_error(
false,
"getblockchaininfo: Bitcoin RPC request failed: error sending request for url (http://127.0.0.1:8332/): connection error: Connection reset by peer (os error 104): connection error: Connection reset by peer (os error 104): Connection reset by peer (os error 104)",
);
assert!(msg.contains("starting up and not yet accepting RPC connections"));
assert!(!msg.contains("os error"));
assert!(!msg.contains("127.0.0.1"));
assert!(!msg.contains("Detail:"));
}
#[test]
fn recognized_causes_omit_detail_entirely() {
for raw in [
"x: Connection refused (os error 111)",
"x: operation timed out",
r#"x: {"error":{"code":-28,"message":"Verifying blocks..."}}"#,
] {
let msg = friendly_transient_error(false, raw);
assert!(!msg.contains("Detail:"), "leaked detail for: {raw}");
}
}
#[test]
fn unknown_errors_keep_bounded_detail() {
let long = format!("weird new failure {}", "x".repeat(300));
let msg = friendly_transient_error(false, &long);
assert!(msg.contains("Detail: weird new failure"));
assert!(msg.len() < 260);
}
} }

View File

@ -39,16 +39,6 @@ const KIOSK_LAUNCHER: &str =
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service"; const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher"; const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
// Journald log-volume policy (size cap + per-service rate limit). Fresh ISOs
// write the identical file at build time (image-recipe/_archived/
// build-auto-installer-iso.sh); this heals already-deployed nodes via OTA.
// A fresh node produced >1 GB/day of journal (bitcoind IBD console spam plus
// debug-level backend logging) — the cap bounds disk use and the rate limit
// keeps one chatty service from drowning everything else.
const JOURNALD_DROPIN: &str =
include_str!("../../../image-recipe/configs/journald-archipelago.conf");
const JOURNALD_DROPIN_PATH: &str = "/etc/systemd/journald.conf.d/10-archipelago-persistent.conf";
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago"; const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago"; const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
/// Per-app proxy snippet included by the HTTPS (:443) server block. Carries its /// Per-app proxy snippet included by the HTTPS (:443) server block. Carries its
@ -130,11 +120,6 @@ pub async fn ensure_doctor_installed() {
Ok(false) => debug!("Bitcoin RPC bind settings already usable"), Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e), Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
} }
match run_journald_dropin().await {
Ok(true) => info!("Installed journald log-volume policy drop-in"),
Ok(false) => debug!("journald log-volume policy already in place"),
Err(e) => warn!("journald drop-in bootstrap failed (non-fatal): {:#}", e),
}
match tighten_secrets_dir().await { match tighten_secrets_dir().await {
Ok(n) if n > 0 => info!(tightened = n, "Tightened mode on secret files"), Ok(n) if n > 0 => info!(tightened = n, "Tightened mode on secret files"),
Ok(_) => debug!("Secrets directory already at expected mode"), Ok(_) => debug!("Secrets directory already at expected mode"),
@ -423,14 +408,6 @@ ensure_line() {
ensure_line server=1 ensure_line server=1
ensure_line rpcallowip=0.0.0.0/0 ensure_line rpcallowip=0.0.0.0/0
ensure_line listen=1 ensure_line listen=1
# Log-volume fix: printtoconsole=1 duplicated every log line (incl. per-block
# IBD "UpdateTip" spam) into journald via conmon on top of the datadir
# debug.log bitcoind already writes. Console off; debug.log stays (bitcoind
# self-shrinks it on restart).
if grep -q '^printtoconsole=1' "$conf"; then
sed -i 's/^printtoconsole=1$/printtoconsole=0/' "$conf"
changed=1
fi
[ "$changed" -eq 0 ] && exit 0 [ "$changed" -eq 0 ] && exit 0
exit 2 exit 2
"#; "#;
@ -451,44 +428,6 @@ exit 2
} }
} }
/// Install the journald log-volume policy drop-in (JOURNALD_DROPIN) so nodes
/// deployed before the ISO shipped it get the size cap + rate limit via OTA.
/// Idempotent; restarts journald only when the file actually changed (safe:
/// the sockets are held by pid1, so at most a few messages queue briefly).
async fn run_journald_dropin() -> Result<bool> {
// Same dev-box guards as the doctor bootstrap: never touch /etc on
// contributors' laptops (symlinked or absent /home/archipelago/archy).
let home_archy = Path::new("/home/archipelago/archy");
if fs::symlink_metadata(home_archy)
.await
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
debug!("/home/archipelago/archy is a symlink — skipping journald bootstrap (dev box)");
return Ok(false);
}
if fs::metadata(home_archy).await.is_err() {
debug!("/home/archipelago/archy missing — skipping journald bootstrap");
return Ok(false);
}
let dropin_dir = "/etc/systemd/journald.conf.d";
let status = host_sudo(&["mkdir", "-p", dropin_dir])
.await
.with_context(|| format!("mkdir {}", dropin_dir))?;
if !status.success() {
anyhow::bail!("mkdir {} exited with {}", dropin_dir, status);
}
let changed = write_root_if_needed(JOURNALD_DROPIN_PATH, JOURNALD_DROPIN).await?;
if changed {
if let Err(e) = host_sudo(&["systemctl", "restart", "systemd-journald"]).await {
warn!("journald restart after drop-in update failed: {:#}", e);
}
}
Ok(changed)
}
async fn run() -> Result<bool> { async fn run() -> Result<bool> {
// Dev-box guard: on contributors' laptops `/home/archipelago/archy` is // Dev-box guard: on contributors' laptops `/home/archipelago/archy` is
// typically a symlink into the git checkout, and writing through it // typically a symlink into the git checkout, and writing through it

View File

@ -19,11 +19,6 @@
//! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert //! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert
//! `signature` + `signed_by` over the canonical form, matching exactly //! `signature` + `signed_by` over the canonical form, matching exactly
//! what `trust::verify_detached` recomputes on every node. //! what `trust::verify_detached` recomputes on every node.
//!
//! archipelago ceremony verify <file.json>
//! Verify a signed JSON document against the compiled-in release-root
//! anchor. Exits non-zero unless the signature verifies AND the signer
//! is the pinned anchor. Needs no mnemonic — used as the publish gate.
//! ``` //! ```
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
@ -52,15 +47,9 @@ pub fn run() -> Result<()> {
.context("usage: archipelago ceremony sign <file.json>")?; .context("usage: archipelago ceremony sign <file.json>")?;
cmd_sign(&file) cmd_sign(&file)
} }
"verify" => {
let file = std::env::args()
.nth(3)
.context("usage: archipelago ceremony verify <file.json>")?;
cmd_verify(&file)
}
other => { other => {
bail!( bail!(
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file> | verify <file>", "unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file>",
other other
) )
} }
@ -118,33 +107,6 @@ fn cmd_sign(path: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_verify(path: &str) -> Result<()> {
let body = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
let value: serde_json::Value =
serde_json::from_str(&body).with_context(|| format!("parse {path} as JSON"))?;
match signed_doc::verify_detached(&value)? {
signed_doc::SignatureStatus::Verified {
signer_did,
anchored: true,
} => {
eprintln!("{path} verified — signed by the pinned release root");
eprintln!(" signed_by: {signer_did}");
Ok(())
}
signed_doc::SignatureStatus::Verified {
signer_did,
anchored: false,
} => {
// Only reachable if no anchor is compiled in/overridden — the
// signature is self-consistent but proves nothing about identity.
bail!("{path} signed by {signer_did}, but no release-root anchor is pinned to compare against")
}
signed_doc::SignatureStatus::Unsigned => {
bail!("{path} is NOT signed (no `signature` field)")
}
}
}
/// Derive the release-root signing key from the mnemonic in env/stdin. /// Derive the release-root signing key from the mnemonic in env/stdin.
fn load_release_root_key() -> Result<SigningKey> { fn load_release_root_key() -> Result<SigningKey> {
let phrase = read_mnemonic()?; let phrase = read_mnemonic()?;

View File

@ -81,11 +81,10 @@ pub struct Config {
impl Config { impl Config {
/// Detect primary host IP (first non-loopback IPv4) /// Detect primary host IP (first non-loopback IPv4)
async fn detect_host_ip() -> Result<String> { fn detect_host_ip() -> Result<String> {
let output = tokio::process::Command::new("hostname") let output = std::process::Command::new("hostname")
.args(["-I"]) .args(["-I"])
.output() .output()
.await
.context("Failed to run hostname -I")?; .context("Failed to run hostname -I")?;
let s = String::from_utf8_lossy(&output.stdout); let s = String::from_utf8_lossy(&output.stdout);
let ip = s let ip = s
@ -211,9 +210,7 @@ impl Config {
if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") { if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") {
config.host_ip = ip; config.host_ip = ip;
} else { } else {
config.host_ip = Self::detect_host_ip() config.host_ip = Self::detect_host_ip().unwrap_or_else(|_| "127.0.0.1".to_string());
.await
.unwrap_or_else(|_| "127.0.0.1".to_string());
} }
// Ensure data directory exists // Ensure data directory exists

View File

@ -339,31 +339,18 @@ fn catalog_urls_from_mirrors(mirrors: &[crate::update::UpdateMirror]) -> Vec<Str
urls urls
} }
/// Outcome of [`refresh_catalog`]: the app count of the fetched catalog and
/// whether the cached bytes actually changed. `changed` drives the manifest-
/// overlay reload — catalog manifests only take effect once `load_manifests`
/// re-runs, and reloading on every unchanged hourly poll would be pure churn.
#[derive(Debug, Clone, Copy)]
pub struct CatalogRefresh {
pub apps: usize,
pub changed: bool,
}
/// Fetch the catalog from the first reachable mirror and atomically write it to /// Fetch the catalog from the first reachable mirror and atomically write it to
/// `<data_dir>/app-catalog.json`. Returns the app count and whether the cache /// `<data_dir>/app-catalog.json`. Returns the number of apps in the catalog on
/// changed. Best-effort: a fetch failure leaves the existing cache untouched /// success. Best-effort: a fetch failure leaves the existing cache untouched
/// (origin-always-wins; updates simply aren't refreshed this cycle). /// (origin-always-wins; updates simply aren't refreshed this cycle).
pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh> { pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<usize> {
let mirrors = crate::update::load_mirrors(data_dir) let mirrors = crate::update::load_mirrors(data_dir)
.await .await
.unwrap_or_default(); .unwrap_or_default();
let urls = catalog_urls_from_mirrors(&mirrors); let urls = catalog_urls_from_mirrors(&mirrors);
if urls.is_empty() { if urls.is_empty() {
debug!("app-catalog: no mirror-derived URLs to fetch from"); debug!("app-catalog: no mirror-derived URLs to fetch from");
return Ok(CatalogRefresh { return Ok(0);
apps: 0,
changed: false,
});
} }
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
@ -373,23 +360,13 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh>
let mut last_err: Option<anyhow::Error> = None; let mut last_err: Option<anyhow::Error> = None;
for url in &urls { for url in &urls {
match fetch_one(&client, url).await { match fetch_one(&client, url).await {
Ok((catalog, body)) => { Ok(catalog) => {
let count = catalog.apps.len(); let count = catalog.apps.len();
let changed = write_cache(data_dir, &body)?; write_cache(data_dir, &catalog)?;
if changed {
// Invalidate the in-process cache so the next read re-parses. // Invalidate the in-process cache so the next read re-parses.
*CACHE.lock().unwrap() = None; *CACHE.lock().unwrap() = None;
} info!("app-catalog: refreshed from {} ({} apps)", url, count);
info!( return Ok(count);
"app-catalog: refreshed from {} ({} apps{})",
url,
count,
if changed { ", changed" } else { ", unchanged" }
);
return Ok(CatalogRefresh {
apps: count,
changed,
});
} }
Err(e) => { Err(e) => {
debug!("app-catalog: fetch {} failed: {}", url, e); debug!("app-catalog: fetch {} failed: {}", url, e);
@ -400,10 +377,7 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh>
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable"))) Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
} }
async fn fetch_one( async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<AppCatalog> {
client: &reqwest::Client,
url: &str,
) -> anyhow::Result<(AppCatalog, String)> {
let resp = client.get(url).send().await?; let resp = client.get(url).send().await?;
if !resp.status().is_success() { if !resp.status().is_success() {
anyhow::bail!("HTTP {}", resp.status()); anyhow::bail!("HTTP {}", resp.status());
@ -442,29 +416,16 @@ async fn fetch_one(
} }
} }
Ok((catalog, body)) Ok(catalog)
} }
/// Atomically write the catalog cache. Caches the RAW fetched bytes — not a fn write_cache(data_dir: &Path, catalog: &AppCatalog) -> anyhow::Result<()> {
/// re-serialization of the typed struct — for two reasons: the struct's
/// `apps` HashMap serializes in nondeterministic order (a re-serialized
/// comparison would report "changed" on every poll), and the raw bytes are
/// the signed preimage, so the cache stays signature-verifiable. Returns
/// `false` (skipping the write) when the bytes are identical to what's
/// already cached, so callers can tell a genuine catalog change from an
/// unchanged poll.
fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result<bool> {
let dest = data_dir.join(APP_CATALOG_FILE); let dest = data_dir.join(APP_CATALOG_FILE);
if std::fs::read_to_string(&dest)
.map(|current| current == body)
.unwrap_or(false)
{
return Ok(false);
}
let tmp = data_dir.join(format!("{}.tmp", APP_CATALOG_FILE)); let tmp = data_dir.join(format!("{}.tmp", APP_CATALOG_FILE));
std::fs::write(&tmp, body)?; let json = serde_json::to_string_pretty(catalog)?;
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &dest)?; std::fs::rename(&tmp, &dest)?;
Ok(true) Ok(())
} }
#[cfg(test)] #[cfg(test)]
@ -501,32 +462,6 @@ mod tests {
assert_eq!(e.digest.as_deref(), Some("blake3:deadbeef")); assert_eq!(e.digest.as_deref(), Some("blake3:deadbeef"));
} }
#[test]
fn write_cache_reports_changed_only_on_new_bytes() {
// The changed flag gates the runtime manifest-overlay reload: an
// unchanged hourly poll must NOT report changed (or every tick would
// rebuild the manifest map), while a genuinely new catalog must. Raw
// fetched bytes are compared — a re-serialized comparison would flap
// on the apps HashMap's nondeterministic key order (seen live on .228).
let dir = tempfile::tempdir().unwrap();
let body = r#"{"schema":1,"apps":{"demo":{"version":"1.0.0"}}}"#;
assert!(write_cache(dir.path(), body).unwrap(), "first write is a change");
assert!(
!write_cache(dir.path(), body).unwrap(),
"identical rewrite is not a change"
);
let body2 = r#"{"schema":1,"apps":{"demo":{"version":"1.0.1"}}}"#;
assert!(
write_cache(dir.path(), body2).unwrap(),
"new catalog bytes are a change"
);
assert_eq!(
std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(),
body2,
"cache holds the raw signed preimage bytes"
);
}
#[test] #[test]
fn entry_carries_embedded_manifest() { fn entry_carries_embedded_manifest() {
let json = r#"{ let json = r#"{

View File

@ -424,7 +424,7 @@ fn build_unit(spec: &CompanionSpec, image: &str) -> QuadletUnit {
ports: spec ports: spec
.ports .ports
.iter() .iter()
.map(|(host, container)| (*host, *container, "tcp".into(), String::new())) .map(|(host, container)| (*host, *container, "tcp".into()))
.collect(), .collect(),
extra_podman_args: vec![], extra_podman_args: vec![],
depends_on: vec![], depends_on: vec![],

View File

@ -1,102 +0,0 @@
//! Trusted-registry policy for container image references — the single
//! source of truth. The RPC boundary (`api::rpc::package::config`) and the
//! orchestrator's pull sites both validate against this, so a catalog- or
//! manifest-supplied ref can't reach `pull_image` unchecked (§A of the
//! 1.8.0 hardening plan).
/// Registries images may be pulled from with an explicit host part.
/// (git.tx1138.com was removed 2026-07-10: the host is retired and must
/// never be pulled through again.)
pub const TRUSTED_REGISTRIES: &[&str] = &[
"docker.io",
"ghcr.io",
"localhost",
"146.59.87.168:3000",
];
/// Validate a container image reference.
///
/// Accepts:
/// * refs whose explicit registry host is on [`TRUSTED_REGISTRIES`]
/// (`docker.io/grafana/grafana`, `146.59.87.168:3000/archy/x:1`), and
/// * registry-less Docker Hub shorthand (`nginx`, `grafana/grafana`) —
/// the first segment has no `.`/`:` so it cannot name an attacker host;
/// resolution follows the host's registries.conf search order.
///
/// Rejects empty/oversized refs, shell metacharacters, and any ref whose
/// explicit registry host is not on the allowlist.
pub fn is_valid_docker_image(image: &str) -> bool {
if image.is_empty() || image.len() > 256 {
return false;
}
// Reject shell metacharacters
let dangerous_chars = [
'&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r', ' ', '\t',
];
if image.chars().any(|c| dangerous_chars.contains(&c)) {
return false;
}
let first_segment = match image.split('/').next() {
Some(r) if !r.is_empty() => r,
_ => return false,
};
if TRUSTED_REGISTRIES.contains(&first_segment) {
return true;
}
// No dot/colon in the first segment ⇒ it's a Docker Hub namespace or a
// bare repo name, not a registry host — allowed. Anything that *looks*
// like a host (has a dot or port) but isn't allowlisted is rejected.
!first_segment.contains('.') && !first_segment.contains(':')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_trusted_registries() {
for img in [
"docker.io/library/nginx:1.25",
"ghcr.io/owner/app:latest",
"localhost/archy-dev:1",
"146.59.87.168:3000/archy/bitcoin-knots:28.1",
] {
assert!(is_valid_docker_image(img), "{img} should be accepted");
}
}
#[test]
fn rejects_retired_tx1138_registry() {
// Retired 2026-07-10 — refs through the dead host must be refused
// at the pull site, not time out against it.
assert!(!is_valid_docker_image("git.tx1138.com/lfg2025/x:2"));
}
#[test]
fn accepts_docker_hub_shorthand() {
for img in ["nginx", "grafana/grafana:11.2.0", "lightninglabs/lnd:v0.18"] {
assert!(is_valid_docker_image(img), "{img} should be accepted");
}
}
#[test]
fn rejects_untrusted_registry_hosts() {
for img in [
"evil.com/backdoor:latest",
"203.0.113.7:5000/x",
"registry.gitlab.com/x/y",
"quay.io/x/y",
] {
assert!(!is_valid_docker_image(img), "{img} should be rejected");
}
}
#[test]
fn rejects_malformed_refs() {
assert!(!is_valid_docker_image(""));
assert!(!is_valid_docker_image(&"a".repeat(257)));
assert!(!is_valid_docker_image("docker.io/x; rm -rf /"));
assert!(!is_valid_docker_image("docker.io/$(curl evil)"));
assert!(!is_valid_docker_image("/leading-slash"));
}
}

View File

@ -234,7 +234,7 @@ pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<
} }
/// Extract version tag from a full image reference. /// Extract version tag from a full image reference.
/// e.g. "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta" /// e.g. "git.tx1138.com/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
/// Returns "latest" if no tag or tag is empty. /// Returns "latest" if no tag or tag is empty.
pub fn extract_version_from_image(image: &str) -> String { pub fn extract_version_from_image(image: &str) -> String {
// Split off the tag after the last colon, but only if it comes after the last slash // Split off the tag after the last colon, but only if it comes after the last slash
@ -328,11 +328,11 @@ mod tests {
#[test] #[test]
fn test_extract_version() { fn test_extract_version() {
assert_eq!( assert_eq!(
extract_version_from_image("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"), extract_version_from_image("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"),
"v0.18.4-beta" "v0.18.4-beta"
); );
assert_eq!( assert_eq!(
extract_version_from_image("146.59.87.168:3000/lfg2025/grafana:10.2.0"), extract_version_from_image("git.tx1138.com/lfg2025/grafana:10.2.0"),
"10.2.0" "10.2.0"
); );
assert_eq!( assert_eq!(
@ -340,7 +340,7 @@ mod tests {
"latest" "latest"
); );
assert_eq!( assert_eq!(
extract_version_from_image("146.59.87.168:3000/lfg2025/bitcoin-knots:latest"), extract_version_from_image("git.tx1138.com/lfg2025/bitcoin-knots:latest"),
"latest" "latest"
); );
} }
@ -352,7 +352,7 @@ mod tests {
"lfg2025/lnd" "lfg2025/lnd"
); );
assert_eq!( assert_eq!(
image_without_registry_or_tag("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"), image_without_registry_or_tag("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"),
"lfg2025/lnd" "lfg2025/lnd"
); );
} }
@ -369,7 +369,7 @@ mod tests {
assert_eq!( assert_eq!(
available_update_for_images( available_update_for_images(
"146.59.87.168:3000/lfg2025/nextcloud:29", "146.59.87.168:3000/lfg2025/nextcloud:29",
"146.59.87.168:3000/lfg2025/nextcloud:29", "git.tx1138.com/lfg2025/nextcloud:29",
), ),
None None
); );
@ -389,7 +389,7 @@ mod tests {
#[test] #[test]
fn test_parse_image_versions() { fn test_parse_image_versions() {
let content = r#" let content = r#"
ARCHY_REGISTRY="146.59.87.168:3000/lfg2025" ARCHY_REGISTRY="git.tx1138.com/lfg2025"
LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta" LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0" GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
# comment # comment
@ -398,11 +398,11 @@ NOT_AN_IMAGE="something"
let parsed = parse_image_versions(content); let parsed = parse_image_versions(content);
assert_eq!( assert_eq!(
parsed.get("LND_IMAGE"), parsed.get("LND_IMAGE"),
Some(&"146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta".to_string()) Some(&"git.tx1138.com/lfg2025/lnd:v0.18.4-beta".to_string())
); );
assert_eq!( assert_eq!(
parsed.get("GRAFANA_IMAGE"), parsed.get("GRAFANA_IMAGE"),
Some(&"146.59.87.168:3000/lfg2025/grafana:10.2.0".to_string()) Some(&"git.tx1138.com/lfg2025/grafana:10.2.0".to_string())
); );
assert!(!parsed.contains_key("NOT_AN_IMAGE")); assert!(!parsed.contains_key("NOT_AN_IMAGE"));
assert!(!parsed.contains_key("ARCHY_REGISTRY")); assert!(!parsed.contains_key("ARCHY_REGISTRY"));

View File

@ -43,11 +43,7 @@ pub enum EnsureOutcome {
Unchanged, Unchanged,
} }
pub async fn ensure_config( pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result<EnsureOutcome> {
paths: &EnsurePaths,
rpc_pass: &str,
bitcoin_host: &str,
) -> Result<EnsureOutcome> {
fs::create_dir_all(&paths.data_dir) fs::create_dir_all(&paths.data_dir)
.await .await
.with_context(|| format!("creating {}", paths.data_dir.display()))?; .with_context(|| format!("creating {}", paths.data_dir.display()))?;
@ -56,7 +52,7 @@ pub async fn ensure_config(
let existing = fs::read_to_string(&paths.conf_path) let existing = fs::read_to_string(&paths.conf_path)
.await .await
.with_context(|| format!("reading {}", paths.conf_path.display()))?; .with_context(|| format!("reading {}", paths.conf_path.display()))?;
if has_required_lnd_flags(&existing, rpc_pass, bitcoin_host) { if has_required_lnd_flags(&existing, rpc_pass) {
return Ok(EnsureOutcome::Unchanged); return Ok(EnsureOutcome::Unchanged);
} }
} }
@ -72,11 +68,12 @@ restlisten=0.0.0.0:8080\n\
bitcoin.active=true\n\ bitcoin.active=true\n\
bitcoin.mainnet=true\n\ bitcoin.mainnet=true\n\
bitcoin.node=bitcoind\n\ bitcoin.node=bitcoind\n\
bitcoind.rpchost={bitcoin_host}:8332\n\ bitcoind.rpchost=bitcoin-knots:8332\n\
bitcoind.rpcuser=archipelago\n\ bitcoind.rpcuser=archipelago\n\
bitcoind.rpcpass={rpc_pass}\n\ bitcoind.rpcpass={}\n\
bitcoind.rpcpolling=true\n\ bitcoind.rpcpolling=true\n\
bitcoind.estimatemode=ECONOMICAL\n" bitcoind.estimatemode=ECONOMICAL\n",
rpc_pass
); );
write_config_atomically(paths, &conf).await?; write_config_atomically(paths, &conf).await?;
@ -656,87 +653,19 @@ fn shell_quote(s: &str) -> String {
s.replace('\'', "'\\''") s.replace('\'', "'\\''")
} }
fn has_required_lnd_flags(conf: &str, rpc_pass: &str, bitcoin_host: &str) -> bool { fn has_required_lnd_flags(conf: &str, rpc_pass: &str) -> bool {
let rpc_pass_line = format!("bitcoind.rpcpass={rpc_pass}"); let rpc_pass_line = format!("bitcoind.rpcpass={rpc_pass}");
let rpc_host_line = format!("bitcoind.rpchost={bitcoin_host}:8332");
[ [
"bitcoin.active=true", "bitcoin.active=true",
"bitcoin.mainnet=true", "bitcoin.mainnet=true",
"bitcoin.node=bitcoind", "bitcoin.node=bitcoind",
rpc_host_line.as_str(), "bitcoind.rpchost=bitcoin-knots:8332",
rpc_pass_line.as_str(), rpc_pass_line.as_str(),
] ]
.iter() .iter()
.all(|needle| conf.lines().any(|line| line.trim() == *needle)) .all(|needle| conf.lines().any(|line| line.trim() == *needle))
} }
/// Secret file consumed by btcpay-server's optional `BTCPAY_BTCLIGHTNING`
/// secret_env (see apps/btcpay-server/manifest.yml).
const BTCPAY_LND_CONNECTION_SECRET: &str = "btcpay-lnd-connection";
/// Materialise the BTCPay→internal-LND connection-string secret.
///
/// LND's datadir is owned by its container subuid (100999 on a stock node),
/// so btcpay cannot bind-mount the macaroon — EACCES across the userns
/// boundary. The connection string therefore carries the macaroon inline as
/// hex, delivered by reference through the podman secret store.
///
/// No-op when LND isn't provisioned yet (missing tls.cert or macaroon) —
/// btcpay's secret_env entry is `optional`, so it simply starts without an
/// internal Lightning node and picks it up on a later reconcile tick.
/// Rewrites when the pinned cert thumbprint no longer matches (LND TLS cert
/// rotation). Macaroon rotation without cert rotation is not auto-detected
/// (reading the macaroon needs sudo; probing it every tick is not worth the
/// churn) — delete the secret file once to force regeneration.
pub async fn ensure_btcpay_lnd_connection_secret(secrets_dir: &std::path::Path) -> Result<()> {
let cert_path = format!("{DEFAULT_DATA_DIR}/tls.cert");
let pem = match fs::read_to_string(&cert_path).await {
Ok(s) => s,
Err(_) => return Ok(()), // LND not installed/provisioned yet
};
let thumbprint =
cert_sha256_thumbprint(&pem).context("computing LND tls.cert thumbprint")?;
let target = secrets_dir.join(BTCPAY_LND_CONNECTION_SECRET);
// Fast path (no sudo): existing secret already pins the current cert.
if let Ok(existing) = fs::read_to_string(&target).await {
if !existing.trim().is_empty()
&& existing.contains(&format!("certthumbprint={thumbprint}"))
{
return Ok(());
}
}
let macaroon_path = format!("{DEFAULT_DATA_DIR}/data/chain/bitcoin/mainnet/admin.macaroon");
if !file_exists_as_root(&macaroon_path).await {
return Ok(()); // wallet not created yet; next tick retries
}
let macaroon = read_file_as_root(&macaroon_path).await?;
let value = format!(
"type=lnd-rest;server=https://lnd:8080/;macaroon={};certthumbprint={}",
hex::encode(macaroon),
thumbprint
);
crate::container::secrets::write_secret_file(&target, &value)
.context("writing btcpay-lnd-connection secret")
}
/// SHA256 over the DER certificate body (matches
/// `openssl x509 -fingerprint -sha256` without colons) — the format BTCPay's
/// `certthumbprint=` connection-string parameter expects.
fn cert_sha256_thumbprint(pem: &str) -> Result<String> {
use sha2::{Digest, Sha256};
let b64: String = pem
.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<Vec<_>>()
.join("");
let der = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.context("decoding tls.cert PEM body")?;
Ok(hex::encode_upper(Sha256::digest(&der)))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -749,7 +678,7 @@ mod tests {
conf_path: tmp.path().join("lnd/lnd.conf"), conf_path: tmp.path().join("lnd/lnd.conf"),
}; };
let out = ensure_config(&paths, "secret", "bitcoin-knots").await.unwrap(); let out = ensure_config(&paths, "secret").await.unwrap();
assert_eq!(out, EnsureOutcome::Written); assert_eq!(out, EnsureOutcome::Written);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap(); let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoin.active=true")); assert!(conf.contains("bitcoin.active=true"));
@ -768,46 +697,17 @@ mod tests {
}; };
assert_eq!( assert_eq!(
ensure_config(&paths, "first", "bitcoin-knots").await.unwrap(), ensure_config(&paths, "first").await.unwrap(),
EnsureOutcome::Written EnsureOutcome::Written
); );
assert_eq!( assert_eq!(
ensure_config(&paths, "second", "bitcoin-knots").await.unwrap(), ensure_config(&paths, "second").await.unwrap(),
EnsureOutcome::Written EnsureOutcome::Written
); );
let conf = fs::read_to_string(&paths.conf_path).await.unwrap(); let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoind.rpcpass=second")); assert!(conf.contains("bitcoind.rpcpass=second"));
} }
#[tokio::test]
async fn ensure_config_repairs_bitcoin_host_drift() {
// A conf written against bitcoin-knots must be rewritten when the
// node's Bitcoin variant is bitcoin-core, or LND dials a hostname
// that doesn't exist on archy-net and dies on startup.
let tmp = tempfile::TempDir::new().unwrap();
let paths = EnsurePaths {
data_dir: tmp.path().join("lnd"),
conf_path: tmp.path().join("lnd/lnd.conf"),
};
assert_eq!(
ensure_config(&paths, "pw", "bitcoin-knots").await.unwrap(),
EnsureOutcome::Written
);
assert_eq!(
ensure_config(&paths, "pw", "bitcoin-core").await.unwrap(),
EnsureOutcome::Written
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoind.rpchost=bitcoin-core:8332"));
assert!(!conf.contains("bitcoind.rpchost=bitcoin-knots:8332"));
assert_eq!(
ensure_config(&paths, "pw", "bitcoin-core").await.unwrap(),
EnsureOutcome::Unchanged
);
}
#[tokio::test] #[tokio::test]
async fn ensure_config_repairs_incomplete_existing_config() { async fn ensure_config_repairs_incomplete_existing_config() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
@ -821,7 +721,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
ensure_config(&paths, "repaired", "bitcoin-knots").await.unwrap(), ensure_config(&paths, "repaired").await.unwrap(),
EnsureOutcome::Written EnsureOutcome::Written
); );
let conf = fs::read_to_string(&paths.conf_path).await.unwrap(); let conf = fs::read_to_string(&paths.conf_path).await.unwrap();

View File

@ -7,7 +7,6 @@ pub mod dev_orchestrator;
pub mod docker_packages; pub mod docker_packages;
pub mod filebrowser; pub mod filebrowser;
pub mod hooks; pub mod hooks;
pub mod image_policy;
pub mod image_versions; pub mod image_versions;
pub mod lnd; pub mod lnd;
pub mod prod_orchestrator; pub mod prod_orchestrator;

File diff suppressed because it is too large Load Diff

View File

@ -140,17 +140,8 @@ pub struct QuadletUnit {
// Backend-manifest extensions (Phase 3.1). Companion units leave // Backend-manifest extensions (Phase 3.1). Companion units leave
// these defaulted; the renderer skips empty/false directives so a // these defaulted; the renderer skips empty/false directives so a
// companion's rendered bytes are unchanged from before this PR. // companion's rendered bytes are unchanged from before this PR.
/// (host_port, container_port, protocol, bind). `bind` empty = 0.0.0.0. pub ports: Vec<(u16, u16, String)>,
pub ports: Vec<(u16, u16, String, String)>,
pub environment: Vec<String>, pub environment: Vec<String>,
/// Secret-backed env: (env_key, podman secret name). Rendered as
/// `Secret=<name>,type=env,target=<key>` so the VALUE never lands in
/// this unit file on disk — only a reference to the podman secret
/// store. The orchestrator registers the secrets before writing units.
pub secret_env: Vec<(String, String)>,
/// Container labels (`Label=k=v`). Carries the secret-env content hash
/// for rotation-drift detection.
pub labels: Vec<(String, String)>,
pub devices: Vec<String>, pub devices: Vec<String>,
pub add_hosts: Vec<(String, String)>, pub add_hosts: Vec<(String, String)>,
pub network_aliases: Vec<String>, pub network_aliases: Vec<String>,
@ -242,19 +233,13 @@ impl QuadletUnit {
// (exit 125). Skip publishing in host mode — matches the NetworkMode // (exit 125). Skip publishing in host mode — matches the NetworkMode
// doc note that Podman discards port mappings under host networking. // doc note that Podman discards port mappings under host networking.
if !matches!(self.network, NetworkMode::Host) { if !matches!(self.network, NetworkMode::Host) {
for (host, container, proto, bind) in &self.ports { for (host, container, proto) in &self.ports {
let p = if proto.is_empty() { let p = if proto.is_empty() {
"tcp" "tcp"
} else { } else {
proto.as_str() proto.as_str()
}; };
// Keep the rendered directive byte-identical for unbound
// ports so existing units don't read as drifted.
if bind.is_empty() {
let _ = writeln!(s, "PublishPort={host}:{container}/{p}"); let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
} else {
let _ = writeln!(s, "PublishPort={bind}:{host}:{container}/{p}");
}
} }
} }
for env in &self.environment { for env in &self.environment {
@ -262,12 +247,6 @@ impl QuadletUnit {
// accepts that form on a single Environment= line per pair. // accepts that form on a single Environment= line per pair.
let _ = writeln!(s, "Environment={}", quote_environment(env)); let _ = writeln!(s, "Environment={}", quote_environment(env));
} }
for (key, secret_name) in &self.secret_env {
let _ = writeln!(s, "Secret={secret_name},type=env,target={key}");
}
for (k, v) in &self.labels {
let _ = writeln!(s, "Label={k}={v}");
}
for dev in &self.devices { for dev in &self.devices {
let _ = writeln!(s, "AddDevice={dev}"); let _ = writeln!(s, "AddDevice={dev}");
} }
@ -433,39 +412,9 @@ impl QuadletUnit {
ports: app ports: app
.ports .ports
.iter() .iter()
.filter(|p| { .map(|p| (p.host, p.container, p.protocol.clone()))
let ok = archipelago_container::manifest::host_can_bind_publish_ip(&p.bind);
if !ok {
tracing::warn!(
app = %app.id,
bind = %p.bind,
host_port = p.host,
"dropping publish: bind address not assignable on this host \
(rootlessport would crash-loop the unit)"
);
}
ok
})
.map(|p| (p.host, p.container, p.protocol.clone(), p.bind.clone()))
.collect(), .collect(),
environment: app.environment.clone(), environment: app.environment.clone(),
secret_env: app
.container
.secret_env_refs
.iter()
.map(|r| (r.env_key.clone(), r.secret_name.clone()))
.collect(),
labels: app
.container
.secret_env_hash
.iter()
.map(|h| {
(
archipelago_container::manifest::SECRET_ENV_HASH_LABEL.to_string(),
h.clone(),
)
})
.collect(),
devices: app.devices.clone(), devices: app.devices.clone(),
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())], add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
// Container always answers to its own name; manifest extras add the // Container always answers to its own name; manifest extras add the
@ -538,18 +487,13 @@ fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<Hea
format!("{url}{path}") format!("{url}{path}")
}; };
let helper_timeout = health_timeout_seconds(&hc.timeout); let helper_timeout = health_timeout_seconds(&hc.timeout);
let (tcp_host, tcp_port) = health_url_host_port(&final_url);
// Images vary wildly: SearXNG ships wget but no curl, while some // Images vary wildly: SearXNG ships wget but no curl, while some
// images (btcpay's dotnet base) ship neither but do have bash — // Node images ship neither. Use whichever probe helper exists and
// fall through to a bash /dev/tcp connect probe there so the // skip Podman health if the image has none; host-side lifecycle
// container can't report healthy while its port is dead // probes still verify reachability.
// (btcpay-server, 2026-07-10). Podman's own health timeout bounds
// the connect attempt. Only images with none of the three skip
// Podman health entirely; host-side lifecycle probes still verify
// reachability for those.
format!( format!(
"if command -v wget >/dev/null 2>&1; then wget -q -T {1} -O /dev/null {0}; elif command -v curl >/dev/null 2>&1; then curl -fsS -m {1} {0}; elif command -v bash >/dev/null 2>&1; then bash -c 'exec 3<>/dev/tcp/{2}/{3}'; else exit 0; fi", "if command -v wget >/dev/null 2>&1; then wget -q -T {1} -O /dev/null {0}; elif command -v curl >/dev/null 2>&1; then curl -fsS -m {1} {0}; else exit 0; fi",
final_url, helper_timeout, tcp_host, tcp_port final_url, helper_timeout
) )
} }
"cmd" => hc.endpoint.as_deref()?.to_string(), "cmd" => hc.endpoint.as_deref()?.to_string(),
@ -563,24 +507,6 @@ fn translate_health_check(hc: &archipelago_container::HealthCheck) -> Option<Hea
}) })
} }
/// Host and port of an http(s) health URL, for the bash /dev/tcp fallback
/// probe. Defaults to port 80/443 by scheme when the authority carries none.
fn health_url_host_port(url: &str) -> (String, u16) {
let (default_port, rest) = if let Some(rest) = url.strip_prefix("https://") {
(443, rest)
} else {
(80, url.strip_prefix("http://").unwrap_or(url))
};
let authority = rest.split('/').next().unwrap_or(rest);
match authority.rsplit_once(':') {
Some((host, port)) => match port.parse::<u16>() {
Ok(port) => (host.to_string(), port),
Err(_) => (authority.to_string(), default_port),
},
None => (authority.to_string(), default_port),
}
}
fn health_timeout_seconds(raw: &str) -> u64 { fn health_timeout_seconds(raw: &str) -> u64 {
let trimmed = raw.trim(); let trimmed = raw.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@ -921,24 +847,6 @@ mod tests {
use super::*; use super::*;
use tempfile::tempdir; use tempfile::tempdir;
#[test]
fn render_emits_secret_env_by_reference_never_value() {
let u = QuadletUnit {
name: "t".into(),
description: "t".into(),
image: "img".into(),
secret_env: vec![("DB_PASS".into(), "archy-env-app-db_pass".into())],
labels: vec![("io.archipelago.secret-env-hash".into(), "abc123".into())],
..QuadletUnit::default()
};
let s = u.render();
assert!(s.contains("Secret=archy-env-app-db_pass,type=env,target=DB_PASS"));
assert!(s.contains("Label=io.archipelago.secret-env-hash=abc123"));
// the secret VALUE never had a path into this unit — but guard the
// env channel anyway: no Environment= line may mention the key
assert!(!s.contains("Environment=DB_PASS"));
}
fn sample_unit() -> QuadletUnit { fn sample_unit() -> QuadletUnit {
QuadletUnit { QuadletUnit {
name: "archy-bitcoin-ui".into(), name: "archy-bitcoin-ui".into(),
@ -1000,7 +908,7 @@ mod tests {
// Podman rejects PublishPort with Network=host (crash-loop exit 125). // Podman rejects PublishPort with Network=host (crash-loop exit 125).
let mut u = sample_unit(); let mut u = sample_unit();
u.network = NetworkMode::Host; u.network = NetworkMode::Host;
u.ports = vec![(3000, 3000, "tcp".into(), String::new())]; u.ports = vec![(3000, 3000, "tcp".into())];
let s = u.render(); let s = u.render();
assert!(s.contains("Network=host")); assert!(s.contains("Network=host"));
assert!(!s.contains("PublishPort")); assert!(!s.contains("PublishPort"));
@ -1010,7 +918,7 @@ mod tests {
fn render_non_host_network_emits_publish_ports() { fn render_non_host_network_emits_publish_ports() {
let mut u = sample_unit(); let mut u = sample_unit();
u.network = NetworkMode::Bridge("archy-net".into()); u.network = NetworkMode::Bridge("archy-net".into());
u.ports = vec![(3000, 3000, "tcp".into(), String::new())]; u.ports = vec![(3000, 3000, "tcp".into())];
let s = u.render(); let s = u.render();
assert!(s.contains("PublishPort=3000:3000/tcp")); assert!(s.contains("PublishPort=3000:3000/tcp"));
} }
@ -1128,29 +1036,6 @@ mod tests {
assert_eq!(RestartPolicy::OnFailure.as_systemd(), "on-failure"); assert_eq!(RestartPolicy::OnFailure.as_systemd(), "on-failure");
} }
#[test]
fn render_bound_ports_prefix_the_bind_address() {
// Bitcoin RPC hardening: the same host port published on loopback +
// the archy-net gateway; an unbound port must render byte-identical
// to the legacy form so existing units don't read as drifted.
let u = QuadletUnit {
name: "bitcoin-knots".into(),
image: "registry/bitcoin-knots:latest".into(),
network: NetworkMode::Bridge("archy-net".into()),
ports: vec![
(8332, 8332, "tcp".into(), "127.0.0.1".into()),
(8332, 8332, "tcp".into(), "10.89.0.1".into()),
(8333, 8333, "tcp".into(), String::new()),
],
..QuadletUnit::default()
};
let s = u.render();
assert!(s.contains("PublishPort=127.0.0.1:8332:8332/tcp"));
assert!(s.contains("PublishPort=10.89.0.1:8332:8332/tcp"));
assert!(s.contains("PublishPort=8333:8333/tcp"));
assert!(!s.contains("PublishPort=8332:8332/tcp"));
}
#[test] #[test]
fn render_emits_backend_directives_when_set() { fn render_emits_backend_directives_when_set() {
let u = QuadletUnit { let u = QuadletUnit {
@ -1160,10 +1045,7 @@ mod tests {
network: NetworkMode::Bridge("archy-net".into()), network: NetworkMode::Bridge("archy-net".into()),
cap_drop_all: true, cap_drop_all: true,
cap_add: vec!["NET_BIND_SERVICE".into()], cap_add: vec!["NET_BIND_SERVICE".into()],
ports: vec![ ports: vec![(8332, 8332, "tcp".into()), (8333, 8333, "tcp".into())],
(8332, 8332, "tcp".into(), String::new()),
(8333, 8333, "tcp".into(), String::new()),
],
environment: vec![ environment: vec![
"BITCOIN_RPC_USER=archipelago".into(), "BITCOIN_RPC_USER=archipelago".into(),
"BITCOIN_RPC_PASS=secret".into(), "BITCOIN_RPC_PASS=secret".into(),
@ -1258,7 +1140,7 @@ app:
assert!(u.read_only_root); assert!(u.read_only_root);
assert!(u.no_new_privileges); assert!(u.no_new_privileges);
assert_eq!(u.cap_add, vec!["NET_BIND_SERVICE"]); assert_eq!(u.cap_add, vec!["NET_BIND_SERVICE"]);
assert_eq!(u.ports, vec![(8332, 8332, "tcp".to_string(), String::new())]); assert_eq!(u.ports, vec![(8332, 8332, "tcp".to_string())]);
assert_eq!(u.environment, vec!["BITCOIN_NETWORK=mainnet"]); assert_eq!(u.environment, vec!["BITCOIN_NETWORK=mainnet"]);
assert_eq!(u.bind_mounts.len(), 1); assert_eq!(u.bind_mounts.len(), 1);
assert_eq!( assert_eq!(
@ -1482,22 +1364,7 @@ app:
let h = translate_health_check(&http).expect("http must translate"); let h = translate_health_check(&http).expect("http must translate");
assert_eq!( assert_eq!(
h.cmd, h.cmd,
"if command -v wget >/dev/null 2>&1; then wget -q -T 3 -O /dev/null http://localhost:8080/health; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 3 http://localhost:8080/health; elif command -v bash >/dev/null 2>&1; then bash -c 'exec 3<>/dev/tcp/localhost/8080'; else exit 0; fi" "if command -v wget >/dev/null 2>&1; then wget -q -T 3 -O /dev/null http://localhost:8080/health; elif command -v curl >/dev/null 2>&1; then curl -fsS -m 3 http://localhost:8080/health; else exit 0; fi"
);
// The /dev/tcp fallback must target the URL's host:port, with
// scheme-default ports when the authority carries none.
assert_eq!(
health_url_host_port("http://localhost:8080/health"),
("localhost".to_string(), 8080)
);
assert_eq!(
health_url_host_port("http://127.0.0.1/"),
("127.0.0.1".to_string(), 80)
);
assert_eq!(
health_url_host_port("https://localhost/status"),
("localhost".to_string(), 443)
); );
let cmdck = HealthCheck { let cmdck = HealthCheck {

View File

@ -11,17 +11,12 @@ use tokio::fs;
const REGISTRY_FILE: &str = "config/registries.json"; const REGISTRY_FILE: &str = "config/registries.json";
const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025"; const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025";
/// Retired registry host (release server retired 2026-06-13; the registry const TX1138_REGISTRY_URL: &str = "git.tx1138.com/lfg2025";
/// frontend was fully dead by 2026-07-10 — 500 on every /v2 manifest read).
/// Never a default, never force-enabled; stripped from saved configs on
/// load. The literal exists ONLY so the strip can match — nothing may pull
/// through this host.
const RETIRED_TX1138_HOST: &str = "git.tx1138.com";
/// A single container registry. /// A single container registry.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Registry { pub struct Registry {
/// Registry URL (e.g., "146.59.87.168:3000/lfg2025"). /// Registry URL (e.g., "git.tx1138.com/lfg2025" or "146.59.87.168:3000/lfg2025").
pub url: String, pub url: String,
/// Human-readable name. /// Human-readable name.
pub name: String, pub name: String,
@ -48,13 +43,22 @@ pub struct RegistryConfig {
impl Default for RegistryConfig { impl Default for RegistryConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
registries: vec![Registry { registries: vec![
Registry {
url: OVH_REGISTRY_URL.to_string(), url: OVH_REGISTRY_URL.to_string(),
name: "Server 1 (OVH)".to_string(), name: "Server 1 (OVH)".to_string(),
tls_verify: false, tls_verify: false,
enabled: true, enabled: true,
priority: 0, priority: 0,
}], },
Registry {
url: TX1138_REGISTRY_URL.to_string(),
name: "Server 2 (tx1138)".to_string(),
tls_verify: true,
enabled: true,
priority: 10,
},
],
} }
} }
} }
@ -68,7 +72,7 @@ impl RegistryConfig {
} }
/// Rewrite an image reference to use a specific registry. /// Rewrite an image reference to use a specific registry.
/// E.g., "docker.io/lfg2025/bitcoin-knots:latest" with registry "146.59.87.168:3000/lfg2025" /// E.g., "git.tx1138.com/lfg2025/bitcoin-knots:latest" with registry "146.59.87.168:3000/lfg2025"
/// becomes "146.59.87.168:3000/lfg2025/bitcoin-knots:latest". /// becomes "146.59.87.168:3000/lfg2025/bitcoin-knots:latest".
pub fn rewrite_image(&self, image: &str, registry: &Registry) -> String { pub fn rewrite_image(&self, image: &str, registry: &Registry) -> String {
// Extract the image name (last component after the org/namespace) // Extract the image name (last component after the org/namespace)
@ -79,7 +83,7 @@ impl RegistryConfig {
} }
/// Extract the image name from a full image reference. /// Extract the image name from a full image reference.
/// "146.59.87.168:3000/lfg2025/bitcoin-knots:latest" -> "bitcoin-knots:latest" /// "git.tx1138.com/lfg2025/bitcoin-knots:latest" -> "bitcoin-knots:latest"
/// "docker.io/gitea/gitea:1.23" -> "gitea:1.23" /// "docker.io/gitea/gitea:1.23" -> "gitea:1.23"
fn extract_image_name(image: &str) -> &str { fn extract_image_name(image: &str) -> &str {
// Split by '/' and take the last segment (image:tag) // Split by '/' and take the last segment (image:tag)
@ -114,12 +118,6 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
config config
.registries .registries
.retain(|r| !r.url.contains("23.182.128.160")); .retain(|r| !r.url.contains("23.182.128.160"));
// Same treatment for the retired tx1138 registry (was Server 2 in older
// defaults): strip it on load so nothing ever pulls through the dead
// host again.
config
.registries
.retain(|r| !r.url.contains(RETIRED_TX1138_HOST));
let mut changed = config.registries.len() != before; let mut changed = config.registries.len() != before;
// Migrate: any default registry URL that isn't already in the // Migrate: any default registry URL that isn't already in the
@ -157,9 +155,7 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if changed { if changed {
// Persist so the next load doesn't have to re-merge. // Persist so the next load doesn't have to re-merge.
if let Err(e) = save_registries(data_dir, &config).await { let _ = save_registries(data_dir, &config).await;
tracing::warn!("Failed to persist migrated registry config: {e:#}");
}
} }
Ok(config) Ok(config)
} }
@ -180,6 +176,12 @@ fn force_ovh_registry_primary(config: &mut RegistryConfig) {
registry.enabled = true; registry.enabled = true;
registry.priority = 0; registry.priority = 0;
} }
TX1138_REGISTRY_URL => {
registry.name = "Server 2 (tx1138)".to_string();
registry.tls_verify = true;
registry.enabled = true;
registry.priority = 10;
}
_ => { _ => {
if registry.priority <= 10 { if registry.priority <= 10 {
registry.priority = registry.priority.saturating_add(20); registry.priority = registry.priority.saturating_add(20);
@ -212,7 +214,7 @@ mod tests {
#[test] #[test]
fn test_extract_image_name() { fn test_extract_image_name() {
assert_eq!( assert_eq!(
extract_image_name("146.59.87.168:3000/lfg2025/bitcoin-knots:latest"), extract_image_name("git.tx1138.com/lfg2025/bitcoin-knots:latest"),
"bitcoin-knots:latest" "bitcoin-knots:latest"
); );
assert_eq!( assert_eq!(
@ -225,11 +227,11 @@ mod tests {
#[test] #[test]
fn test_rewrite_image() { fn test_rewrite_image() {
let config = RegistryConfig::default(); let config = RegistryConfig::default();
// An image hardcoded to some other registry rewrites to OVH when // Default primary is now the OVH VPS (index 0). A tx1138-hardcoded
// asked for the primary mirror. // image rewrites to OVH when asked for the primary mirror.
let primary = &config.registries[0]; let primary = &config.registries[0];
assert_eq!( assert_eq!(
config.rewrite_image("docker.io/lfg2025/bitcoin-knots:latest", primary), config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", primary),
"146.59.87.168:3000/lfg2025/bitcoin-knots:latest" "146.59.87.168:3000/lfg2025/bitcoin-knots:latest"
); );
} }
@ -238,51 +240,15 @@ mod tests {
fn test_active_registries_sorted() { fn test_active_registries_sorted() {
let config = RegistryConfig::default(); let config = RegistryConfig::default();
let active = config.active_registries(); let active = config.active_registries();
assert_eq!(active.len(), 1); assert_eq!(active.len(), 2);
assert_eq!(active[0].url, OVH_REGISTRY_URL); assert!(active[0].priority <= active[1].priority);
} }
#[tokio::test] #[tokio::test]
async fn test_load_default() { async fn test_load_default() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let config = load_registries(tmp.path()).await.unwrap(); let config = load_registries(tmp.path()).await.unwrap();
assert_eq!(config.registries.len(), 1); assert_eq!(config.registries.len(), 2);
}
#[tokio::test]
async fn test_load_strips_retired_tx1138_registry() {
// Nodes provisioned before the retirement have the tx1138 registry
// baked into their saved config (was Server 2). It must be stripped
// on load and never re-added by the defaults merge.
let tmp = TempDir::new().unwrap();
let config = RegistryConfig {
registries: vec![
Registry {
url: format!("{RETIRED_TX1138_HOST}/lfg2025"),
name: "Server 2 (tx1138)".into(),
tls_verify: true,
enabled: true,
priority: 10,
},
Registry {
url: OVH_REGISTRY_URL.into(),
name: "Server 1 (OVH)".into(),
tls_verify: false,
enabled: true,
priority: 0,
},
],
};
save_registries(tmp.path(), &config).await.unwrap();
let loaded = load_registries(tmp.path()).await.unwrap();
assert!(
!loaded
.registries
.iter()
.any(|r| r.url.contains(RETIRED_TX1138_HOST)),
"retired tx1138 registry must be stripped on load; got {:?}",
loaded.registries
);
} }
#[tokio::test] #[tokio::test]
@ -298,6 +264,6 @@ mod tests {
}); });
save_registries(tmp.path(), &config).await.unwrap(); save_registries(tmp.path(), &config).await.unwrap();
let loaded = load_registries(tmp.path()).await.unwrap(); let loaded = load_registries(tmp.path()).await.unwrap();
assert_eq!(loaded.registries.len(), 2); assert_eq!(loaded.registries.len(), 3);
} }
} }

View File

@ -102,17 +102,6 @@ fn random_base64(bytes: usize) -> String {
base64::engine::general_purpose::STANDARD.encode(buf) base64::engine::general_purpose::STANDARD.encode(buf)
} }
/// Write an externally computed secret value (0600, atomic). For derived
/// secrets that aren't random generators — e.g. the btcpay internal-LND
/// connection string assembled in `container::lnd`.
pub(crate) fn write_secret_file(path: &Path, value: &str) -> Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)
.with_context(|| format!("creating secrets dir {}", dir.display()))?;
}
write_secret(path, value)
}
/// Atomically write a `0600` secret: a temp file in the same dir (so the rename /// Atomically write a `0600` secret: a temp file in the same dir (so the rename
/// is atomic), fsynced, then renamed over the target. /// is atomic), fsynced, then renamed over the target.
fn write_secret(path: &Path, value: &str) -> Result<()> { fn write_secret(path: &Path, value: &str) -> Result<()> {

View File

@ -25,26 +25,6 @@ pub trait ContainerOrchestrator: Send + Sync {
/// to a manifest the orchestrator already knows about. /// to a manifest the orchestrator already knows about.
async fn install(&self, app_id: &str) -> Result<String>; async fn install(&self, app_id: &str) -> Result<String>;
/// True when this orchestrator holds a manifest for `app_id` (disk or
/// signed-catalog overlay) — i.e. `install(app_id)` would not fail with
/// "unknown app_id". Lets the RPC layer route any manifest-driven app
/// through the orchestrator without a per-app allowlist. Defaults to
/// `false` so orchestrators without a manifest registry keep routing
/// through the legacy install flow.
async fn knows_app(&self, _app_id: &str) -> bool {
false
}
/// Rebuild the in-memory manifest map (disk + signed-catalog overlay).
/// Called after a runtime catalog refresh detects changed bytes so catalog
/// manifest changes take effect without a service restart — without this,
/// `load_manifests` only runs at startup and a freshly published manifest
/// sits dormant until the next restart. Returns the merged manifest count.
/// Defaults to a no-op for orchestrators without a manifest registry.
async fn reload_manifests(&self) -> Result<usize> {
Ok(0)
}
/// Start an already-created container. /// Start an already-created container.
async fn start(&self, app_id: &str) -> Result<()>; async fn start(&self, app_id: &str) -> Result<()>;

View File

@ -49,46 +49,6 @@ pub fn is_recovery_complete() -> bool {
RECOVERY_COMPLETE.load(Ordering::SeqCst) RECOVERY_COMPLETE.load(Ordering::SeqCst)
} }
// ── Pending boot-start tracking ─────────────────────────────────────────
// Containers that boot recovery / the reconciler is about to start (or is
// starting right now). The package scanner overlays these as `Restarting`
// instead of the raw podman `Stopped`/`Exited`, so a freshly rebooted node
// doesn't tell the user their apps are "Stopped" while the sequential
// recovery pass (3s stagger + up to minutes for heavyweights like bitcoin)
// is still working through the queue. Writers register names when a pass
// begins and remove each name once its start attempt finishes, whatever
// the outcome — a container that truly failed goes back to showing its
// real state on the next scan.
static PENDING_BOOT_STARTS: std::sync::LazyLock<std::sync::RwLock<std::collections::HashSet<String>>> =
std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashSet::new()));
/// Register container/app names an active recovery or reconcile pass
/// intends to start.
pub fn pending_boot_starts_add<I: IntoIterator<Item = String>>(names: I) {
if let Ok(mut set) = PENDING_BOOT_STARTS.write() {
set.extend(names);
}
}
/// A start attempt for `name` finished (success or failure) — stop
/// overlaying it.
pub fn pending_boot_start_done(name: &str) {
if let Ok(mut set) = PENDING_BOOT_STARTS.write() {
set.remove(name);
}
}
/// Whether `name` (a container name or scanner app id) is queued for a
/// boot/reconcile start. Container names may carry an `archy-` prefix the
/// scanner strips when deriving app ids, so check both forms.
pub fn is_pending_boot_start(name: &str) -> bool {
let Ok(set) = PENDING_BOOT_STARTS.read() else {
return false;
};
set.contains(name) || set.contains(&format!("archy-{name}"))
}
// ── User-stopped tracking ─────────────────────────────────────────────── // ── User-stopped tracking ───────────────────────────────────────────────
// When a user explicitly stops a container via the UI, we record it here // When a user explicitly stops a container via the UI, we record it here
// so crash recovery and health monitor don't auto-restart it. // so crash recovery and health monitor don't auto-restart it.
@ -218,17 +178,10 @@ pub async fn check_for_crash(data_dir: &Path) -> Result<Option<Vec<RunningContai
old_pid old_pid
); );
// Check if that PID is actually still running (zombie/stuck process). // Check if that PID is actually still running (zombie/stuck process)
// Guard against PID reuse: after a reboot the old PID often belongs to an
// unrelated process (or, before the main.rs ordering fix, to OURSELVES) —
// only treat it as "previous instance still alive" if it's a live process
// that is not us and whose cmdline looks like the archipelago binary.
if !old_pid.is_empty() { if !old_pid.is_empty() {
if let Ok(pid) = old_pid.parse::<u32>() { if let Ok(pid) = old_pid.parse::<u32>() {
if pid != std::process::id() if is_process_running(pid) {
&& is_process_running(pid)
&& process_is_archipelago(pid)
{
warn!( warn!(
"Previous process (PID {}) is still running — not a crash, skipping recovery", "Previous process (PID {}) is still running — not a crash, skipping recovery",
pid pid
@ -358,8 +311,6 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
failed: Vec::new(), failed: Vec::new(),
}; };
pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
for (i, record) in containers.iter().enumerate() { for (i, record) in containers.iter().enumerate() {
info!( info!(
"Recovering container: {} (image: {})", "Recovering container: {} (image: {})",
@ -422,7 +373,6 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
if !started { if !started {
report.failed.push(record.name.clone()); report.failed.push(record.name.clone());
} }
pending_boot_start_done(&record.name);
} }
report report
@ -441,16 +391,6 @@ fn is_process_running(pid: u32) -> bool {
std::path::Path::new(&format!("/proc/{}", pid)).exists() std::path::Path::new(&format!("/proc/{}", pid)).exists()
} }
/// Whether the process at `pid` looks like an archipelago instance. Used to
/// tell "the previous instance is genuinely still alive" apart from PID
/// reuse by an unrelated process after a reboot.
fn process_is_archipelago(pid: u32) -> bool {
match std::fs::read(format!("/proc/{pid}/cmdline")) {
Ok(cmdline) => String::from_utf8_lossy(&cmdline).contains("archipelago"),
Err(_) => false,
}
}
/// Start all stopped containers that were previously installed. /// Start all stopped containers that were previously installed.
/// Runs on every startup to ensure containers come back after clean reboots. /// Runs on every startup to ensure containers come back after clean reboots.
/// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones. /// The crash recovery (PID-based) handles dirty shutdowns; this handles clean ones.
@ -485,34 +425,16 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
); );
repair_stack_network_aliases(stack).await; repair_stack_network_aliases(stack).await;
// Register the whole stack up front: the per-member dependency waits
// below can take minutes, and the UI should say "Restarting", not
// "Stopped", for members still queued behind them.
pending_boot_starts_add(
stack
.containers
.iter()
.filter(|c| !user_stopped.contains(**c))
.map(|c| (*c).to_string()),
);
for container in stack.containers { for container in stack.containers {
if user_stopped.contains(*container) { if user_stopped.contains(*container) {
info!("Skipping user-stopped container: {}", container); info!("Skipping user-stopped container: {}", container);
continue; continue;
} }
let state = container_state(container).await; match container_state(container).await {
match state { Some(state) if state == "running" => continue,
Some(state) if state == "running" => {
pending_boot_start_done(container);
continue;
}
Some(_) => {} Some(_) => {}
None => { None => continue,
pending_boot_start_done(container);
continue;
}
} }
repair_stack_network_aliases(stack).await; repair_stack_network_aliases(stack).await;
@ -524,7 +446,6 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
} else { } else {
report.failed.push((*container).to_string()); report.failed.push((*container).to_string());
} }
pending_boot_start_done(container);
} }
} }

View File

@ -166,12 +166,7 @@ pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<Federa
} }
// Explicitly (re-)adding a node clears any prior tombstone so the // Explicitly (re-)adding a node clears any prior tombstone so the
// operator can intentionally bring back a previously removed peer. // operator can intentionally bring back a previously removed peer.
// Propagate failure BEFORE mutating the node list: with the tombstone let _ = untombstone_did(data_dir, &node.did).await;
// still in place, sync reconciliation would silently re-remove the
// node the operator just added.
untombstone_did(data_dir, &node.did)
.await
.context("clear removal tombstone")?;
nodes.push(node); nodes.push(node);
save_nodes(data_dir, &nodes).await?; save_nodes(data_dir, &nodes).await?;
Ok(nodes) Ok(nodes)
@ -184,16 +179,12 @@ pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode
if nodes.len() == before { if nodes.len() == before {
anyhow::bail!("No federated node with DID {}", did); anyhow::bail!("No federated node with DID {}", did);
} }
save_nodes(data_dir, &nodes).await?;
// Tombstone the DID so transitive federation discovery (a still-federated // Tombstone the DID so transitive federation discovery (a still-federated
// peer advertising this DID as one of *its* trusted peers) can't silently // peer advertising this DID as one of *its* trusted peers) can't silently
// re-add it. Tombstone FIRST and propagate failure: a remove whose // re-add it. Best-effort: a failed tombstone write must not fail the
// tombstone never landed isn't a remove — the peer would quietly // remove the operator asked for.
// reappear after the next sync. Tombstoning is idempotent, so if the let _ = tombstone_did(data_dir, did).await;
// node-list save below fails the operator's retry works cleanly.
tombstone_did(data_dir, did)
.await
.context("persist removal tombstone")?;
save_nodes(data_dir, &nodes).await?;
Ok(nodes) Ok(nodes)
} }

View File

@ -49,16 +49,13 @@ pub async fn sync_with_peer(
// Record transport used so the UI badge on this peer's card reflects // Record transport used so the UI badge on this peer's card reflects
// the transport that actually carried the call, not a prediction. // the transport that actually carried the call, not a prediction.
if let Err(e) = super::storage::record_peer_transport( let _ = super::storage::record_peer_transport(
data_dir, data_dir,
Some(&peer.did), Some(&peer.did),
Some(&peer.onion), Some(&peer.onion),
&transport.to_string(), &transport.to_string(),
) )
.await .await;
{
tracing::warn!("Failed to persist peer transport badge: {e:#}");
}
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?; let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
let state_val = result let state_val = result

View File

@ -147,21 +147,6 @@ fn has_running_bitcoin_conflict(name: &str, containers: &[ContainerHealth]) -> b
}) })
} }
/// True when a lifecycle op lock covering this container is held. Probes the
/// container name, the derived app id, and a '_'→'-' normalization of the
/// name — legacy stack containers are named with underscores
/// (immich_postgres) while op-lock keys and the stack-member table use the
/// hyphenated app ids.
fn lifecycle_op_covers_container(container_name: &str, app_id: &str) -> bool {
let hyphenated = container_name.replace('_', "-");
let mut keys = vec![container_name, app_id];
if hyphenated != container_name {
keys.push(hyphenated.as_str());
}
keys.iter()
.any(|key| crate::app_ops::lifecycle_op_in_flight(key))
}
/// Track restart attempts per container with exponential backoff and stability reset. /// Track restart attempts per container with exponential backoff and stability reset.
struct RestartTracker { struct RestartTracker {
attempts: HashMap<String, u32>, attempts: HashMap<String, u32>,
@ -1072,19 +1057,6 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
continue; continue;
} }
// A package.start/stop/restart worker is mid-sequence on this
// app (or its owning stack): the container is legitimately
// down between the worker's stop and start halves. Restarting
// it here races the worker the same way the reconciler's
// repair-recreate did (e275494a) — skip for this cycle.
if lifecycle_op_covers_container(&container.name, &container.app_id) {
debug!(
"Skipping auto-restart for {}: lifecycle operation in flight",
container.name
);
continue;
}
// When transitioning to a higher tier, wait briefly for previous tier to stabilize // When transitioning to a higher tier, wait briefly for previous tier to stabilize
if let Some(prev) = prev_tier { if let Some(prev) = prev_tier {
if tier > prev { if tier > prev {
@ -1484,30 +1456,6 @@ mod tests {
assert_eq!(container_tier("bitcoin-knots"), StartupTier::CoreInfra); assert_eq!(container_tier("bitcoin-knots"), StartupTier::CoreInfra);
} }
#[tokio::test]
async fn lifecycle_guard_covers_name_app_id_and_legacy_underscores() {
assert!(!lifecycle_op_covers_container("hm-guard-app", "hm-guard-app"));
// Held package lock covers the container directly and via stack
// membership; the '_'→'-' probe covers legacy underscore names.
let lock = crate::app_ops::op_lock("immich");
let _guard = lock.lock().await;
assert!(lifecycle_op_covers_container(
"immich-postgres",
"immich-postgres"
));
assert!(lifecycle_op_covers_container(
"immich_postgres",
"immich_postgres"
));
assert!(!lifecycle_op_covers_container("grafana", "grafana"));
// archy- prefixed containers probe via the stripped app id too.
let lnd = crate::app_ops::op_lock("lnd");
let _lnd_guard = lnd.lock().await;
assert!(lifecycle_op_covers_container("archy-lnd", "lnd"));
}
#[test] #[test]
fn test_container_tier_dependent() { fn test_container_tier_dependent() {
assert_eq!(container_tier("lnd"), StartupTier::DependentService); assert_eq!(container_tier("lnd"), StartupTier::DependentService);

View File

@ -26,7 +26,6 @@ use tokio::sync::Notify;
use tracing::info; use tracing::info;
mod api; mod api;
mod app_ops;
mod auth; mod auth;
mod avatar; mod avatar;
mod backup; mod backup;
@ -72,7 +71,6 @@ mod state;
mod storage_crypto; mod storage_crypto;
mod streaming; mod streaming;
mod swarm; mod swarm;
mod tollgate_sweep;
mod totp; mod totp;
mod transport; mod transport;
mod trust; mod trust;
@ -100,15 +98,11 @@ async fn main() -> Result<()> {
let startup_start = std::time::Instant::now(); let startup_start = std::time::Instant::now();
crash_recovery::init_start_time(); crash_recovery::init_start_time();
// Initialize tracing. Default to `info`: production units don't set // Initialize tracing
// RUST_LOG, and the old `archipelago=debug` default flooded journald
// with per-request debug lines ("RPC method: …", cookie-flag notes) —
// part of a >1 GB/day journal on a fresh node. Set RUST_LOG (e.g.
// RUST_LOG=archipelago=debug) to get debug logs back when debugging.
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()), .unwrap_or_else(|_| "archipelago=debug,info".into()),
) )
.init(); .init();
@ -155,18 +149,13 @@ async fn main() -> Result<()> {
); );
} }
// Check for a crash marker BEFORE writing our own. The old order wrote // Write PID marker early so we can detect crashes on next startup
// the marker first, so the check always read the CURRENT process's PID,
// found it alive, and skipped recovery — on every boot, forever.
let crash_containers = crash_recovery::check_for_crash(&config.data_dir).await;
// Now mark this instance as running so the next startup can detect a crash.
crash_recovery::write_pid_marker(&config.data_dir).await?; crash_recovery::write_pid_marker(&config.data_dir).await?;
// Run crash recovery before starting the manifest reconciler. Both paths // Run crash recovery before starting the manifest reconciler. Both paths
// mutate Podman; running them concurrently can corrupt transient runtime // mutate Podman; running them concurrently can corrupt transient runtime
// state and leave netavark/conmon unable to start containers. // state and leave netavark/conmon unable to start containers.
match crash_containers { match crash_recovery::check_for_crash(&config.data_dir).await {
Ok(Some(containers)) => { Ok(Some(containers)) => {
info!( info!(
"🔧 Recovering {} containers from previous crash...", "🔧 Recovering {} containers from previous crash...",
@ -223,10 +212,7 @@ async fn main() -> Result<()> {
) )
.await .await
{ {
Ok(Ok(r)) => info!( Ok(Ok(n)) => info!("🛰️ app-catalog refreshed before manifest load ({n} apps)"),
"🛰️ app-catalog refreshed before manifest load ({} apps)",
r.apps
),
Ok(Err(e)) => tracing::debug!("app-catalog pre-load refresh failed (using cache): {e}"), Ok(Err(e)) => tracing::debug!("app-catalog pre-load refresh failed (using cache): {e}"),
Err(_) => tracing::debug!("app-catalog pre-load refresh timed out (using cache)"), Err(_) => tracing::debug!("app-catalog pre-load refresh timed out (using cache)"),
} }

View File

@ -580,9 +580,7 @@ pub(super) async fn handle_identity_received(
// Update peer record // Update peer record
let peer = MeshPeer { let peer = MeshPeer {
contact_id, contact_id,
// .get(): a malformed DID shorter than the "did:key:" prefix must advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
// not panic the listener on a radio-supplied string.
advert_name: format!("Archy-{}", did.get(8..16.min(did.len())).unwrap_or(did)),
did: Some(did.to_string()), did: Some(did.to_string()),
pubkey_hex: Some(ed_pubkey_hex.to_string()), pubkey_hex: Some(ed_pubkey_hex.to_string()),
// The advert signature was verified above, so this is an authenticated // The advert signature was verified above, so this is an authenticated

View File

@ -423,7 +423,6 @@ pub fn spawn_mesh_listener(
lora_region: Option<String>, lora_region: Option<String>,
channel_name: Option<String>, channel_name: Option<String>,
device_kind: Option<super::types::DeviceType>, device_kind: Option<super::types::DeviceType>,
reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
shutdown: tokio::sync::watch::Receiver<bool>, shutdown: tokio::sync::watch::Receiver<bool>,
cmd_rx: mpsc::Receiver<MeshCommand>, cmd_rx: mpsc::Receiver<MeshCommand>,
) -> tokio::task::JoinHandle<()> { ) -> tokio::task::JoinHandle<()> {
@ -458,7 +457,6 @@ pub fn spawn_mesh_listener(
lora_region.as_deref(), lora_region.as_deref(),
channel_name.as_deref(), channel_name.as_deref(),
device_kind, device_kind,
reticulum_tcp.clone(),
&mut shutdown, &mut shutdown,
&mut cmd_rx, &mut cmd_rx,
) )

View File

@ -261,7 +261,7 @@ impl MeshRadioDevice {
/// `MeshConfig.device_kind` — see the plan's §2c reflashable-board note): only /// `MeshConfig.device_kind` — see the plan's §2c reflashable-board note): only
/// that one device's probe runs, so a non-matching firmware's init bytes are /// that one device's probe runs, so a non-matching firmware's init bytes are
/// never injected into the port. `None` keeps the strict /// never injected into the port. `None` keeps the strict
/// Reticulum→Meshcore→Meshtastic probe order. /// Meshcore→Meshtastic→Reticulum probe order.
async fn auto_detect_and_open( async fn auto_detect_and_open(
data_dir: &Path, data_dir: &Path,
our_ed_pubkey_hex: &str, our_ed_pubkey_hex: &str,
@ -274,34 +274,6 @@ async fn auto_detect_and_open(
} }
for path in &paths { for path in &paths {
debug!(path = %path, "Probing for mesh radio device"); debug!(path = %path, "Probing for mesh radio device");
// Tried FIRST: `ReticulumLink::open()` gates its expensive daemon
// spawn behind a cheap (~1s worst case) RNode KISS-detect probe, so a
// failed match here costs about as much as the Meshcore/Meshtastic
// probes below. Trying it first matters in practice: on a real RNode
// board, Meshcore's and Meshtastic's handshake bytes (~5s timeout
// each, ~10.6s combined) sitting on the wire before Reticulum ever
// gets a turn was observed to leave the RNode firmware unresponsive
// by the time its turn came — confirmed on hardware that responds
// correctly to the same KISS probe on a truly fresh port.
if device_kind.is_none_or(|k| k == DeviceType::Reticulum) {
match ReticulumLink::open(
path,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
{
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, "Found Reticulum (RNode) device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Reticulum(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Reticulum daemon failed to initialize"),
},
Err(e) => debug!(path = %path, error = %e, "Not a Reticulum RNode"),
}
}
if device_kind.is_none_or(|k| k == DeviceType::Meshcore) { if device_kind.is_none_or(|k| k == DeviceType::Meshcore) {
match MeshcoreDevice::open(path).await { match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await { Ok(mut dev) => match dev.initialize().await {
@ -326,6 +298,30 @@ async fn auto_detect_and_open(
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"), Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
} }
} }
// Tried LAST: the same reflashable board (e.g. Heltec V3) can run
// Meshcore, Meshtastic, or RNode firmware, so each probe must fail
// strictly before the next is attempted. The RNode KISS-detect probe
// is the most expensive (spawns the supervised daemon on a match), so
// it goes after the two cheap firmware-specific handshakes above.
if device_kind.is_none_or(|k| k == DeviceType::Reticulum) {
match ReticulumLink::open(
path,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
{
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, "Found Reticulum (RNode) device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Reticulum(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Reticulum daemon failed to initialize"),
},
Err(e) => debug!(path = %path, error = %e, "Not a Reticulum RNode"),
}
}
} }
anyhow::bail!( anyhow::bail!(
"No supported mesh radio found on {} candidate ports: {:?}", "No supported mesh radio found on {} candidate ports: {:?}",
@ -385,24 +381,6 @@ async fn open_preferred_path(
}; };
} }
// Reticulum first — see the matching comment on auto_detect_and_open:
// its cheap probe_rnode gate fails in ~1s for non-RNode firmware, while
// trying Meshcore/Meshtastic first was observed leaving a real RNode
// board unresponsive by the time Reticulum's turn came.
match ReticulumLink::open(
path,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
{
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Reticulum(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Reticulum"),
}
match MeshcoreDevice::open(path).await { match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await { Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)), Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
@ -412,55 +390,25 @@ async fn open_preferred_path(
} }
match MeshtasticDevice::open(path).await { match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await { Ok(mut dev) => match dev.initialize().await {
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)), Ok(info) => return Ok((MeshRadioDevice::Meshtastic(dev), info)),
Err(e) => Err(e).context("Preferred path is not a working Meshtastic device"), Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshtastic"),
}, },
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"), Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshtastic"),
} }
} match ReticulumLink::open(
path,
/// Bring up a Reticulum daemon over plain TCP — no physical RNode, no
/// `probe_rnode` KISS-detect gate. This is the radio-less counterpart to
/// `auto_detect_and_open`/`open_preferred_path`'s serial paths; see
/// `types::ReticulumTcpConfig`'s doc comment for scope (dev/verification,
/// loopback-only server bind).
async fn open_reticulum_tcp(
cfg: &ReticulumTcpConfig,
data_dir: &Path,
our_ed_pubkey_hex: &str,
our_x25519_pubkey_hex: &str,
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let mut dev = match cfg {
ReticulumTcpConfig::Server { bind } => {
ReticulumLink::open_tcp_server(
bind,
data_dir, data_dir,
Some(our_ed_pubkey_hex), Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex), Some(our_x25519_pubkey_hex),
) )
.await .await
.context("Could not open Reticulum TCP server interface")? {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => Ok((MeshRadioDevice::Reticulum(dev), info)),
Err(e) => Err(e).context("Preferred path is not a working Reticulum RNode"),
},
Err(e) => Err(e).context("Could not open preferred path as Reticulum"),
} }
ReticulumTcpConfig::Client { connect } => {
ReticulumLink::open_tcp_client(
connect,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open Reticulum TCP client interface")?
}
};
let info = dev
.initialize()
.await
.context("Reticulum TCP interface failed to initialize")?;
let label = match cfg {
ReticulumTcpConfig::Server { bind } => format!("tcp-server:{bind}"),
ReticulumTcpConfig::Client { connect } => format!("tcp-client:{}", connect.join(",")),
};
Ok((label, MeshRadioDevice::Reticulum(dev), info))
} }
/// ASCII marker for the original DM-via-channel format: /// ASCII marker for the original DM-via-channel format:
@ -679,19 +627,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
advert_name: contact.advert_name.clone(), advert_name: contact.advert_name.clone(),
did: existing.and_then(|p| p.did.clone()), did: existing.and_then(|p| p.did.clone()),
pubkey_hex: Some(contact.public_key_hex.clone()), pubkey_hex: Some(contact.public_key_hex.clone()),
// Reticulum carries the archipelago identity in-band with // Preserve any archipelago identity bound by an earlier
// the contact snapshot itself (see `ParsedContact:: // identity advert — NEVER overwrite it with the firmware
// arch_pubkey_hex`'s doc comment) — prefer it when this // contact key, or a signed `!ai` query from this peer would
// refresh's snapshot has one. Otherwise preserve whatever // fail authentication after the next contact refresh.
// was bound by an earlier identity advert (Meshcore/ arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()),
// Meshtastic's `bind_federation_twins` path): NEVER
// overwrite a known identity with the firmware contact
// key, or a signed `!ai` query from this peer would fail
// authentication after the next contact refresh.
arch_pubkey_hex: contact
.arch_pubkey_hex
.clone()
.or_else(|| existing.and_then(|p| p.arch_pubkey_hex.clone())),
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey), x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
// Meshtastic-only today (see ParsedContact) — falls back to // Meshtastic-only today (see ParsedContact) — falls back to
// whatever was already known if this refresh's contact // whatever was already known if this refresh's contact
@ -847,17 +787,11 @@ pub(super) async fn run_mesh_session(
lora_region: Option<&str>, lora_region: Option<&str>,
channel_name: Option<&str>, channel_name: Option<&str>,
device_kind: Option<DeviceType>, device_kind: Option<DeviceType>,
reticulum_tcp: Option<ReticulumTcpConfig>,
shutdown: &mut tokio::sync::watch::Receiver<bool>, shutdown: &mut tokio::sync::watch::Receiver<bool>,
cmd_rx: &mut mpsc::Receiver<MeshCommand>, cmd_rx: &mut mpsc::Receiver<MeshCommand>,
) -> Result<()> { ) -> Result<()> {
// Detect device — TCP Reticulum config (radio-less) takes priority when // Detect device — try preferred path first, fall back to auto-detect
// set, otherwise try the preferred serial path, falling back to let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
// auto-detect. TCP mode is additive/dev-only; it never changes behavior
// for existing serial/RNode deployments where `reticulum_tcp` is None.
let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp {
open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await?
} else if let Some(path) = preferred_path {
match open_preferred_path( match open_preferred_path(
path, path,
data_dir, data_dir,

View File

@ -180,12 +180,6 @@ impl MeshtasticDevice {
"Failed to open serial port {} (permission denied? device busy?)", "Failed to open serial port {} (permission denied? device busy?)",
path path
))?; ))?;
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
// boards reset on a DTR/RTS transition, so deassert both and settle
// before the handshake below.
let _ = port.set_dtr(false);
let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await;
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port"); info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
Ok(Self { Ok(Self {
@ -982,7 +976,6 @@ impl MeshtasticDevice {
snr, snr,
lat, lat,
lon, lon,
arch_pubkey_hex: None,
}, },
); );
} }
@ -1055,7 +1048,6 @@ fn packet_to_inbound_frame(
snr: None, snr: None,
lat: None, lat: None,
lon: None, lon: None,
arch_pubkey_hex: None,
}); });
if packet.rx_rssi.is_some() { if packet.rx_rssi.is_some() {
contact.rssi = packet.rx_rssi.map(|v| v as i16); contact.rssi = packet.rx_rssi.map(|v| v as i16);

View File

@ -258,31 +258,7 @@ impl TypedEnvelope {
} }
} }
/// Signing preimage v2: binds the anti-replay `seq` so a radio MITM /// Verify signature if present.
/// can't reorder/replay a signed message under a different sequence
/// number. v1 (legacy) covers only (t, v, ts).
fn signing_preimage_v2(&self) -> Vec<u8> {
let mut sign_data = Vec::with_capacity(1 + self.v.len() + 4 + 8);
sign_data.push(self.t);
sign_data.extend_from_slice(&self.v);
sign_data.extend_from_slice(&self.ts.to_le_bytes());
sign_data.extend_from_slice(&self.seq.to_le_bytes());
sign_data
}
fn signing_preimage_v1(&self) -> Vec<u8> {
let mut sign_data = Vec::with_capacity(1 + self.v.len() + 4);
sign_data.push(self.t);
sign_data.extend_from_slice(&self.v);
sign_data.extend_from_slice(&self.ts.to_le_bytes());
sign_data
}
/// Verify signature if present. Accepts the seq-binding v2 preimage OR
/// the legacy (t, v, ts) preimage — senders still emit v1 until the
/// whole fleet verifies v2 (receivers hard-drop bad signatures, so
/// flipping the send side first would break mixed-fleet alerts). The
/// seq-tampering window closes only when the v1 arm is removed.
pub fn verify_signature(&self, verifying_key: &ed25519_dalek::VerifyingKey) -> Result<bool> { pub fn verify_signature(&self, verifying_key: &ed25519_dalek::VerifyingKey) -> Result<bool> {
let Some(sig_bytes) = &self.sig else { let Some(sig_bytes) = &self.sig else {
return Ok(false); return Ok(false);
@ -290,14 +266,13 @@ impl TypedEnvelope {
let signature = let signature =
ed25519_dalek::Signature::from_slice(sig_bytes).context("Invalid signature bytes")?; ed25519_dalek::Signature::from_slice(sig_bytes).context("Invalid signature bytes")?;
if verifying_key let mut sign_data = Vec::with_capacity(1 + self.v.len() + 4);
.verify_strict(&self.signing_preimage_v2(), &signature) sign_data.push(self.t);
.is_ok() sign_data.extend_from_slice(&self.v);
{ sign_data.extend_from_slice(&self.ts.to_le_bytes());
return Ok(true);
}
verifying_key verifying_key
.verify_strict(&self.signing_preimage_v1(), &signature) .verify_strict(&sign_data, &signature)
.context("Signature verification failed")?; .context("Signature verification failed")?;
Ok(true) Ok(true)
} }
@ -309,25 +284,12 @@ impl TypedEnvelope {
/// Set the outbound sequence number. Called by the send path after the /// Set the outbound sequence number. Called by the send path after the
/// target's counter has been incremented. Safe to call AFTER `new_signed` /// target's counter has been incremented. Safe to call AFTER `new_signed`
/// because the v1 signature covers `(t, v, ts)` — not `seq`. Once the /// because the signature covers `(t, v, ts)` — not `seq`.
/// fleet is on a build whose `verify_signature` accepts the v2 preimage,
/// flip senders to sign AFTER seq allocation via `signed_with_seq`.
pub fn with_seq(mut self, seq: u64) -> Self { pub fn with_seq(mut self, seq: u64) -> Self {
self.seq = seq; self.seq = seq;
self self
} }
/// v2 sender path (NOT yet wired — see `verify_signature` for the fleet
/// migration order): set seq first, then sign binding it.
#[allow(dead_code)]
pub fn signed_with_seq(mut self, seq: u64, signing_key: &ed25519_dalek::SigningKey) -> Self {
use ed25519_dalek::Signer;
self.seq = seq;
let signature = signing_key.sign(&self.signing_preimage_v2());
self.sig = Some(signature.to_bytes().to_vec());
self
}
/// Encode to wire format: [0x02] [CBOR envelope]. /// Encode to wire format: [0x02] [CBOR envelope].
pub fn to_wire(&self) -> Result<Vec<u8>> { pub fn to_wire(&self) -> Result<Vec<u8>> {
let mut buf = Vec::new(); let mut buf = Vec::new();
@ -854,37 +816,6 @@ mod tests {
assert!(envelope.verify_signature(&key.verifying_key()).is_err()); assert!(envelope.verify_signature(&key.verifying_key()).is_err());
} }
#[test]
fn test_v2_seq_bound_signature() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let key = SigningKey::generate(&mut OsRng);
let envelope = TypedEnvelope::new(MeshMessageType::Alert, b"test".to_vec())
.signed_with_seq(42, &key);
assert!(envelope.verify_signature(&key.verifying_key()).unwrap());
// v2 binds seq: replaying the signed envelope under a different
// sequence number must fail verification.
let mut replayed = envelope.clone();
replayed.seq = 43;
assert!(replayed.verify_signature(&key.verifying_key()).is_err());
}
#[test]
fn test_v1_signature_survives_seq_set_after_signing() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
// Mixed-fleet compatibility: current senders sign first (v1
// preimage, no seq) and allocate seq afterwards; verify must still
// accept that.
let key = SigningKey::generate(&mut OsRng);
let envelope = TypedEnvelope::new_signed(MeshMessageType::Alert, b"test".to_vec(), &key)
.with_seq(7);
assert!(envelope.verify_signature(&key.verifying_key()).unwrap());
}
#[test] #[test]
fn test_invoice_payload_roundtrip() { fn test_invoice_payload_roundtrip() {
let invoice = InvoicePayload { let invoice = InvoicePayload {

View File

@ -392,13 +392,6 @@ pub struct MeshConfig {
/// strict-probe auto-detect. /// strict-probe auto-detect.
#[serde(default)] #[serde(default)]
pub device_kind: Option<types::DeviceType>, pub device_kind: Option<types::DeviceType>,
/// Optional plain-TCP Reticulum interface — radio-less dev/verification
/// alternative to the serial-RNode path (see `types::ReticulumTcpConfig`
/// doc comment). Not exposed via `mesh.configure`/frontend; hand-edit
/// this file to use it. `None` (default) preserves today's
/// serial-only/auto-detect behavior unchanged.
#[serde(default)]
pub reticulum_tcp: Option<types::ReticulumTcpConfig>,
} }
fn default_assistant_backend() -> String { fn default_assistant_backend() -> String {
@ -429,7 +422,6 @@ impl Default for MeshConfig {
assistant_backend: default_assistant_backend(), assistant_backend: default_assistant_backend(),
assistant_allowed_contacts: Vec::new(), assistant_allowed_contacts: Vec::new(),
device_kind: None, device_kind: None,
reticulum_tcp: None,
} }
} }
} }
@ -712,7 +704,6 @@ impl MeshService {
self.config.lora_region.clone(), self.config.lora_region.clone(),
self.config.channel_name.clone(), self.config.channel_name.clone(),
self.config.device_kind, self.config.device_kind,
self.config.reticulum_tcp.clone(),
shutdown_rx, shutdown_rx,
cmd_rx, cmd_rx,
); );
@ -829,9 +820,7 @@ impl MeshService {
timestamp: header.timestamp, timestamp: header.timestamp,
announced_by: bha_did.clone(), announced_by: bha_did.clone(),
}; };
if let Err(e) = bha_cache.store_header(payload).await { let _ = bha_cache.store_header(payload).await;
warn!("Failed to persist block-header cache: {e:#}");
}
// Build signed announcement and broadcast // Build signed announcement and broadcast
match bitcoin_relay::build_block_header_announcement( match bitcoin_relay::build_block_header_announcement(
@ -2232,133 +2221,4 @@ mod tests {
assert!(loaded.enabled); assert!(loaded.enabled);
assert_eq!(loaded.device_path, Some("/dev/ttyUSB0".to_string())); assert_eq!(loaded.device_path, Some("/dev/ttyUSB0".to_string()));
} }
/// End-to-end: `MeshService::start()` spawns a real `reticulum-daemon` in
/// plain-TCP CLIENT mode (no serial RNode, no `probe_rnode`), dials a
/// second stand-alone daemon instance running in TCP SERVER mode (the
/// Aurora-side role — Aurora's `RnsTcpInterface` dials the same way), and
/// reaches `device_connected: true` via the exact `mesh.status`-backing
/// `MeshService::status()` call the RPC layer uses. This is the Rust-side
/// half of the archy<->Aurora TCP interop gate (see
/// docs/RETICULUM-TRANSPORT-PROGRESS.md); the LXMF wire-level proof
/// itself already passed via a scripted RNS/LXMF stand-in (Steps 1-2 of
/// that gate), so this test only needs to prove Rust's spawn/connect
/// path, not re-prove LXMF content delivery.
///
/// Requires `reticulum-daemon/.venv` (`python3 -m venv .venv &&
/// .venv/bin/pip install -r requirements.txt`) — skips (not fails) if
/// absent, since CI doesn't provision a Python/RNS/LXMF environment for
/// the rest of the mesh test suite either.
#[tokio::test]
#[ignore = "spawns real reticulum-daemon subprocesses over loopback TCP; run manually with `cargo test -p archipelago -- --ignored mesh_service_connects_over_reticulum_tcp`"]
async fn mesh_service_connects_over_reticulum_tcp_client() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("core/archipelago has two ancestors up to the repo root")
.to_path_buf();
let venv_py = repo_root.join("reticulum-daemon/.venv/bin/python");
let daemon_script = repo_root.join("reticulum-daemon/reticulum_daemon.py");
if !venv_py.exists() {
eprintln!(
"SKIP mesh_service_connects_over_reticulum_tcp_client: {} not found — \
run `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt` \
in reticulum-daemon/",
venv_py.display()
);
return;
}
let root = tempfile::tempdir().unwrap();
// Stand-in "Aurora-side" server daemon: a second, independent
// reticulum-daemon instance in --tcp-listen mode. Any free loopback
// port works; this test doesn't need it fixed, so ask the OS for one.
let bind_addr = {
let sock = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = sock.local_addr().unwrap();
drop(sock); // release it so the daemon can bind — small TOCTOU window, acceptable for a manual/ignored test
addr
};
let server_dir = root.path().join("standin-server");
std::fs::create_dir_all(&server_dir).unwrap();
let server_seed = SigningKey::generate(&mut OsRng);
std::fs::write(server_dir.join("seed.bin"), server_seed.to_bytes()).unwrap();
let mut server = tokio::process::Command::new(&venv_py)
.arg(&daemon_script)
.arg("--identity-key")
.arg(server_dir.join("seed.bin"))
.arg("--socket")
.arg(server_dir.join("server.sock"))
.arg("--rns-config")
.arg(server_dir.join("rns-config"))
.arg("--tcp-listen")
.arg(bind_addr.to_string())
.arg("--display-name")
.arg("standin-server")
.kill_on_drop(true)
.spawn()
.expect("failed to spawn stand-in reticulum-daemon (server)");
// Give the server daemon time to bind before the client dials it.
tokio::time::sleep(Duration::from_secs(2)).await;
// Point ReticulumLink::daemon_command's dev-fallback at our venv —
// these env vars are read fresh on every spawn (mesh/reticulum.rs),
// so setting them here (test-process-global, but this test owns the
// only mesh session in this process) is sufficient.
std::env::set_var("ARCHY_RETICULUM_DAEMON_PY", &venv_py);
std::env::set_var("ARCHY_RETICULUM_DAEMON_SCRIPT", &daemon_script);
// Force the dev fallback even if a packaged binary happens to exist
// on this machine's PATH convention — this test wants exactly the
// freshly-built venv daemon under test.
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", "/nonexistent-force-dev-fallback");
let data_dir = root.path().join("node");
std::fs::create_dir_all(data_dir.join("identity")).unwrap();
let node_signing_key = SigningKey::generate(&mut OsRng);
std::fs::write(
data_dir.join("identity").join("node_key"),
node_signing_key.to_bytes(),
)
.unwrap();
let ed_pubkey_hex = hex::encode(node_signing_key.verifying_key().to_bytes());
let did = crate::identity::did_key_from_pubkey_hex(&ed_pubkey_hex).unwrap();
let config = MeshConfig {
enabled: true,
reticulum_tcp: Some(types::ReticulumTcpConfig::Client {
connect: vec![bind_addr.to_string()],
}),
..Default::default()
};
save_config(&data_dir, &config).await.unwrap();
let mut service = MeshService::new(&data_dir, &node_signing_key, &did, &ed_pubkey_hex)
.await
.expect("MeshService::new failed");
service.start().expect("MeshService::start failed");
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
let mut last_status = service.status().await;
while tokio::time::Instant::now() < deadline {
last_status = service.status().await;
if last_status.device_connected {
break;
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
let _ = server.start_kill();
assert!(
last_status.device_connected,
"MeshService never reached device_connected: true over Reticulum TCP client \
mode within 20s (last status: {last_status:?})"
);
assert_eq!(last_status.device_type, DeviceType::Reticulum);
}
} }

View File

@ -406,15 +406,6 @@ pub struct ParsedContact {
/// contact has shared one. /// contact has shared one.
pub lat: Option<f64>, pub lat: Option<f64>,
pub lon: Option<f64>, pub lon: Option<f64>,
/// Archipelago ed25519 identity hex, when this transport carried it
/// in-band with the contact announce itself (Reticulum only today — the
/// RNS announce's app_data can embed an `ARCHY:n:` identity blob
/// alongside the destination hash in the same event, so there's no
/// ambiguity about which physical peer it belongs to). Meshcore/
/// Meshtastic identity adverts go out on a separate channel and are
/// correlated after the fact by `bind_federation_twins`'s advert_name
/// matching instead, so they always leave this `None`.
pub arch_pubkey_hex: Option<String>,
} }
/// Parse RESP_CONTACT (0x03) response. /// Parse RESP_CONTACT (0x03) response.
@ -466,7 +457,6 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
snr: None, snr: None,
lat: None, lat: None,
lon: None, lon: None,
arch_pubkey_hex: None,
}) })
} }

View File

@ -47,14 +47,7 @@ const KISS_CMD_PLATFORM: u8 = 0x48;
const KISS_CMD_MCU: u8 = 0x49; const KISS_CMD_MCU: u8 = 0x49;
const PROBE_BAUD: u32 = 115200; const PROBE_BAUD: u32 = 115200;
// 800ms was too tight for real hardware: confirmed on a genuine Heltec V4 const PROBE_READ_TIMEOUT: Duration = Duration::from_millis(800);
// RNode (firmware 1.86, verified via `rnodeconf --info`) that prints extra
// boot/status chatter on the same serial line before answering KISS
// commands — DETECT_RESP measured arriving ~1.05s after the probe write in
// that case. 2.5s leaves comfortable margin without meaningfully slowing
// down detection of non-RNode devices (Meshcore/Meshtastic already budget
// ~5s each).
const PROBE_READ_TIMEOUT: Duration = Duration::from_millis(2500);
/// Prefix marking an LXMF `content` string as base64 of a raw binary /// Prefix marking an LXMF `content` string as base64 of a raw binary
/// typed-envelope payload rather than literal text. LXMF `content` travels /// typed-envelope payload rather than literal text. LXMF `content` travels
@ -71,44 +64,6 @@ const RETICULUM_BINARY_CONTENT_MARKER: &str = "\u{0}b64:";
/// packaging); during development it can point at the venv's interpreter /// packaging); during development it can point at the venv's interpreter
/// invoking `reticulum_daemon.py` directly. Overridable for testing/packaging. /// invoking `reticulum_daemon.py` directly. Overridable for testing/packaging.
/// ///
/// Which Reticulum interface the daemon should bring up. `Serial` is the
/// original (and only, until now) path — a physical RNode over a serial
/// port, gated behind `probe_rnode`'s KISS-detect handshake in `open()`.
/// `TcpServer`/`TcpClient` are the radio-less, dev/verification-only
/// addition (see `types::ReticulumTcpConfig`'s doc comment): they let the
/// daemon speak plain Reticulum TCP — the same transport Aurora's
/// `RnsTcpInterface`/`RnsTcpServerInterface` use by default — without any
/// physical hardware. `TcpServer` is hard-gated to loopback by
/// `open_tcp_server`; `TcpClient` (outbound dial) is unrestricted, the same
/// risk class as Aurora's own hub uplinks.
pub enum ReticulumInterface<'a> {
Serial(&'a str),
TcpServer(&'a str),
TcpClient(&'a [String]),
}
impl ReticulumInterface<'_> {
/// Human-readable label used for both the `device_path` status field and
/// (sanitized) the per-instance RPC socket filename. For `Serial` this is
/// exactly the old bare path — zero behavior change for the existing
/// hardware flow.
fn label(&self) -> String {
match self {
Self::Serial(path) => path.to_string(),
Self::TcpServer(bind) => format!("tcp-server:{bind}"),
Self::TcpClient(targets) => format!("tcp-client:{}", targets.join(",")),
}
}
}
/// Bind-scope guard for TCP server mode — see `ReticulumInterface` doc
/// comment. Mirrors `reticulum_daemon.py`'s `_require_loopback`; enforced
/// here too (defense in depth) since this is the gate an operator/caller
/// actually goes through in Rust.
fn is_loopback_host(host: &str) -> bool {
matches!(host, "127.0.0.1" | "::1" | "localhost")
}
/// `archy_ed_pubkey_hex`/`archy_x25519_pubkey_hex` (when known) are embedded /// `archy_ed_pubkey_hex`/`archy_x25519_pubkey_hex` (when known) are embedded
/// by the daemon in its announce app_data as `ARCHY:2:{ed}:{x25519}` — the /// by the daemon in its announce app_data as `ARCHY:2:{ed}:{x25519}` — the
/// SAME wire format meshcore/Meshtastic identity adverts use — so a /// SAME wire format meshcore/Meshtastic identity adverts use — so a
@ -117,7 +72,7 @@ fn is_loopback_host(host: &str) -> bool {
/// satisfying cross-protocol DM convergence with zero new Rust dispatch code. /// satisfying cross-protocol DM convergence with zero new Rust dispatch code.
fn daemon_command( fn daemon_command(
socket_path: &Path, socket_path: &Path,
iface: &ReticulumInterface<'_>, serial_port: &str,
identity_key: &Path, identity_key: &Path,
archy_ed_pubkey_hex: Option<&str>, archy_ed_pubkey_hex: Option<&str>,
archy_x25519_pubkey_hex: Option<&str>, archy_x25519_pubkey_hex: Option<&str>,
@ -139,72 +94,35 @@ fn daemon_command(
cmd.arg("--identity-key") cmd.arg("--identity-key")
.arg(identity_key) .arg(identity_key)
.arg("--socket") .arg("--socket")
.arg(socket_path); .arg(socket_path)
match iface { .arg("--serial-port")
ReticulumInterface::Serial(path) => { .arg(serial_port);
cmd.arg("--serial-port").arg(path);
}
ReticulumInterface::TcpServer(bind) => {
cmd.arg("--tcp-listen").arg(bind);
}
ReticulumInterface::TcpClient(targets) => {
for target in *targets {
cmd.arg("--tcp-connect").arg(target);
}
}
}
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) { if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
cmd.arg("--archy-ed-pubkey-hex") cmd.arg("--archy-ed-pubkey-hex")
.arg(ed) .arg(ed)
.arg("--archy-x25519-pubkey-hex") .arg("--archy-x25519-pubkey-hex")
.arg(x); .arg(x);
} }
cmd.kill_on_drop(true)
// Run the daemon as its own process-group leader. The packaged binary is // Run the daemon as its own process-group leader. The packaged binary is
// a PyInstaller one-file bootloader that forks the real Python process; // a PyInstaller one-file bootloader that forks the real Python process;
// making it a group leader lets shutdown signal the WHOLE group so the // making it a group leader lets Drop signal the WHOLE group so the
// forked child can't be orphaned and keep holding the serial port. // forked child can't be orphaned and keep holding the serial port.
// Deliberately NOT `kill_on_drop`: that SIGKILLs the bootloader the instant .process_group(0)
// the `Child` drops, before it can delete its `_MEI*` extraction dir (48M
// leaked into TMPDIR per daemon restart — filled .116's 12G /tmp tmpfs).
// Shutdown goes through `terminate_group` instead, on every path.
cmd.process_group(0)
.stdin(std::process::Stdio::null()) .stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped()); .stderr(std::process::Stdio::piped());
cmd cmd
} }
/// Gracefully stop a daemon process group: SIGTERM now (the daemon's handler
/// releases the RNode + socket, and the PyInstaller bootloader gets to delete
/// its `_MEI*` extraction dir once the Python child exits), SIGKILL from a
/// detached backstop thread a few seconds later so a wedged daemon still can't
/// survive or keep holding the serial port.
fn terminate_group(child: &Child) {
if let Some(pid) = child.id() {
let pgid = -(pid as i32);
unsafe {
libc::kill(pgid, libc::SIGTERM);
}
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(5));
unsafe {
libc::kill(pgid, libc::SIGKILL);
}
});
}
}
/// One peer learned via an RNS announce (LXMF delivery destination). /// One peer learned via an RNS announce (LXMF delivery destination).
#[derive(Clone)] #[derive(Clone)]
struct ReticulumPeer { struct ReticulumPeer {
dest_hash: [u8; 16], dest_hash: [u8; 16],
display_name: String, display_name: String,
/// Archy ed25519 identity hex, once carried in this peer's own announce /// Archy ed25519 identity hex, once carried in a verified announce
/// app-data blob (`ARCHY:n:...`) — see `handle_event`'s "announce" arm. /// app-data blob. Not yet wired (TODO, lands with the signed-announce
/// Unlike Meshcore/Meshtastic, this arrives in-band with the same event /// work) — present so `get_contacts` has a stable shape to extend into.
/// as the destination hash, so it can be bound directly onto this
/// RNS-hash-keyed peer with no name-matching ambiguity (contrast
/// `bind_federation_twins`, which those two transports rely on instead).
arch_pubkey_hex: Option<String>, arch_pubkey_hex: Option<String>,
reachable: bool, reachable: bool,
} }
@ -271,62 +189,11 @@ impl ReticulumLink {
our_x25519_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>,
) -> Result<Self> { ) -> Result<Self> {
probe_rnode(path).await.context("RNode KISS detect failed")?; probe_rnode(path).await.context("RNode KISS detect failed")?;
Self::spawn( Self::spawn(path, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await
ReticulumInterface::Serial(path),
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
)
.await
}
/// Bring up a daemon in plain-TCP server mode — no physical RNode, no
/// `probe_rnode` gate. `bind` (`host:port`) must be loopback; see
/// `ReticulumInterface`/`is_loopback_host`.
pub async fn open_tcp_server(
bind: &str,
data_dir: &Path,
our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>,
) -> Result<Self> {
let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind);
anyhow::ensure!(
is_loopback_host(host),
"reticulum TCP server bind must be loopback-only (127.0.0.1/::1/localhost) — \
got {bind}; WAN/LAN exposure needs its own security review"
);
Self::spawn(
ReticulumInterface::TcpServer(bind),
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
)
.await
}
/// Bring up a daemon in plain-TCP client mode, dialing one or more
/// `host:port` targets — no physical RNode, no `probe_rnode` gate.
pub async fn open_tcp_client(
targets: &[String],
data_dir: &Path,
our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>,
) -> Result<Self> {
anyhow::ensure!(
!targets.is_empty(),
"reticulum TCP client mode needs at least one target"
);
Self::spawn(
ReticulumInterface::TcpClient(targets),
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
)
.await
} }
async fn spawn( async fn spawn(
iface: ReticulumInterface<'_>, path: &str,
data_dir: &Path, data_dir: &Path,
our_ed_pubkey_hex: Option<&str>, our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>,
@ -345,23 +212,13 @@ impl ReticulumLink {
let _ = tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700)) let _ = tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
.await; .await;
} }
let label = iface.label(); let socket_path = runtime_dir.join(format!(
let iface_key = label.replace(['/', ' ', ':', ','], "_"); "{}.sock",
let socket_path = runtime_dir.join(format!("{iface_key}.sock")); path.replace(['/', ' '], "_")
));
if socket_path.exists() { if socket_path.exists() {
let _ = std::fs::remove_file(&socket_path); let _ = std::fs::remove_file(&socket_path);
} }
// Private TMPDIR for the PyInstaller one-file bootloader, wiped on every
// (re)spawn: even a hard-killed or power-lost daemon can never accumulate
// stale 48M `_MEI*` extraction dirs, and they land under the data dir
// instead of the small tmpfs /tmp. Per-interface so the live daemons of
// two radios never share a dir (the previous daemon for the SAME
// interface is guaranteed dead before we respawn it).
let tmp_dir = runtime_dir.join("tmp").join(&iface_key);
let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
tokio::fs::create_dir_all(&tmp_dir)
.await
.context("Failed to create reticulum daemon tmp dir")?;
let identity_key = data_dir.join("identity").join("node_key"); let identity_key = data_dir.join("identity").join("node_key");
if !identity_key.exists() { if !identity_key.exists() {
anyhow::bail!( anyhow::bail!(
@ -372,23 +229,20 @@ impl ReticulumLink {
let mut cmd = daemon_command( let mut cmd = daemon_command(
&socket_path, &socket_path,
&iface, path,
&identity_key, &identity_key,
our_ed_pubkey_hex, our_ed_pubkey_hex,
our_x25519_pubkey_hex, our_x25519_pubkey_hex,
); );
cmd.env("TMPDIR", &tmp_dir); let mut child = cmd
let child = cmd
.spawn() .spawn()
.context("Failed to spawn reticulum-daemon — is it installed/packaged?")?; .context("Failed to spawn reticulum-daemon — is it installed/packaged?")?;
// Wait for the socket to appear, then for the daemon's "ready" event. // Wait for the socket to appear, then for the daemon's "ready" event.
// Runs as a block so every failure path tears the just-spawned daemon
// group down via `terminate_group` (the child has no `kill_on_drop`).
let init = async {
let deadline = tokio::time::Instant::now() + Duration::from_secs(15); let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
let stream = loop { let stream = loop {
if tokio::time::Instant::now() > deadline { if tokio::time::Instant::now() > deadline {
let _ = child.start_kill();
anyhow::bail!("reticulum-daemon did not create its RPC socket in time"); anyhow::bail!("reticulum-daemon did not create its RPC socket in time");
} }
match UnixStream::connect(&socket_path).await { match UnixStream::connect(&socket_path).await {
@ -420,22 +274,13 @@ impl ReticulumLink {
.map(str::to_string); .map(str::to_string);
info!( info!(
iface = %label, path = %path,
dest_hash = %dest_hash_hex, dest_hash = %dest_hash_hex,
"Reticulum daemon ready" "Reticulum daemon ready"
); );
Ok((write_half, reader, dest_hash, display_name))
};
let (write_half, reader, dest_hash, display_name) = match init.await {
Ok(parts) => parts,
Err(e) => {
terminate_group(&child);
return Err(e);
}
};
let mut link = Self { let mut link = Self {
device_path: label, device_path: path.to_string(),
socket_path, socket_path,
child, child,
writer: write_half, writer: write_half,
@ -699,7 +544,6 @@ impl ReticulumLink {
snr: None, snr: None,
lat: None, lat: None,
lon: None, lon: None,
arch_pubkey_hex: p.arch_pubkey_hex.clone(),
}) })
.collect()) .collect())
} }
@ -778,21 +622,17 @@ impl ReticulumLink {
.filter(|s| !s.is_empty()); .filter(|s| !s.is_empty());
// If the announce app_data is an ARCHY:n: identity blob (see // If the announce app_data is an ARCHY:n: identity blob (see
// daemon_command's doc comment), bind the ed25519 hex directly // daemon_command's doc comment), bind it onto this peer AND
// onto this RNS-hash-keyed peer below (unambiguous — it came // surface it through the SAME channel-text path
// in the same event as `hash`) AND surface it through the same // meshcore/Meshtastic identity adverts use
// channel-text path meshcore/Meshtastic identity adverts use
// (frames::handle_channel_payload -> parse_identity_broadcast // (frames::handle_channel_payload -> parse_identity_broadcast
// -> handle_identity_received), so it also lands on that // -> handle_identity_received -> bind_federation_twins), so a
// transport's federation-twin peer for UI/contact purposes. // Reticulum-carried identity merges into the same conversation
// `group_peer_twins` can then collapse the two rows since both // as that node's other-transport twins — zero new bind logic.
// now carry the same `arch_pubkey_hex`, instead of relying on let is_identity_blob = app_data_text
// `bind_federation_twins`'s advert_name matching, which never
// matches here — see `display_name` below.
let parsed_identity = app_data_text
.as_deref() .as_deref()
.and_then(protocol::parse_identity_broadcast); .map(|t| protocol::parse_identity_broadcast(t).is_some())
let is_identity_blob = parsed_identity.is_some(); .unwrap_or(false);
if is_identity_blob { if is_identity_blob {
let text = app_data_text.clone().unwrap(); let text = app_data_text.clone().unwrap();
let mut data = Vec::with_capacity(7 + text.len()); let mut data = Vec::with_capacity(7 + text.len());
@ -805,7 +645,6 @@ impl ReticulumLink {
bytes_consumed: 0, bytes_consumed: 0,
}); });
} }
let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
let display_name = app_data_text let display_name = app_data_text
.filter(|_| !is_identity_blob) .filter(|_| !is_identity_blob)
@ -815,14 +654,11 @@ impl ReticulumLink {
.and_modify(|p| { .and_modify(|p| {
p.display_name = display_name.clone(); p.display_name = display_name.clone();
p.reachable = true; p.reachable = true;
if arch_pubkey_hex.is_some() {
p.arch_pubkey_hex = arch_pubkey_hex.clone();
}
}) })
.or_insert(ReticulumPeer { .or_insert(ReticulumPeer {
dest_hash: hash, dest_hash: hash,
display_name, display_name,
arch_pubkey_hex, arch_pubkey_hex: None,
reachable: true, reachable: true,
}); });
self.persist_peers(); self.persist_peers();
@ -1068,14 +904,6 @@ fn parse_hash16(hex_str: &str) -> Result<[u8; 16]> {
async fn probe_rnode(path: &str) -> Result<()> { async fn probe_rnode(path: &str) -> Result<()> {
let port = serial2_tokio::SerialPort::open(path, PROBE_BAUD) let port = serial2_tokio::SerialPort::open(path, PROBE_BAUD)
.with_context(|| format!("Failed to open {} for Reticulum probe", path))?; .with_context(|| format!("Failed to open {} for Reticulum probe", path))?;
// ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART
// bridge chip) treat a DTR/RTS transition on open as a reset signal, the
// same mechanism esptool uses to force bootloader entry. Deassert both
// and let the board settle before writing the probe, or the reboot eats
// the DETECT_RESP window below.
let _ = port.set_dtr(false);
let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await;
let probe: [u8; 13] = [ let probe: [u8; 13] = [
KISS_FEND, KISS_FEND,
KISS_CMD_DETECT, KISS_CMD_DETECT,
@ -1122,12 +950,22 @@ fn contains_detect_resp(buf: &[u8]) -> bool {
impl Drop for ReticulumLink { impl Drop for ReticulumLink {
fn drop(&mut self) { fn drop(&mut self) {
// Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`). // The packaged daemon is a PyInstaller one-file bootloader that forks the
// The old immediate SIGTERM+SIGKILL never let the PyInstaller bootloader // real Python process into the same (leader) group we spawned it in.
// run its exit cleanup, stranding a 48M `_MEI*` extraction dir on every // SIGKILLing only the bootloader (what `start_kill`/`kill_on_drop` do)
// reconnect/restart until /tmp filled; the grace period fixes that, and // orphans that child, which keeps holding the serial port — the root
// the per-spawn TMPDIR wipe in `spawn` covers any dir that still leaks. // cause of daemons piling up across reconnects and jamming the RNode.
terminate_group(&self.child); // Signal the whole process group instead: SIGTERM is caught by the
// daemon's handler (clean RNode + socket release), and SIGKILL is the
// hard backstop so a wedged daemon can never survive.
if let Some(pid) = self.child.id() {
let pgid = -(pid as i32);
unsafe {
libc::kill(pgid, libc::SIGTERM);
libc::kill(pgid, libc::SIGKILL);
}
}
let _ = self.child.start_kill();
let _ = std::fs::remove_file(&self.socket_path); let _ = std::fs::remove_file(&self.socket_path);
} }
} }

View File

@ -176,9 +176,7 @@ async fn fire_due(scheduler: &Arc<MeshScheduler>, state: &Arc<MeshState>) {
} }
q.retain(|m| !to_remove.contains(&m.id)); q.retain(|m| !to_remove.contains(&m.id));
} }
if let Err(e) = scheduler.save().await { let _ = scheduler.save().await;
warn!("Failed to persist mesh outbox after sweep: {e:#}");
}
} }
/// Hand a due message to the radio. Returns true if it was sent (or should be /// Hand a due message to the radio. Returns true if it was sent (or should be

View File

@ -57,12 +57,6 @@ impl MeshcoreDevice {
"Failed to open serial port {} (permission denied? device busy?)", "Failed to open serial port {} (permission denied? device busy?)",
path path
))?; ))?;
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
// boards reset on a DTR/RTS transition, so deassert both and settle
// before the handshake below.
let _ = port.set_dtr(false);
let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await;
info!(path = %path, baud = BAUD_RATE, "Opened serial port"); info!(path = %path, baud = BAUD_RATE, "Opened serial port");

View File

@ -28,23 +28,6 @@ impl std::fmt::Display for DeviceType {
} }
} }
/// Optional plain-TCP Reticulum interface, radio-less alternative to the
/// serial-RNode path. Dev/verification surface for now (e.g. proving
/// interop with Aurora's `RnsTcpInterface`/`RnsTcpServerInterface`, which is
/// its default connectivity mode) — not exposed through `mesh.configure`/the
/// frontend. `Server` is hard-gated to loopback in both the daemon and Rust
/// (`reticulum::is_loopback_host`); WAN/LAN exposure is a separate, deliberate
/// future decision (archy is otherwise Tor-first for inter-node traffic).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum ReticulumTcpConfig {
/// Bind a `TCPServerInterface`. `bind` is `host:port`, host must be
/// loopback (127.0.0.1/::1/localhost).
Server { bind: String },
/// Dial one or more `TCPClientInterface` targets (`host:port`).
Client { connect: Vec<String> },
}
/// The per-message transport pill label for a radio-delivered message: the /// The per-message transport pill label for a radio-delivered message: the
/// active device's own name, since one session owns exactly one device. /// active device's own name, since one session owns exactly one device.
/// Federation sends/receives are labelled "fips"/"tor" elsewhere — this only /// Federation sends/receives are labelled "fips"/"tor" elsewhere — this only

View File

@ -267,10 +267,7 @@ impl Server {
info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices); info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices);
mesh_config.enabled = true; mesh_config.enabled = true;
mesh_config.device_path = Some(devices[0].clone()); mesh_config.device_path = Some(devices[0].clone());
if let Err(e) = crate::mesh::save_config(&data_dir, &mesh_config).await let _ = crate::mesh::save_config(&data_dir, &mesh_config).await;
{
warn!("Failed to persist auto-detected mesh config: {e:#}");
}
} }
} }
@ -537,28 +534,6 @@ impl Server {
}); });
} }
// Periodic TollGate ecash sweep. tollgate-wrt keeps its own separate
// Cashu wallet on the router — customer payments never land in this
// node's wallet on their own, and its Lightning auto-payout config is
// independent and easy to leave misconfigured. This drains whatever
// TollGate has collected straight into the local wallet on a timer,
// sidestepping Lightning payout configuration entirely.
{
let data_dir = config.data_dir.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(30)).await;
let mut interval = tokio::time::interval(Duration::from_secs(300));
loop {
interval.tick().await;
match crate::tollgate_sweep::sweep_once(&data_dir).await {
Ok(0) => {}
Ok(swept) => info!(sats = swept, "tollgate wallet sweep complete"),
Err(e) => debug!(error = %e, "tollgate wallet sweep (non-fatal)"),
}
}
});
}
// Initialize container scanner — discovers installed apps from Podman/Docker // Initialize container scanner — discovers installed apps from Podman/Docker
{ {
let scanner = create_docker_scanner(&config).await?; let scanner = create_docker_scanner(&config).await?;
@ -1056,13 +1031,9 @@ async fn accept_loop(
let permit = active_connections.clone().acquire_owned().await; let permit = active_connections.clone().acquire_owned().await;
tokio::spawn(async move { tokio::spawn(async move {
let _permit = permit; let _permit = permit;
let service = service_fn(move |mut req: hyper::Request<hyper::Body>| { let service = service_fn(move |req: hyper::Request<hyper::Body>| {
let handler = handler.clone(); let handler = handler.clone();
async move { async move {
// Record the TCP peer so rate limiting only trusts
// forwarded headers on loopback (nginx) connections.
req.extensions_mut()
.insert(crate::api::rpc::PeerAddr(peer_addr));
if peer_only && !is_peer_allowed_path(req.uri().path()) { if peer_only && !is_peer_allowed_path(req.uri().path()) {
let resp = hyper::Response::builder() let resp = hyper::Response::builder()
.status(hyper::StatusCode::NOT_FOUND) .status(hyper::StatusCode::NOT_FOUND)
@ -1206,30 +1177,6 @@ fn merge_preserving_transitional(
{ {
fresh.state.clone() fresh.state.clone()
} }
// A user-initiated stop whose container podman now reports settled
// (the scanner maps exited+user-stopped → Stopped) has visibly
// completed — report it. The stop worker still owns cleanup, but it
// can trail the actual container exit by minutes when it is queued
// behind the orchestrator's per-app lock (reconcile host-port repair
// holds it through multi-minute stability waits). Holding the card
// (and the lifecycle gate) in "Stopping" that whole time reports a
// completed stop as stuck (vaultwarden/jellyfin, gate runs C/D,
// .228 2026-07-09).
(crate::data_model::PackageState::Stopping, crate::data_model::PackageState::Stopped)
if user_stop_requested =>
{
fresh.state.clone()
}
// Same reasoning for start: once podman reports Running, the start
// has visibly succeeded. The start worker keeps Starting through its
// full readiness wait (host-port probe budgets reach 420s for
// uptime-kuma — longer than any UI/test patience) even though the
// container is up and its live health is already shown separately
// (uptime-kuma, gate run E). Restarting is deliberately NOT mapped:
// mid-restart Running readings are the pre-stop container.
(crate::data_model::PackageState::Starting, crate::data_model::PackageState::Running) => {
fresh.state.clone()
}
// Removing with a live running container is stale: uninstall either // Removing with a live running container is stale: uninstall either
// failed or Archipelago restarted before the spawned task could revert // failed or Archipelago restarted before the spawned task could revert
// state. Let the scanner recover the UI immediately instead of // state. Let the scanner recover the UI immediately instead of
@ -1256,21 +1203,6 @@ fn merge_preserving_transitional(
} }
} }
/// Package ids whose `Restarting` state was written by the scanner's
/// pending-boot-start overlay (not by an RPC restart task). For these, the
/// scan is the owner: once podman reports a settled state and the id is no
/// longer queued for a boot start, the fresh state wins immediately instead
/// of being preserved for the transitional-stuck timeout.
static SCANNER_RESTARTING: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
fn take_scanner_restarting(id: &str) -> bool {
SCANNER_RESTARTING
.lock()
.map(|mut set| set.remove(id))
.unwrap_or(false)
}
fn is_podman_scan_timeout(error: &anyhow::Error) -> bool { fn is_podman_scan_timeout(error: &anyhow::Error) -> bool {
let msg = format!("{:#}", error); let msg = format!("{:#}", error);
msg.contains("podman ps") && msg.contains("timed out") msg.contains("podman ps") && msg.contains("timed out")
@ -1291,31 +1223,6 @@ async fn scan_and_update_packages(
pkg.state = crate::data_model::PackageState::Stopped; pkg.state = crate::data_model::PackageState::Stopped;
pkg.exit_code = None; pkg.exit_code = None;
} }
// A down container that boot recovery / the reconciler is queued to
// start is "Restarting", not "Stopped" — after a reboot the sequential
// recovery pass can take minutes to reach heavyweights, and telling
// the user their app stopped when it's about to come back is wrong.
// Ids overlaid here are recorded in SCANNER_RESTARTING so the merge
// below knows this Restarting is scanner-authored (resolve it as soon
// as podman reports a settled state) and not owned by an RPC restart
// task (whose transitional state must be preserved).
// …but never for a user-stopped app: the stop marker is written
// before the stop runs, and a reconcile pass that queued the app
// moments earlier must not repaint the user's deliberate Stopped as
// Restarting (gate iteration-5 vaultwarden stop-wait, 2026-07-09 —
// the overlay held 'restarting' past the 120s window).
if matches!(
pkg.state,
crate::data_model::PackageState::Stopped | crate::data_model::PackageState::Exited
) && crate::crash_recovery::is_pending_boot_start(id)
&& !user_stopped.contains(id)
{
pkg.state = crate::data_model::PackageState::Restarting;
pkg.exit_code = None;
if let Ok(mut set) = SCANNER_RESTARTING.lock() {
set.insert(id.clone());
}
}
} }
normalize_reachable_package_health(&mut packages).await; normalize_reachable_package_health(&mut packages).await;
@ -1366,19 +1273,6 @@ async fn scan_and_update_packages(
absence_tracker.remove(id); absence_tracker.remove(id);
let existing = merged.get(id); let existing = merged.get(id);
let overwrite = match existing { let overwrite = match existing {
// Scanner-authored Restarting (the pending-boot-start overlay)
// resolves as soon as the fresh scan reports anything else: the
// scan is its owner — no RPC task will ever write a final state
// back. Without this, a successfully recovered container would
// sit wedged in "Restarting" until the 20-minute stuck timeout.
Some(existing_entry)
if existing_entry.state == crate::data_model::PackageState::Restarting
&& pkg.state != crate::data_model::PackageState::Restarting
&& take_scanner_restarting(id) =>
{
transitional_since.remove(id);
true
}
Some(existing_entry) if is_transitional(&existing_entry.state) => { Some(existing_entry) if is_transitional(&existing_entry.state) => {
let entered = *transitional_since.entry(id.clone()).or_insert(now); let entered = *transitional_since.entry(id.clone()).or_insert(now);
let timeout = transitional_stuck_timeout(&existing_entry.state); let timeout = transitional_stuck_timeout(&existing_entry.state);
@ -1760,49 +1654,6 @@ mod merge_tests {
assert_eq!(merged.health.as_deref(), Some("healthy")); assert_eq!(merged.health.as_deref(), Some("healthy"));
} }
#[test]
fn user_stop_resolves_when_container_has_exited() {
// The container exited and the scanner already normalized
// exited+user-stopped to Stopped — the stop visibly completed, even
// if the stop worker is still queued behind the per-app lock.
let existing = make_entry(PackageState::Stopping, Some("unknown"));
let fresh = make_entry(PackageState::Stopped, None);
let merged = merge_preserving_transitional(&existing, &fresh, true);
assert_eq!(merged.state, PackageState::Stopped);
}
#[test]
fn non_user_stopping_with_exited_container_stays_owned() {
// No user-stop marker → this Stopping belongs to some other flow;
// don't resolve it from a scan.
let existing = make_entry(PackageState::Stopping, Some("unknown"));
let fresh = make_entry(PackageState::Stopped, None);
let merged = merge_preserving_transitional(&existing, &fresh, false);
assert_eq!(merged.state, PackageState::Stopping);
}
#[test]
fn starting_resolves_when_container_is_running() {
// Start worker may still be inside its readiness wait (up to 420s for
// uptime-kuma) — but podman reporting Running means the start visibly
// succeeded; live health is merged separately.
let existing = make_entry(PackageState::Starting, Some("starting"));
let fresh = make_entry(PackageState::Running, Some("healthy"));
let merged = merge_preserving_transitional(&existing, &fresh, false);
assert_eq!(merged.state, PackageState::Running);
assert_eq!(merged.health.as_deref(), Some("healthy"));
}
#[test]
fn restarting_is_not_resolved_by_running_scan() {
// Mid-restart the pre-stop container still reads Running — the
// restart worker owns this state until it finishes.
let existing = make_entry(PackageState::Restarting, Some("healthy"));
let fresh = make_entry(PackageState::Running, Some("healthy"));
let merged = merge_preserving_transitional(&existing, &fresh, false);
assert_eq!(merged.state, PackageState::Restarting);
}
#[test] #[test]
fn merges_fresh_observability_fields() { fn merges_fresh_observability_fields() {
// Non-state observability fields (health, exit_code, installed) // Non-state observability fields (health, exit_code, installed)

View File

@ -1,81 +0,0 @@
use std::path::Path;
use anyhow::{Context, Result};
use archipelago_openwrt::router::Router;
use tracing::{info, warn};
use crate::network::router as net_router;
use crate::wallet::ecash;
/// Pull whatever TollGate has collected in its on-router Cashu wallet into
/// this node's own wallet.
///
/// `tollgate-wrt` keeps a completely separate Cashu wallet on the router
/// (`/etc/tollgate/wallet.db`) — customer payments land there, never in this
/// node's wallet directly. Its own Lightning auto-payout is configured
/// independently in `/etc/tollgate/identities.json`, which is easy to leave
/// pointed at the wrong (or a placeholder) address and easy to lose track of.
/// This sweep sidesteps that entirely: periodically drain the router's
/// TollGate wallet to Cashu tokens (`tollgate wallet drain cashu`, over SSH)
/// and receive them straight into the local wallet via the same path the
/// "Receive ecash" UI uses.
///
/// Returns the total sats swept in (0 if there was nothing to do, including
/// when no router is configured or it doesn't have TollGate installed).
pub async fn sweep_once(data_dir: &Path) -> Result<u64> {
let cfg = net_router::load_router_config(data_dir).await?;
if !cfg.configured {
return Ok(0);
}
let ssh_user = cfg.username.clone().unwrap_or_else(|| "root".to_string());
let ssh_password = cfg.password.clone().unwrap_or_default();
let router = Router::connect_password(&cfg.address, 22, &ssh_user, &ssh_password)
.context("connect to router for tollgate wallet sweep")?;
// `tollgate` CLI missing (no TollGate installed here) is a normal,
// expected case, not an error — just nothing to sweep.
let (balance_out, code) = router.run("tollgate wallet balance 2>/dev/null")?;
if code != 0 {
return Ok(0);
}
let balance_sats = balance_out
.lines()
.find_map(|l| l.trim().strip_prefix("balance_sats:"))
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(0);
if balance_sats == 0 {
return Ok(0);
}
let (drain_out, drain_code) = router.run("tollgate wallet drain cashu 2>&1")?;
if drain_code != 0 {
anyhow::bail!("tollgate wallet drain cashu failed: {}", drain_out.trim());
}
// The CLI has no machine-readable output mode; pull tokens out of its
// " Token: cashuB..." lines.
let mut received_total = 0u64;
for line in drain_out.lines() {
let Some(token) = line.trim().strip_prefix("Token:") else {
continue;
};
let token = token.trim();
if token.is_empty() {
continue;
}
match ecash::receive_token(data_dir, token).await {
Ok(amount) => {
received_total += amount;
info!(amount_sats = amount, "swept TollGate ecash into local wallet");
}
Err(e) => {
// The token is still in this log line if this happens — not
// silently lost, just needs manual `wallet.ecash-receive`.
warn!(error = %e, token, "failed to receive swept TollGate token");
}
}
}
Ok(received_total)
}

View File

@ -23,36 +23,26 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// TTL keeps the result responsive to daemon flaps without pounding DBus. /// TTL keeps the result responsive to daemon flaps without pounding DBus.
const AVAILABILITY_CACHE_TTL: Duration = Duration::from_secs(10); const AVAILABILITY_CACHE_TTL: Duration = Duration::from_secs(10);
/// Availability cache shared with the background probe thread, so the
/// sync `is_available()` hot path never blocks on `systemctl`.
struct AvailabilityCache {
available: AtomicBool,
probed_at_ms: AtomicU64,
probe_in_flight: AtomicBool,
}
pub struct FipsTransport { pub struct FipsTransport {
identity_dir: PathBuf, identity_dir: PathBuf,
availability: std::sync::Arc<AvailabilityCache>, available_cached: AtomicBool,
available_cached_at_ms: AtomicU64,
} }
impl FipsTransport { impl FipsTransport {
pub fn new(identity_dir: &Path) -> Self { pub fn new(identity_dir: &Path) -> Self {
Self { Self {
identity_dir: identity_dir.to_path_buf(), identity_dir: identity_dir.to_path_buf(),
availability: std::sync::Arc::new(AvailabilityCache { available_cached: AtomicBool::new(false),
available: AtomicBool::new(false), available_cached_at_ms: AtomicU64::new(0),
probed_at_ms: AtomicU64::new(0),
probe_in_flight: AtomicBool::new(false),
}),
} }
} }
fn probe_daemon_active() -> bool { fn probe_daemon_active() -> bool {
// Blocking probe — only ever run on a dedicated background thread // Cheap blocking probe: spawn `systemctl is-active` synchronously.
// (see is_available), never on a tokio worker. Short-circuit if // Short-circuit if either the archipelago-managed unit or the
// either the archipelago-managed unit or the upstream fips.service // upstream fips.service is active — legacy/dev nodes run only the
// is active — legacy/dev nodes run only the upstream unit. // upstream unit.
for unit in [ for unit in [
crate::fips::SERVICE_UNIT, crate::fips::SERVICE_UNIT,
crate::fips::UPSTREAM_SERVICE_UNIT, crate::fips::UPSTREAM_SERVICE_UNIT,
@ -80,30 +70,14 @@ impl NodeTransport for FipsTransport {
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64) .map(|d| d.as_millis() as u64)
.unwrap_or(0); .unwrap_or(0);
let cached_at = self.availability.probed_at_ms.load(Ordering::Relaxed); let cached_at = self.available_cached_at_ms.load(Ordering::Relaxed);
let cached = self.availability.available.load(Ordering::Relaxed);
if now_ms.saturating_sub(cached_at) < AVAILABILITY_CACHE_TTL.as_millis() as u64 { if now_ms.saturating_sub(cached_at) < AVAILABILITY_CACHE_TTL.as_millis() as u64 {
return cached; return self.available_cached.load(Ordering::Relaxed);
} }
// Cache is stale. This sync trait method is called from async
// route(), so running the ~50ms systemctl probe inline stalls a
// tokio worker. Serve the stale value and refresh on a background
// thread instead — the transport supervisor's warm loop keeps this
// fresh in steady state, so staleness is bounded to one probe round.
let cache = std::sync::Arc::clone(&self.availability);
if !cache.probe_in_flight.swap(true, Ordering::Relaxed) {
std::thread::spawn(move || {
let val = Self::probe_daemon_active(); let val = Self::probe_daemon_active();
let probed_ms = SystemTime::now() self.available_cached.store(val, Ordering::Relaxed);
.duration_since(UNIX_EPOCH) self.available_cached_at_ms.store(now_ms, Ordering::Relaxed);
.map(|d| d.as_millis() as u64) val
.unwrap_or(0);
cache.available.store(val, Ordering::Relaxed);
cache.probed_at_ms.store(probed_ms, Ordering::Relaxed);
cache.probe_in_flight.store(false, Ordering::Relaxed);
});
}
cached
} }
fn send<'a>( fn send<'a>(

View File

@ -16,13 +16,9 @@ use ed25519_dalek::VerifyingKey;
/// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars). /// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars).
/// ///
/// Pinned 2026-07-02 from the release-root signing ceremony /// TODO(dht Phase 0): bake the real value here after the signing ceremony.
/// (signer did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). The /// Generate it with: `scripts/release-root-ceremony.sh pubkey`.
/// corresponding mnemonic is held offline by the publisher — see pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> = None;
/// `docs/workstream-b-signing-runbook.md`. Regenerate/verify with:
/// `RELEASE_MASTER_MNEMONIC=… archipelago ceremony pubkey`.
pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> =
Some("5d15cbee8a108f7dd288c02d29a1d9d71f198acc99186aad8008b4f28d469951");
const ENV_OVERRIDE: &str = "ARCHY_RELEASE_ROOT_PUBKEY"; const ENV_OVERRIDE: &str = "ARCHY_RELEASE_ROOT_PUBKEY";
@ -55,14 +51,9 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn pinned_constant_parses_to_a_valid_key() { fn unset_constant_is_none() {
// The release-root anchor is pinned (ceremony 2026-07-02); it must be // Default build ships no pinned anchor yet.
// present and a well-formed 32-byte Ed25519 key. assert!(RELEASE_ROOT_PUBKEY_HEX.is_none());
let hex = RELEASE_ROOT_PUBKEY_HEX.expect("release-root anchor must be pinned");
assert!(
parse_pubkey_hex(hex).is_some(),
"pinned RELEASE_ROOT_PUBKEY_HEX is not a valid Ed25519 key"
);
} }
#[test] #[test]

View File

@ -149,18 +149,6 @@ mod tests {
doc doc
} }
/// Pin `test_key` as the release-root anchor for this process via the env
/// override. Needed because the baked-in `RELEASE_ROOT_PUBKEY_HEX` is the
/// real ceremony key, which no unit test can produce signatures for — so to
/// exercise the anchored-verification path we pin a key we can sign with.
/// Every call sets the same value, so parallel tests stay consistent.
fn pin_test_key_as_anchor() {
std::env::set_var(
"ARCHY_RELEASE_ROOT_PUBKEY",
hex::encode(test_key().verifying_key().to_bytes()),
);
}
#[test] #[test]
fn unsigned_document_reports_unsigned() { fn unsigned_document_reports_unsigned() {
let doc = json!({"schema": 1, "apps": {}}); let doc = json!({"schema": 1, "apps": {}});
@ -168,31 +156,18 @@ mod tests {
} }
#[test] #[test]
fn roundtrip_verifies_and_anchors_to_pinned_key() { fn roundtrip_verifies() {
// With the anchor pinned to the signer, verification succeeds AND
// reports anchored == true (signer identity confirmed).
pin_test_key_as_anchor();
let signed = sign_into(&test_key(), json!({"schema": 1, "n": 42})); let signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
match verify_detached(&signed).unwrap() { match verify_detached(&signed).unwrap() {
SignatureStatus::Verified { anchored, .. } => assert!(anchored), // No anchor pinned in the default test build → anchored == false.
SignatureStatus::Verified { anchored, .. } => assert!(!anchored),
other => panic!("expected Verified, got {:?}", other), other => panic!("expected Verified, got {:?}", other),
} }
} }
#[test]
fn signature_from_non_anchor_key_is_rejected() {
// A self-consistent signature from a key that is NOT the pinned anchor
// must hard-reject — this is what stops a mirror swapping in its own key.
pin_test_key_as_anchor();
let other_key = SigningKey::from_bytes(&[11u8; 32]);
let signed = sign_into(&other_key, json!({"schema": 1, "n": 42}));
assert!(verify_detached(&signed).is_err());
}
#[test] #[test]
fn signature_survives_key_reordering() { fn signature_survives_key_reordering() {
// Re-emitting the document with shuffled keys must not break the sig. // Re-emitting the document with shuffled keys must not break the sig.
pin_test_key_as_anchor();
let signed = sign_into(&test_key(), json!({"b": 2, "a": 1})); let signed = sign_into(&test_key(), json!({"b": 2, "a": 1}));
let reparsed: Value = let reparsed: Value =
serde_json::from_str(&serde_json::to_string(&signed).unwrap()).unwrap(); serde_json::from_str(&serde_json::to_string(&signed).unwrap()).unwrap();
@ -204,9 +179,6 @@ mod tests {
#[test] #[test]
fn tampered_payload_is_rejected() { fn tampered_payload_is_rejected() {
// Pin the signer so verification reaches the signature check (not an
// anchor-identity short-circuit), proving tamper detection itself.
pin_test_key_as_anchor();
let mut signed = sign_into(&test_key(), json!({"schema": 1, "n": 42})); let mut signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
signed signed
.as_object_mut() .as_object_mut()

View File

@ -160,9 +160,7 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
force_ovh_update_primary(&mut list); force_ovh_update_primary(&mut list);
changed = changed || before_order != list.iter().map(|m| m.url.clone()).collect::<Vec<_>>(); changed = changed || before_order != list.iter().map(|m| m.url.clone()).collect::<Vec<_>>();
if changed { if changed {
if let Err(e) = save_mirrors(data_dir, &list).await { let _ = save_mirrors(data_dir, &list).await;
warn!("Failed to persist migrated update-mirrors list: {e:#}");
}
} }
Ok(list) Ok(list)
} }
@ -340,26 +338,6 @@ fn rewrite_manifest_origins(manifest: &mut UpdateManifest, manifest_url: &str) {
} }
} }
/// Parse a fetched manifest body, verifying its detached release-root
/// signature if one is present. Returns the manifest plus whether it was
/// signed by the pinned release-root anchor.
///
/// * Present-but-invalid signature (tampered payload, wrong signer, bad
/// hex…) → `Err`; the caller treats the mirror as failed. This is the
/// teeth: a MITM can strip the signature but can never forge one.
/// * Unsigned → accepted with `signed == false` during the migration
/// window; the scheduler refuses to auto-apply such manifests and the
/// state surfaces `manifest_signed` to the UI.
fn parse_and_verify_manifest(raw: serde_json::Value) -> Result<(UpdateManifest, bool)> {
let signed = match crate::trust::verify_detached(&raw).context("manifest signature")? {
crate::trust::SignatureStatus::Verified { anchored, .. } => anchored,
crate::trust::SignatureStatus::Unsigned => false,
};
let manifest: UpdateManifest =
serde_json::from_value(raw).context("parse update manifest")?;
Ok((manifest, signed))
}
/// Which manifest URL to try FIRST — operator override via env wins, /// Which manifest URL to try FIRST — operator override via env wins,
/// otherwise the first entry in the mirrors list, otherwise the hard /// otherwise the first entry in the mirrors list, otherwise the hard
/// default. Callers that need the full mirror walk should use /// default. Callers that need the full mirror walk should use
@ -416,14 +394,6 @@ pub struct UpdateState {
/// their node actually hit (vs. just which is configured primary). /// their node actually hit (vs. just which is configured primary).
#[serde(default)] #[serde(default)]
pub manifest_mirror: Option<String>, pub manifest_mirror: Option<String>,
/// True when `available_update` came from a manifest whose detached
/// Ed25519 signature verified against the pinned release-root anchor
/// (`trust::verify_detached`). Unsigned manifests are still offered for
/// MANUAL apply during the signing-migration window, but the scheduler
/// refuses to auto-apply them — otherwise a mirror/MITM could push an
/// arbitrary root binary to the fleet unattended.
#[serde(default)]
pub manifest_signed: bool,
} }
impl Default for UpdateState { impl Default for UpdateState {
@ -436,7 +406,6 @@ impl Default for UpdateState {
rollback_available: false, rollback_available: false,
schedule: UpdateSchedule::DailyCheck, schedule: UpdateSchedule::DailyCheck,
manifest_mirror: None, manifest_mirror: None,
manifest_signed: false,
} }
} }
} }
@ -513,64 +482,8 @@ async fn probe_frontend_once() -> Result<()> {
anyhow::bail!("frontend probe returned HTTP {}", status); anyhow::bail!("frontend probe returned HTTP {}", status);
} }
/// Probe the backend RPC API through nginx. An unauthenticated call is
/// EXPECTED to get 401/403 — any such response proves the Rust HTTP stack
/// is alive behind nginx. 5xx (backend dead → nginx 502), 404 (proxy
/// misroute), or connection errors mean the API is down even though the
/// static frontend may still serve — exactly the failure mode the plain
/// `GET /` probe waved through.
async fn probe_backend_once() -> Result<()> {
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(5))
.build()
.context("build probe client")?;
let body = serde_json::json!({ "method": "update.status" });
let resp = match client
.post("https://127.0.0.1/rpc/v1")
.json(&body)
.send()
.await
{
Ok(resp) => resp,
Err(e) if e.is_connect() => client
.post("http://127.0.0.1/rpc/v1")
.json(&body)
.send()
.await
.context("probe POST http://127.0.0.1/rpc/v1 (https not bound on loopback)")?,
Err(e) => return Err(e).context("probe POST https://127.0.0.1/rpc/v1"),
};
let status = resp.status();
if status.is_server_error() || status == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!("backend RPC probe returned HTTP {}", status);
}
Ok(())
}
/// Probe that the rootless container runtime is reachable from the new
/// binary (uid mapping / podman socket intact after the swap). A healthy
/// node answers `podman ps` in well under a second.
async fn probe_container_runtime_once() -> Result<()> {
let out = tokio::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output()
.await
.context("spawn podman ps")?;
if !out.status.success() {
anyhow::bail!(
"podman ps exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
/// Called from main.rs startup. If a pending-verification marker is /// Called from main.rs startup. If a pending-verification marker is
/// present, verify the node actually works on the new version — binary /// present, probe the frontend; on failure, trigger rollback and
/// version matches the marker, frontend serves, backend RPC answers,
/// rootless podman is reachable. On failure, trigger rollback and
/// restart the service so the OLD binary boots. /// restart the service so the OLD binary boots.
/// ///
/// This is the "post-OTA auto-rollback" guardrail. If ANY problem in /// This is the "post-OTA auto-rollback" guardrail. If ANY problem in
@ -603,60 +516,35 @@ pub async fn verify_pending_update(data_dir: &Path) {
info!( info!(
new_version = %marker.new_version, new_version = %marker.new_version,
previous_version = %marker.previous_version, previous_version = %marker.previous_version,
"Post-OTA verification: probing frontend, backend RPC, and container runtime" "Post-OTA verification: probing frontend at https://127.0.0.1/"
); );
// Binary identity check: if the running binary's version isn't the one
// the marker says we applied, the swap silently failed (or half-applied
// — new frontend with old binary). Deterministic, so no retry loop:
// fall through straight to rollback to restore a matched pair.
let running = env!("CARGO_PKG_VERSION");
let mut attempt = 0u32;
let mut last_err: Option<String> = None;
let version_ok = running == marker.new_version;
if !version_ok {
last_err = Some(format!(
"running binary is {} but marker says {} was applied — binary swap failed",
running, marker.new_version
));
} else {
// Give the new service time to bind its listeners + nginx to // Give the new service time to bind its listeners + nginx to
// pick up any config changes. 15s matches what we observed on // pick up any config changes. 15s matches what we observed on
// .116 during the v1.7.40 rollout recovery. // .116 during the v1.7.40 rollout recovery.
tokio::time::sleep(std::time::Duration::from_secs(15)).await; tokio::time::sleep(std::time::Duration::from_secs(15)).await;
let deadline = std::time::Instant::now() let deadline =
+ std::time::Duration::from_secs(PENDING_VERIFY_WINDOW_SECS); std::time::Instant::now() + std::time::Duration::from_secs(PENDING_VERIFY_WINDOW_SECS);
let mut attempt = 0u32;
let mut last_err: Option<String> = None;
while std::time::Instant::now() < deadline { while std::time::Instant::now() < deadline {
attempt += 1; attempt += 1;
// All three must pass in the same attempt: static frontend via match probe_frontend_once().await {
// nginx, backend RPC liveness, and rootless-podman reachability.
// (Individual app containers are NOT asserted — the pre-Quadlet
// service restart legitimately takes them down and the boot
// reconciler can need minutes to bring heavy apps back.)
let result = match probe_frontend_once().await {
Ok(()) => match probe_backend_once().await {
Ok(()) => probe_container_runtime_once().await,
Err(e) => Err(e),
},
Err(e) => Err(e),
};
match result {
Ok(()) => { Ok(()) => {
info!(attempt, "Post-OTA verification succeeded — clearing marker"); info!(attempt, "Post-OTA verification succeeded — clearing marker");
clear_pending_verification(data_dir).await; clear_pending_verification(data_dir).await;
return; return;
} }
Err(e) => { Err(e) => {
let msg = format!("{e:#}"); let msg = e.to_string();
tracing::warn!(attempt, error = %msg, "Post-OTA probe failed, retrying"); tracing::warn!(attempt, error = %msg, "Post-OTA probe failed, retrying");
last_err = Some(msg); last_err = Some(msg);
} }
} }
tokio::time::sleep(std::time::Duration::from_secs(5)).await; tokio::time::sleep(std::time::Duration::from_secs(5)).await;
} }
}
tracing::error!( tracing::error!(
attempts = attempt, attempts = attempt,
@ -733,7 +621,6 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
// if there's genuinely something newer. // if there's genuinely something newer.
state.available_update = None; state.available_update = None;
state.manifest_mirror = None; state.manifest_mirror = None;
state.manifest_signed = false;
changed = true; changed = true;
} }
@ -823,33 +710,19 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
tokio::time::sleep(std::time::Duration::from_secs(2)).await; tokio::time::sleep(std::time::Duration::from_secs(2)).await;
} }
match client.get(manifest_url).send().await { match client.get(manifest_url).send().await {
Ok(resp) if resp.status().is_success() => match resp Ok(resp) if resp.status().is_success() => match resp.json::<UpdateManifest>().await
.json::<serde_json::Value>()
.await
.map_err(anyhow::Error::from)
.and_then(parse_and_verify_manifest)
{ {
Ok((mut manifest, signed)) => { Ok(mut manifest) => {
rewrite_manifest_origins(&mut manifest, manifest_url); rewrite_manifest_origins(&mut manifest, manifest_url);
if is_newer(&manifest.version, &state.current_version) { if is_newer(&manifest.version, &state.current_version) {
if !signed {
warn!(
available = %manifest.version,
mirror = %manifest_url,
"Update manifest is NOT signed by the release root — \
offering for manual apply only; auto-apply is refused"
);
}
info!( info!(
current = %state.current_version, current = %state.current_version,
available = %manifest.version, available = %manifest.version,
mirror = %manifest_url, mirror = %manifest_url,
signed,
"Update available" "Update available"
); );
state.available_update = Some(manifest); state.available_update = Some(manifest);
state.manifest_mirror = Some(manifest_url.clone()); state.manifest_mirror = Some(manifest_url.clone());
state.manifest_signed = signed;
} else { } else {
// Manifest version matches us or is behind // Manifest version matches us or is behind
// us — either we're current, or this mirror // us — either we're current, or this mirror
@ -864,7 +737,6 @@ pub async fn check_for_updates(data_dir: &Path) -> Result<UpdateState> {
); );
state.manifest_mirror = None; state.manifest_mirror = None;
state.available_update = None; state.available_update = None;
state.manifest_signed = false;
handled = true; handled = true;
continue 'mirrors; continue 'mirrors;
} }
@ -1258,22 +1130,14 @@ async fn download_component_resumable(
} }
} }
if canceled { if canceled {
// Best-effort: the partial file is re-validated (size + sha)
// when the download resumes, so a lost tail here is harmless.
let _ = file.flush().await; let _ = file.flush().await;
drop(file); drop(file);
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed); DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
DOWNLOAD_BYTES.store(0, Ordering::Relaxed); DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
anyhow::bail!("Download canceled"); anyhow::bail!("Download canceled");
} }
// A failed flush/sync surfaces later as a size/sha mismatch and a let _ = file.flush().await;
// resume, but log it — this is where ENOSPC first shows itself. let _ = file.sync_all().await;
if let Err(e) = file.flush().await {
warn!("Staging file flush failed: {e:#}");
}
if let Err(e) = file.sync_all().await {
warn!("Staging file sync failed: {e:#}");
}
drop(file); drop(file);
if stream_err { if stream_err {
continue; continue;
@ -1356,9 +1220,7 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
if let Ok(mut state) = load_state(data_dir).await { if let Ok(mut state) = load_state(data_dir).await {
if state.update_in_progress { if state.update_in_progress {
state.update_in_progress = false; state.update_in_progress = false;
if let Err(e) = save_state(data_dir, &state).await { let _ = save_state(data_dir, &state).await;
warn!("Failed to persist cleared update_in_progress: {e:#}");
}
cleared_marker = true; cleared_marker = true;
} }
} }
@ -1897,23 +1759,6 @@ async fn apply_per_app_auto_updates(
} }
} }
/// After a catalog refresh that changed the cached bytes, rebuild the
/// orchestrator's manifest map so registry-shipped manifest changes take
/// effect now instead of at the next service restart.
async fn reload_manifests_if_changed(
refresh: crate::container::app_catalog::CatalogRefresh,
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
) {
if !refresh.changed {
return;
}
let Some(orch) = orchestrator else { return };
match orch.reload_manifests().await {
Ok(n) => info!("Update scheduler: catalog changed, reloaded {n} manifest(s)"),
Err(e) => warn!("Update scheduler: manifest reload after catalog change failed: {e}"),
}
}
pub async fn run_update_scheduler( pub async fn run_update_scheduler(
data_dir: std::path::PathBuf, data_dir: std::path::PathBuf,
orchestrator: Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>, orchestrator: Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
@ -1925,12 +1770,11 @@ pub async fn run_update_scheduler(
// Refresh the app catalog once at startup so per-app "update available" // Refresh the app catalog once at startup so per-app "update available"
// badges appear without waiting for the first hourly tick. // badges appear without waiting for the first hourly tick.
match crate::container::app_catalog::refresh_catalog(&data_dir).await { if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await {
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await, debug!(
Err(e) => debug!(
"Update scheduler: initial app-catalog refresh failed: {}", "Update scheduler: initial app-catalog refresh failed: {}",
e e
), );
} }
loop { loop {
@ -1940,11 +1784,8 @@ pub async fn run_update_scheduler(
// populates per-app update availability (the "Update" button still has // populates per-app update availability (the "Update" button still has
// to be clicked — nothing auto-applies). Best-effort; on failure the // to be clicked — nothing auto-applies). Best-effort; on failure the
// previously cached catalog stays in place (origin-always-wins). // previously cached catalog stays in place (origin-always-wins).
// A changed catalog also reloads the orchestrator's manifest overlay so if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await {
// catalog-shipped manifest fixes apply without a service restart. debug!("Update scheduler: app-catalog refresh failed: {}", e);
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
Err(e) => debug!("Update scheduler: app-catalog refresh failed: {}", e),
} }
// Per-app auto-update-to-latest (multi-version support). Runs every tick // Per-app auto-update-to-latest (multi-version support). Runs every tick
@ -2005,23 +1846,6 @@ pub async fn run_update_scheduler(
info!("Update scheduler: 3 AM auto-apply window"); info!("Update scheduler: 3 AM auto-apply window");
match check_for_updates(&data_dir).await { match check_for_updates(&data_dir).await {
Ok(s) if s.available_update.is_some() => { Ok(s) if s.available_update.is_some() => {
if !s.manifest_signed {
// Unattended apply of an unauthenticated manifest is
// the §A supply-chain hole: a mirror/MITM could ship
// an arbitrary root binary fleet-wide at 3 AM. Manual
// apply from the UI remains possible during the
// signing-migration window; auto-apply does not.
warn!(
available = %s
.available_update
.as_ref()
.map(|m| m.version.as_str())
.unwrap_or("?"),
"Update scheduler: manifest is not signed by the \
release root refusing unattended auto-apply"
);
continue;
}
info!("Update scheduler: downloading update"); info!("Update scheduler: downloading update");
if let Err(e) = download_update(&data_dir).await { if let Err(e) = download_update(&data_dir).await {
debug!("Update scheduler: download failed: {}", e); debug!("Update scheduler: download failed: {}", e);
@ -2060,68 +1884,6 @@ pub async fn run_update_scheduler(
mod tests { mod tests {
use super::*; use super::*;
fn sample_manifest_value() -> serde_json::Value {
serde_json::json!({
"version": "9.9.9",
"release_date": "2026-07-02",
"changelog": ["test release"],
"components": [{
"name": "archipelago",
"current_version": "0.0.0",
"new_version": "9.9.9",
"download_url": "http://example.invalid/archipelago",
"sha256": "00",
"size_bytes": 1
}]
})
}
/// Same key + env-pin convention as `trust::signed_doc::tests` — every
/// caller pins the identical value, so parallel tests stay consistent.
fn test_release_key() -> ed25519_dalek::SigningKey {
let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
std::env::set_var(
"ARCHY_RELEASE_ROOT_PUBKEY",
hex::encode(key.verifying_key().to_bytes()),
);
key
}
fn sign_value(key: &ed25519_dalek::SigningKey, mut doc: serde_json::Value) -> serde_json::Value {
let (sig, did) = crate::trust::signed_doc::sign_detached(key, &doc).unwrap();
let obj = doc.as_object_mut().unwrap();
obj.insert("signed_by".into(), serde_json::json!(did));
obj.insert("signature".into(), serde_json::json!(sig));
doc
}
#[test]
fn unsigned_manifest_parses_but_reports_unsigned() {
let (manifest, signed) = parse_and_verify_manifest(sample_manifest_value()).unwrap();
assert_eq!(manifest.version, "9.9.9");
assert!(!signed, "unsigned manifest must not report as signed");
}
#[test]
fn anchor_signed_manifest_reports_signed() {
let key = test_release_key();
let doc = sign_value(&key, sample_manifest_value());
let (manifest, signed) = parse_and_verify_manifest(doc).unwrap();
assert_eq!(manifest.version, "9.9.9");
assert!(signed);
}
#[test]
fn tampered_signed_manifest_is_rejected() {
let key = test_release_key();
let mut doc = sign_value(&key, sample_manifest_value());
// Version swap after signing — exactly what a malicious mirror would do.
doc.as_object_mut()
.unwrap()
.insert("version".into(), serde_json::json!("99.0.0"));
assert!(parse_and_verify_manifest(doc).is_err());
}
#[test] #[test]
fn test_update_schedule_default_is_daily_check() { fn test_update_schedule_default_is_daily_check() {
let schedule = UpdateSchedule::default(); let schedule = UpdateSchedule::default();
@ -2132,9 +1894,9 @@ mod tests {
fn test_manifest_origin_parses_https() { fn test_manifest_origin_parses_https() {
assert_eq!( assert_eq!(
manifest_origin( manifest_origin(
"https://releases.example.com/lfg2025/archy/raw/branch/main/releases/manifest.json" "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
), ),
Some("https://releases.example.com".to_string()) Some("https://git.tx1138.com".to_string())
); );
} }
@ -2151,7 +1913,7 @@ mod tests {
#[test] #[test]
fn test_manifest_origin_rejects_garbage() { fn test_manifest_origin_rejects_garbage() {
assert_eq!(manifest_origin("not a url"), None); assert_eq!(manifest_origin("not a url"), None);
assert_eq!(manifest_origin("ftp://releases.example.com/x"), None); assert_eq!(manifest_origin("ftp://git.tx1138.com/x"), None);
} }
#[test] #[test]
@ -2165,7 +1927,7 @@ mod tests {
name: "archipelago".into(), name: "archipelago".into(),
current_version: "1.7.25-alpha".into(), current_version: "1.7.25-alpha".into(),
new_version: "1.7.26-alpha".into(), new_version: "1.7.26-alpha".into(),
download_url: "https://releases.example.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago".into(), download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago".into(),
sha256: "x".into(), sha256: "x".into(),
size_bytes: 1, size_bytes: 1,
blake3: None, blake3: None,
@ -2174,7 +1936,7 @@ mod tests {
name: "frontend".into(), name: "frontend".into(),
current_version: "1.7.25-alpha".into(), current_version: "1.7.25-alpha".into(),
new_version: "1.7.26-alpha".into(), new_version: "1.7.26-alpha".into(),
download_url: "https://releases.example.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz".into(), download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz".into(),
sha256: "y".into(), sha256: "y".into(),
size_bytes: 2, size_bytes: 2,
blake3: None, blake3: None,
@ -2218,8 +1980,6 @@ mod tests {
label: "Server 1 (OVH)".to_string(), label: "Server 1 (OVH)".to_string(),
}, },
UpdateMirror { UpdateMirror {
// Deliberately the retired host: this fixture exists to prove
// load_mirrors strips it.
url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json" url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
.to_string(), .to_string(),
label: "Server 2 (tx1138)".to_string(), label: "Server 2 (tx1138)".to_string(),
@ -2283,7 +2043,6 @@ mod tests {
rollback_available: true, rollback_available: true,
schedule: UpdateSchedule::AutoApply, schedule: UpdateSchedule::AutoApply,
manifest_mirror: None, manifest_mirror: None,
manifest_signed: false,
}; };
let json = serde_json::to_string(&state).unwrap(); let json = serde_json::to_string(&state).unwrap();
let deserialized: UpdateState = serde_json::from_str(&json).unwrap(); let deserialized: UpdateState = serde_json::from_str(&json).unwrap();
@ -2408,10 +2167,9 @@ mod tests {
rollback_available: false, rollback_available: false,
schedule: UpdateSchedule::Manual, schedule: UpdateSchedule::Manual,
manifest_mirror: Some( manifest_mirror: Some(
"https://releases.example.com/lfg2025/archy/raw/branch/main/releases/manifest.json" "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
.to_string(), .to_string(),
), ),
manifest_signed: false,
}; };
save_state(dir.path(), &state).await.unwrap(); save_state(dir.path(), &state).await.unwrap();
let loaded = load_state(dir.path()).await.unwrap(); let loaded = load_state(dir.path()).await.unwrap();

View File

@ -1,6 +1,6 @@
//! Cashu token format (NUT-00) — serialization and deserialization. //! Cashu token format (NUT-00) — serialization and deserialization.
//! //!
//! Emits the cashuA (V3) token format: //! Supports the cashuA (V3) token format:
//! cashuA<base64url_encoded_json> //! cashuA<base64url_encoded_json>
//! //!
//! Token JSON structure: //! Token JSON structure:
@ -8,54 +8,13 @@
//! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }], //! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }],
//! "memo": "<optional>" //! "memo": "<optional>"
//! } //! }
//!
//! Also accepts (decode-only) the cashuB (V4) CBOR format many wallets emit
//! by default now:
//! cashuB<base64url_encoded_cbor>
//! CBOR map keys are the spec's single-letter names (t/i/p/a/s/c/m/u/d/w),
//! not the JSON names above. `i` (keyset id) and `c` (signature) are raw
//! bytes on the wire; we hex-encode them into `Proof` to match the V3
//! convention so the rest of the wallet doesn't need to know which version
//! a token arrived in.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use bitcoin::secp256k1::PublicKey; use bitcoin::secp256k1::PublicKey;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Prefix for V3 (JSON) tokens. /// Prefix for V3 tokens.
const CASHU_A_PREFIX: &str = "cashuA"; const CASHU_A_PREFIX: &str = "cashuA";
/// Prefix for V4 (CBOR) tokens.
const CASHU_B_PREFIX: &str = "cashuB";
/// Raw CBOR shape of a V4 proof — field names are the spec's map keys.
#[derive(Debug, Deserialize)]
struct ProofV4 {
a: u64,
s: String,
#[serde(with = "serde_bytes")]
c: Vec<u8>,
// DLEQ proof ("d") and witness ("w") aren't verified or stored by this
// wallet; accept and discard them rather than fail on the field.
}
/// Raw CBOR shape of a V4 token entry (one keyset's worth of proofs).
#[derive(Debug, Deserialize)]
struct TokenEntryV4 {
#[serde(with = "serde_bytes")]
i: Vec<u8>,
p: Vec<ProofV4>,
}
/// Raw CBOR shape of a full V4 token — single mint per token, unlike V3.
#[derive(Debug, Deserialize)]
struct TokenV4 {
t: Vec<TokenEntryV4>,
m: String,
#[serde(default)]
u: Option<String>,
#[serde(default, rename = "d")]
memo: Option<String>,
}
/// A single Cashu proof (a signed token for a specific denomination). /// A single Cashu proof (a signed token for a specific denomination).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -141,65 +100,31 @@ impl CashuToken {
Ok(format!("{}{}", CASHU_A_PREFIX, encoded)) Ok(format!("{}{}", CASHU_A_PREFIX, encoded))
} }
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string. /// Decode a cashuA token string.
pub fn deserialize(token_str: &str) -> Result<Self> { pub fn deserialize(token_str: &str) -> Result<Self> {
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) { let payload = token_str
return Self::deserialize_v4(payload); .strip_prefix(CASHU_A_PREFIX)
} .ok_or_else(|| anyhow::anyhow!("Token must start with '{}'", CASHU_A_PREFIX))?;
let payload = token_str.strip_prefix(CASHU_A_PREFIX).ok_or_else(|| { use base64::Engine;
anyhow::anyhow!( let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
"Token must start with '{}' or '{}'", .decode(payload)
CASHU_A_PREFIX, .or_else(|_| {
CASHU_B_PREFIX // Try standard base64 as fallback (some implementations use it)
) base64::engine::general_purpose::URL_SAFE.decode(payload)
})?; })
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload))
.context("Invalid base64 in cashuA token")?;
let decoded = decode_token_base64(payload).context("Invalid base64 in cashuA token")?;
let json_str = String::from_utf8(decoded).context("Invalid UTF-8 in decoded token")?; let json_str = String::from_utf8(decoded).context("Invalid UTF-8 in decoded token")?;
let token: CashuToken = let token: CashuToken =
serde_json::from_str(&json_str).context("Invalid JSON in cashuA token")?; serde_json::from_str(&json_str).context("Invalid JSON in cashuA token")?;
token.validate()?; // Structural validation
Ok(token) if token.token.is_empty() {
}
/// Decode a cashuB (V4 CBOR) token payload (prefix already stripped).
fn deserialize_v4(payload: &str) -> Result<Self> {
let decoded = decode_token_base64(payload).context("Invalid base64 in cashuB token")?;
let v4: TokenV4 =
ciborium::from_reader(decoded.as_slice()).context("Invalid CBOR in cashuB token")?;
let proofs = v4
.t
.into_iter()
.flat_map(|entry| {
let keyset_id = hex::encode(&entry.i);
entry.p.into_iter().map(move |p| Proof {
amount: p.a,
id: keyset_id.clone(),
secret: p.s,
c: hex::encode(&p.c),
})
})
.collect();
let token = CashuToken {
token: vec![TokenEntry { mint: v4.m, proofs }],
memo: v4.memo,
unit: v4.u,
};
token.validate()?;
Ok(token)
}
/// Structural validation shared by both token versions.
fn validate(&self) -> Result<()> {
if self.token.is_empty() {
anyhow::bail!("Token has no entries"); anyhow::bail!("Token has no entries");
} }
for entry in &self.token { for entry in &token.token {
if entry.mint.is_empty() { if entry.mint.is_empty() {
anyhow::bail!("Token entry has empty mint URL"); anyhow::bail!("Token entry has empty mint URL");
} }
@ -218,18 +143,9 @@ impl CashuToken {
} }
} }
} }
Ok(())
}
}
/// Decode a token's base64 payload, trying URL-safe-no-pad first (the spec Ok(token)
/// default) and falling back to other alphabets some implementations use. }
fn decode_token_base64(payload: &str) -> Result<Vec<u8>, base64::DecodeError> {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload))
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload))
} }
/// Keyset info returned by a mint. /// Keyset info returned by a mint.
@ -387,63 +303,11 @@ mod tests {
} }
#[test] #[test]
fn test_deserialize_rejects_unknown_prefix() { fn test_deserialize_rejects_invalid_prefix() {
let result = CashuToken::deserialize("cashuZabc123");
assert!(result.is_err());
}
#[test]
fn test_deserialize_rejects_malformed_v4_cbor() {
let result = CashuToken::deserialize("cashuBabc123"); let result = CashuToken::deserialize("cashuBabc123");
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn test_deserialize_v4_cbor_token() {
// Hand-built (not via our own encoder) to verify we actually match
// the NUT-00 V4 wire format: single-letter CBOR map keys, raw-byte
// keyset id ("i") and signature ("c").
use base64::Engine;
use ciborium::value::Value;
let keyset_id = vec![0x00u8, 0x9a, 0x1f, 0x29, 0x32, 0x53, 0xe4, 0x1e];
let sig = hex::decode(
"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24",
)
.unwrap();
let proof = Value::Map(vec![
(Value::from("a"), Value::from(8u64)),
(Value::from("s"), Value::from("abcdef1234567890")),
(Value::from("c"), Value::from(sig.clone())),
]);
let entry = Value::Map(vec![
(Value::from("i"), Value::from(keyset_id.clone())),
(Value::from("p"), Value::Array(vec![proof])),
]);
let token = Value::Map(vec![
(Value::from("t"), Value::Array(vec![entry])),
(Value::from("m"), Value::from("http://127.0.0.1:8175")),
(Value::from("u"), Value::from("sat")),
(Value::from("d"), Value::from("test token")),
]);
let mut buf = Vec::new();
ciborium::into_writer(&token, &mut buf).unwrap();
let encoded = format!(
"cashuB{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf)
);
let decoded = CashuToken::deserialize(&encoded).unwrap();
assert_eq!(decoded.total_amount(), 8);
assert_eq!(decoded.token[0].mint, "http://127.0.0.1:8175");
assert_eq!(decoded.token[0].proofs[0].secret, "abcdef1234567890");
assert_eq!(decoded.token[0].proofs[0].id, hex::encode(&keyset_id));
assert_eq!(decoded.token[0].proofs[0].c, hex::encode(&sig));
assert_eq!(decoded.memo, Some("test token".to_string()));
}
#[test] #[test]
fn test_amount_to_denominations() { fn test_amount_to_denominations() {
assert_eq!(amount_to_denominations(0), Vec::<u64>::new()); assert_eq!(amount_to_denominations(0), Vec::<u64>::new());

View File

@ -19,8 +19,6 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4"] } uuid = { version = "1.0", features = ["v4"] }
log = "0.4" log = "0.4"
tracing = "0.1" tracing = "0.1"
sha2 = "0.10"
hex = "0.4"
[lib] [lib]
name = "archipelago_container" name = "archipelago_container"

View File

@ -1,207 +0,0 @@
//! Container image signature verification (cosign).
//!
//! The manifest/catalog `image_signature` field is a *claim* that the image
//! is signed with the fleet's cosign key. Verification runs at the pull
//! choke points (`PodmanClient::pull_image`, `DockerRuntime::pull_image`);
//! a declared signature that cannot be verified hard-fails the pull.
//!
//! Every manifest has carried the literal placeholder `cosign://...` since
//! the field was introduced — that means "not signed yet" and is treated as
//! no claim, so enforcement stays dormant until the signing ceremony
//! publishes real signatures AND nodes carry the pinned cosign public key.
//! Ship order matters: key + cosign binary reach the fleet first, real
//! signature values in the catalog come after.
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
/// The literal placeholder every pre-ceremony manifest carries.
pub const SIGNATURE_PLACEHOLDER: &str = "cosign://...";
/// Env override for the pinned cosign public key path (tests, staging).
pub const COSIGN_PUBKEY_ENV: &str = "ARCHIPELAGO_COSIGN_PUBKEY";
const DEFAULT_PUBKEY_PATH: &str = "/etc/archipelago/cosign.pub";
const COSIGN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignatureClaim {
/// No signature declared (field absent or empty).
None,
/// The literal `cosign://...` placeholder — manifest predates real signing.
Placeholder,
/// A real declared signature reference; MUST verify or the pull fails.
Declared(String),
}
pub fn classify_signature(signature: Option<&str>) -> SignatureClaim {
match signature.map(str::trim) {
None | Some("") => SignatureClaim::None,
Some(SIGNATURE_PLACEHOLDER) => SignatureClaim::Placeholder,
Some(s) => SignatureClaim::Declared(s.to_string()),
}
}
fn pinned_pubkey_path() -> PathBuf {
std::env::var(COSIGN_PUBKEY_ENV)
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_PUBKEY_PATH))
}
/// Verify a declared image signature with the fleet's pinned cosign key.
/// Any failure — missing key, missing cosign binary, verification error —
/// is a hard error: an image that CLAIMS to be signed must never be pulled
/// on a node that can't prove the claim.
pub async fn verify_declared_signature(
image: &str,
sig_ref: &str,
allow_insecure_registry: bool,
) -> Result<()> {
verify_with_key_path(image, sig_ref, &pinned_pubkey_path(), allow_insecure_registry).await
}
async fn verify_with_key_path(
image: &str,
sig_ref: &str,
key_path: &std::path::Path,
allow_insecure_registry: bool,
) -> Result<()> {
if !key_path.exists() {
bail!(
"Image '{image}' declares signature '{sig_ref}' but the pinned cosign \
public key is missing at {} (override with {COSIGN_PUBKEY_ENV}). \
Refusing to pull an image whose signature claim cannot be verified.",
key_path.display()
);
}
// Self-managed key => signatures aren't in the public Rekor transparency
// log, so tlog verification must be disabled explicitly (cosign v2
// defaults it on and would fail every private-key signature otherwise).
let mut cmd = tokio::process::Command::new("cosign");
cmd.arg("verify")
.arg("--key")
.arg(key_path)
.arg("--insecure-ignore-tlog=true");
if allow_insecure_registry {
// podman's --tls-verify=false covers both plain HTTP and bad TLS;
// cosign splits those into two flags — pass both to match.
cmd.arg("--allow-insecure-registry");
cmd.arg("--allow-http-registry");
}
cmd.arg(image);
let output = tokio::time::timeout(COSIGN_TIMEOUT, cmd.output())
.await
.map_err(|_| {
anyhow::anyhow!(
"cosign verify timed out after {}s for image '{image}'",
COSIGN_TIMEOUT.as_secs()
)
})?
.with_context(|| {
format!(
"Failed to run cosign for image '{image}' which declares signature \
'{sig_ref}' is cosign installed? A declared signature cannot be \
skipped."
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"Signature verification FAILED for image '{image}' (declared: '{sig_ref}'): \
{stderr}"
);
}
tracing::info!("cosign signature verified for image {image}");
Ok(())
}
/// Shared pull-site gate: decide whether the pull may proceed.
/// Returns Ok(()) for unsigned/placeholder claims (with a log line) and only
/// after successful cosign verification for declared ones.
pub async fn enforce_signature_claim(
image: &str,
signature: Option<&str>,
allow_insecure_registry: bool,
) -> Result<()> {
match classify_signature(signature) {
SignatureClaim::None => {
tracing::debug!("image {image}: no signature declared, pulling unverified");
Ok(())
}
SignatureClaim::Placeholder => {
tracing::debug!(
"image {image}: signature is the pre-ceremony placeholder, pulling unverified"
);
Ok(())
}
SignatureClaim::Declared(sig_ref) => {
verify_declared_signature(image, &sig_ref, allow_insecure_registry).await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn absent_and_empty_signatures_are_no_claim() {
assert_eq!(classify_signature(None), SignatureClaim::None);
assert_eq!(classify_signature(Some("")), SignatureClaim::None);
assert_eq!(classify_signature(Some(" ")), SignatureClaim::None);
}
#[test]
fn literal_placeholder_is_not_a_claim() {
assert_eq!(
classify_signature(Some("cosign://...")),
SignatureClaim::Placeholder
);
assert_eq!(
classify_signature(Some(" cosign://... ")),
SignatureClaim::Placeholder
);
}
#[test]
fn real_values_are_declared_claims() {
assert_eq!(
classify_signature(Some("cosign://sha256-abc.sig")),
SignatureClaim::Declared("cosign://sha256-abc.sig".to_string())
);
// Unknown schemes still count as a claim — better to fail closed on
// a value we don't understand than to pull unverified.
assert_eq!(
classify_signature(Some("sigstore://whatever")),
SignatureClaim::Declared("sigstore://whatever".to_string())
);
}
#[tokio::test]
async fn declared_signature_without_pinned_key_hard_fails() {
let err = verify_with_key_path(
"registry.example/app:1.0",
"cosign://sha256-abc.sig",
std::path::Path::new("/nonexistent/cosign.pub"),
false,
)
.await
.unwrap_err();
assert!(err.to_string().contains("pinned cosign public key is missing"));
}
#[tokio::test]
async fn unsigned_and_placeholder_claims_pass_the_gate() {
enforce_signature_claim("registry.example/app:1.0", None, false)
.await
.unwrap();
enforce_signature_claim("registry.example/app:1.0", Some("cosign://..."), false)
.await
.unwrap();
}
}

View File

@ -1,6 +1,5 @@
pub mod bitcoin_simulator; pub mod bitcoin_simulator;
pub mod health_monitor; pub mod health_monitor;
pub mod image_verify;
pub mod manifest; pub mod manifest;
pub mod podman_client; pub mod podman_client;
pub mod port_manager; pub mod port_manager;

View File

@ -243,22 +243,6 @@ pub struct ContainerConfig {
/// Example: `"100070:100070"` for Postgres' mapped subuid. /// Example: `"100070:100070"` for Postgres' mapped subuid.
#[serde(default)] #[serde(default)]
pub data_uid: Option<String>, pub data_uid: Option<String>,
/// Runtime-resolved secret env entries (never serialized, never in a
/// manifest file). Populated by the orchestrator's env-resolution
/// chokepoint; the backends turn each ref into a podman secret
/// reference (`secret_env` in the API spec / `Secret=…,type=env` in
/// Quadlet) so the VALUE never lands in `podman inspect` output or a
/// unit file on disk. The dev-only docker fallback injects `value` as
/// plain env — docker has no rootless secret store.
#[serde(skip)]
pub secret_env_refs: Vec<SecretEnvRef>,
/// sha256 over all resolved secret env pairs, stamped onto the
/// container as a label so rotation is detectable as drift without
/// exposing values. None when the app has no secret env.
#[serde(skip)]
pub secret_env_hash: Option<String>,
} }
/// Derived-env entry. The template is rendered against `HostFacts` at /// Derived-env entry. The template is rendered against `HostFacts` at
@ -279,81 +263,6 @@ pub struct DerivedEnv {
pub struct SecretEnv { pub struct SecretEnv {
pub key: String, pub key: String,
pub secret_file: String, pub secret_file: String,
/// When true, a missing/unreadable/empty secret skips this entry instead
/// of failing the whole resolution. For integrations that exist on some
/// nodes only (btcpay's internal-LND connection string: nodes without
/// LND must still run btcpay, just without the internal node).
#[serde(default)]
pub optional: bool,
}
/// A fully resolved secret env entry, produced at apply time. `value` lives
/// only in memory on its way to the podman secret store (or, on the dev
/// docker fallback, straight into the container env).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretEnvRef {
pub env_key: String,
/// Podman secret object name: `archy-env-<app>-<KEY>`.
pub secret_name: String,
pub value: String,
}
/// Container label carrying the combined secret-env content hash, used by
/// the reconciler to detect secret rotation as env drift.
pub const SECRET_ENV_HASH_LABEL: &str = "io.archipelago.secret-env-hash";
/// Podman secret label carrying the individual secret's content hash, used
/// to skip re-registration when nothing changed.
pub const SECRET_HASH_LABEL: &str = "io.archipelago.hash";
/// Expand `${KEY}` placeholders across plain + secret values, then split
/// the result into (plain env, secret-bearing pairs). Any plain entry whose
/// value interpolates a secret key is *tainted* — it embeds the secret and
/// must travel as a secret itself (btcpay's
/// `BTCPAY_POSTGRES=…Password=${BTCPAY_DB_PASS};…` pattern). Secret values
/// themselves are taken verbatim: a generated secret that happens to
/// contain `${` must not be mangled by expansion.
pub fn expand_and_partition_env(
plain: Vec<String>,
secrets: Vec<(String, String)>,
) -> (Vec<String>, Vec<(String, String)>) {
let plain_values: std::collections::HashMap<String, String> = plain
.iter()
.filter_map(|entry| {
let (key, value) = entry.split_once('=')?;
Some((key.to_string(), value.to_string()))
})
.collect();
let mut out_plain = Vec::with_capacity(plain.len());
let mut out_secret: Vec<(String, String)> = Vec::with_capacity(secrets.len());
for entry in plain {
let Some((key, value)) = entry.split_once('=') else {
out_plain.push(entry);
continue;
};
let mut expanded = value.to_string();
let mut tainted = false;
for (k, v) in &plain_values {
expanded = expanded.replace(&format!("${{{k}}}"), v);
}
for (k, v) in &secrets {
let placeholder = format!("${{{k}}}");
if expanded.contains(&placeholder) {
expanded = expanded.replace(&placeholder, v);
tainted = true;
}
}
if tainted {
out_secret.push((key.to_string(), expanded));
} else {
out_plain.push(format!("{key}={expanded}"));
}
}
out_secret.extend(secrets);
(out_plain, out_secret)
} }
/// How a [`GeneratedSecret`] is produced. Each kind is deterministic in shape /// How a [`GeneratedSecret`] is produced. Each kind is deterministic in shape
@ -509,13 +418,6 @@ pub struct PortMapping {
pub container: u16, pub container: u16,
#[serde(default)] #[serde(default)]
pub protocol: String, pub protocol: String,
/// Host address to bind the publish to. Empty = all interfaces
/// (0.0.0.0). Set `127.0.0.1` to keep a port host-local; list the same
/// host/container pair twice with different binds to serve several
/// addresses (e.g. loopback + the archy-net gateway `10.89.0.1` so
/// containers keep reaching it via `host.archipelago`).
#[serde(default)]
pub bind: String,
} }
impl From<(u16, u16)> for PortMapping { impl From<(u16, u16)> for PortMapping {
@ -524,38 +426,10 @@ impl From<(u16, u16)> for PortMapping {
host, host,
container, container,
protocol: "tcp".to_string(), protocol: "tcp".to_string(),
bind: String::new(),
} }
} }
} }
/// True when the host can actually bind a publish to `ip` right now.
///
/// Rootless podman forwards published ports via rootlessport, which listens
/// in the HOST network namespace — an address that only exists inside the
/// rootless netns (e.g. the archy-net bridge gateway `10.89.0.1`) fails with
/// "cannot assign requested address" and systemd crash-loops the whole unit
/// (bitcoin went down fleet-wide-capable this way, 2026-07-09 on .228).
/// Callers drop such publishes instead of passing them through: a publish
/// that cannot bind provides nothing, while the failed bind takes the
/// container down with it.
///
/// Empty (= 0.0.0.0), wildcard, and loopback binds are always accepted
/// without probing. Anything else is probed with an ephemeral-port bind.
/// Unparseable addresses return false — podman would reject them anyway.
pub fn host_can_bind_publish_ip(ip: &str) -> bool {
if ip.is_empty() {
return true;
}
let Ok(addr) = ip.parse::<std::net::IpAddr>() else {
return false;
};
if addr.is_unspecified() || addr.is_loopback() {
return true;
}
std::net::TcpListener::bind((addr, 0)).is_ok()
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Volume { pub struct Volume {
#[serde(rename = "type")] #[serde(rename = "type")]
@ -1016,16 +890,7 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
"ports[{i}].protocol must be tcp or udp" "ports[{i}].protocol must be tcp or udp"
))); )));
} }
if !port.bind.is_empty() && port.bind.parse::<std::net::IpAddr>().is_err() { if !seen_host.insert((port.host, protocol.to_string())) {
return Err(ManifestError::Invalid(format!(
"ports[{i}].bind must be an IP address, got '{}'",
port.bind
)));
}
// The same host port may be listed more than once with different bind
// addresses (e.g. loopback + the archy-net gateway); identical
// (host, protocol, bind) triples are still rejected.
if !seen_host.insert((port.host, protocol.to_string(), port.bind.clone())) {
return Err(ManifestError::Invalid(format!( return Err(ManifestError::Invalid(format!(
"ports contains duplicate host binding {}/{}", "ports contains duplicate host binding {}/{}",
port.host, protocol port.host, protocol
@ -1344,60 +1209,23 @@ impl ContainerConfig {
&self, &self,
provider: &dyn SecretsProvider, provider: &dyn SecretsProvider,
) -> Result<Vec<String>, ManifestError> { ) -> Result<Vec<String>, ManifestError> {
Ok(self
.resolve_secret_env_pairs(provider)?
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect())
}
/// Like `resolve_secret_env` but returns (key, value) pairs — the shape
/// the podman-secret pipeline needs.
pub fn resolve_secret_env_pairs(
&self,
provider: &dyn SecretsProvider,
) -> Result<Vec<(String, String)>, ManifestError> {
let mut out = Vec::with_capacity(self.secret_env.len()); let mut out = Vec::with_capacity(self.secret_env.len());
for e in &self.secret_env { for e in &self.secret_env {
let v = match provider.read(&e.secret_file) { let v = provider.read(&e.secret_file)?;
Ok(v) => v,
Err(_) if e.optional => continue,
Err(err) => return Err(err),
};
// An empty secret produces e.g. `-rpcpassword=` and crashes // An empty secret produces e.g. `-rpcpassword=` and crashes
// the container on auth before logs are useful. Fail loud — // the container on auth before logs are useful. Fail loud.
// unless the entry is optional, where empty means "not set".
if v.trim().is_empty() { if v.trim().is_empty() {
if e.optional {
continue;
}
return Err(ManifestError::Invalid(format!( return Err(ManifestError::Invalid(format!(
"secret_env {} resolved to empty value (file: {})", "secret_env {} resolved to empty value (file: {})",
e.key, e.secret_file e.key, e.secret_file
))); )));
} }
out.push((e.key.clone(), v)); out.push(format!("{}={}", e.key, v));
} }
Ok(out) Ok(out)
} }
} }
/// Deterministic content hash over resolved secret env pairs (sorted by
/// key), used for the container drift label and per-secret labels.
pub fn secret_env_content_hash(pairs: &[(String, String)]) -> String {
use sha2::{Digest, Sha256};
let mut sorted: Vec<&(String, String)> = pairs.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
let mut hasher = Sha256::new();
for (k, v) in sorted {
hasher.update(k.as_bytes());
hasher.update([0u8]);
hasher.update(v.as_bytes());
hasher.update([0u8]);
}
hex::encode(hasher.finalize())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -1405,72 +1233,6 @@ mod tests {
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[test]
fn host_can_bind_accepts_empty_wildcard_and_loopback_without_probing() {
assert!(host_can_bind_publish_ip(""));
assert!(host_can_bind_publish_ip("0.0.0.0"));
assert!(host_can_bind_publish_ip("127.0.0.1"));
assert!(host_can_bind_publish_ip("::"));
assert!(host_can_bind_publish_ip("::1"));
}
#[test]
fn host_can_bind_rejects_addresses_absent_from_the_host() {
// TEST-NET-1 (RFC 5737) is never assigned to a real interface. The
// production case is 10.89.0.1 (podman bridge gateway, exists only
// inside the rootless netns), but that could legitimately bind on a
// rootful host, so the test probes an address that can't.
assert!(!host_can_bind_publish_ip("192.0.2.1"));
assert!(!host_can_bind_publish_ip("not-an-ip"));
}
#[test]
fn partition_taints_plain_entries_that_interpolate_secrets() {
let plain = vec![
"PLAIN=1".to_string(),
"COMPOSED=${BASE}/x".to_string(),
"DB_URL=postgres://u:${DB_PASS}@db/x".to_string(),
"BASE=/srv".to_string(),
"UNKNOWN=${NOT_DEFINED}".to_string(),
];
let secrets = vec![("DB_PASS".to_string(), "s3cret".to_string())];
let (p, s) = expand_and_partition_env(plain, secrets);
// plain-from-plain expansion stays plain; unknown placeholders stay
// literal (legacy expander parity)
assert!(p.contains(&"PLAIN=1".to_string()));
assert!(p.contains(&"COMPOSED=/srv/x".to_string()));
assert!(p.contains(&"UNKNOWN=${NOT_DEFINED}".to_string()));
// the tainted entry moved out of plain, fully expanded
assert!(!p.iter().any(|e| e.starts_with("DB_URL=")));
assert!(s.contains(&("DB_URL".to_string(), "postgres://u:s3cret@db/x".to_string())));
// the original secret rides along verbatim
assert!(s.contains(&("DB_PASS".to_string(), "s3cret".to_string())));
}
#[test]
fn secret_values_are_never_expanded() {
// A generated secret containing `${` must pass through untouched.
let secrets = vec![("WEIRD".to_string(), "pa${PLAIN}ss".to_string())];
let (_, s) = expand_and_partition_env(vec!["PLAIN=1".to_string()], secrets);
assert!(s.contains(&("WEIRD".to_string(), "pa${PLAIN}ss".to_string())));
}
#[test]
fn secret_env_hash_is_order_independent() {
let a = vec![
("K1".to_string(), "v1".to_string()),
("K2".to_string(), "v2".to_string()),
];
let b = vec![
("K2".to_string(), "v2".to_string()),
("K1".to_string(), "v1".to_string()),
];
assert_eq!(secret_env_content_hash(&a), secret_env_content_hash(&b));
let c = vec![("K1".to_string(), "CHANGED".to_string())];
assert_ne!(secret_env_content_hash(&a), secret_env_content_hash(&c));
}
#[test] #[test]
fn hooks_parse_and_validate() { fn hooks_parse_and_validate() {
let yaml = r#" let yaml = r#"
@ -2003,8 +1765,6 @@ app:
generated_secrets: vec![], generated_secrets: vec![],
generated_certs: vec![], generated_certs: vec![],
data_uid: None, data_uid: None,
secret_env_refs: vec![],
secret_env_hash: None,
}; };
let facts = HostFacts { let facts = HostFacts {
host_ip: "192.168.1.116".to_string(), host_ip: "192.168.1.116".to_string(),
@ -2048,19 +1808,15 @@ app:
SecretEnv { SecretEnv {
key: "FM_BITCOIND_PASSWORD".to_string(), key: "FM_BITCOIND_PASSWORD".to_string(),
secret_file: "bitcoin-rpc-password".to_string(), secret_file: "bitcoin-rpc-password".to_string(),
optional: false,
}, },
SecretEnv { SecretEnv {
key: "FM_GATEWAY_PASSWORD".to_string(), key: "FM_GATEWAY_PASSWORD".to_string(),
secret_file: "fedimint-gateway-password".to_string(), secret_file: "fedimint-gateway-password".to_string(),
optional: false,
}, },
], ],
generated_secrets: vec![], generated_secrets: vec![],
generated_certs: vec![], generated_certs: vec![],
data_uid: None, data_uid: None,
secret_env_refs: vec![],
secret_env_hash: None,
}; };
let p = MapSecretsProvider { let p = MapSecretsProvider {
data: HashMap::from([ data: HashMap::from([
@ -2095,13 +1851,10 @@ app:
secret_env: vec![SecretEnv { secret_env: vec![SecretEnv {
key: "BITCOIN_RPC_PASS".to_string(), key: "BITCOIN_RPC_PASS".to_string(),
secret_file: "bitcoin-rpc-password".to_string(), secret_file: "bitcoin-rpc-password".to_string(),
optional: false,
}], }],
generated_secrets: vec![], generated_secrets: vec![],
generated_certs: vec![], generated_certs: vec![],
data_uid: None, data_uid: None,
secret_env_refs: vec![],
secret_env_hash: None,
}; };
let p = MapSecretsProvider { let p = MapSecretsProvider {
data: HashMap::from([("bitcoin-rpc-password".to_string(), " \n".to_string())]), data: HashMap::from([("bitcoin-rpc-password".to_string(), " \n".to_string())]),
@ -2116,51 +1869,6 @@ app:
} }
} }
#[test]
fn resolve_secret_env_skips_missing_or_empty_optional_entries() {
let c = ContainerConfig {
image: Some("x:latest".to_string()),
image_signature: None,
pull_policy: "if-not-present".to_string(),
build: None,
network: None,
network_aliases: vec![],
custom_args: vec![],
entrypoint: None,
derived_env: vec![],
secret_env: vec![
SecretEnv {
key: "REQUIRED".to_string(),
secret_file: "present".to_string(),
optional: false,
},
SecretEnv {
key: "OPT_MISSING".to_string(),
secret_file: "does-not-exist".to_string(),
optional: true,
},
SecretEnv {
key: "OPT_EMPTY".to_string(),
secret_file: "empty".to_string(),
optional: true,
},
],
generated_secrets: vec![],
generated_certs: vec![],
data_uid: None,
secret_env_refs: vec![],
secret_env_hash: None,
};
let p = MapSecretsProvider {
data: HashMap::from([
("present".to_string(), "value".to_string()),
("empty".to_string(), " \n".to_string()),
]),
};
let out = c.resolve_secret_env(&p).unwrap();
assert_eq!(out, vec!["REQUIRED=value".to_string()]);
}
#[test] #[test]
fn unsafe_manifest_values_are_rejected() { fn unsafe_manifest_values_are_rejected() {
let cases = [ let cases = [
@ -2251,35 +1959,6 @@ app:
"#, "#,
"duplicate host binding", "duplicate host binding",
), ),
(
"duplicate host port with same bind",
r#"
app:
id: bad-port-bind
name: Bad
version: 1.0.0
container:
image: test/image:latest
ports:
- { host: 8332, container: 8332, protocol: tcp, bind: 127.0.0.1 }
- { host: 8332, container: 8332, protocol: tcp, bind: 127.0.0.1 }
"#,
"duplicate host binding",
),
(
"non-IP bind address",
r#"
app:
id: bad-bind
name: Bad
version: 1.0.0
container:
image: test/image:latest
ports:
- { host: 8332, container: 8332, protocol: tcp, bind: localhost }
"#,
"bind must be an IP address",
),
( (
"bad device", "bad device",
r#" r#"
@ -2319,31 +1998,6 @@ app:
} }
} }
#[test]
fn same_host_port_with_distinct_binds_is_valid() {
// Bitcoin RPC hardening: 8332 published on loopback + the archy-net
// gateway as two mappings of the same host port.
let m = AppManifest::parse(
r#"
app:
id: bitcoin-knots
name: Bitcoin Knots
version: 1.0.0
container:
image: test/bitcoin:latest
ports:
- { host: 8332, container: 8332, protocol: tcp, bind: 127.0.0.1 }
- { host: 8332, container: 8332, protocol: tcp, bind: 10.89.0.1 }
- { host: 8333, container: 8333, protocol: tcp }
"#,
)
.expect("distinct binds on one host port must validate");
assert_eq!(m.app.ports.len(), 3);
assert_eq!(m.app.ports[0].bind, "127.0.0.1");
assert_eq!(m.app.ports[1].bind, "10.89.0.1");
assert_eq!(m.app.ports[2].bind, "");
}
#[test] #[test]
fn reviewed_host_bind_exceptions_parse() { fn reviewed_host_bind_exceptions_parse() {
let yaml = r#" let yaml = r#"

View File

@ -252,17 +252,7 @@ impl PodmanClient {
// ─── Container Operations ──────────────────────────────────── // ─── Container Operations ────────────────────────────────────
pub async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> { pub async fn pull_image(&self, image: &str, _signature: Option<&str>) -> Result<()> {
// A declared (non-placeholder) signature must verify before we fetch
// anything; placeholder/absent claims pull unverified until the
// signing ceremony ships real signatures (see image_verify).
crate::image_verify::enforce_signature_claim(
image,
signature,
image_uses_insecure_registry(image),
)
.await?;
// Image pull uses CLI — it's a streaming operation that the API handles differently // Image pull uses CLI — it's a streaming operation that the API handles differently
let mut cmd = tokio::process::Command::new("podman"); let mut cmd = tokio::process::Command::new("podman");
cmd.arg("pull"); cmd.arg("pull");
@ -291,15 +281,6 @@ impl PodmanClient {
// Build the container spec for the API // Build the container spec for the API
let mut port_mappings = Vec::new(); let mut port_mappings = Vec::new();
for port in &manifest.app.ports { for port in &manifest.app.ports {
if !crate::manifest::host_can_bind_publish_ip(&port.bind) {
tracing::warn!(
app = %manifest.app.id,
bind = %port.bind,
host_port = port.host,
"dropping publish: bind address not assignable on this host"
);
continue;
}
// Honour the manifest's protocol (default tcp). netbird's STUN port // Honour the manifest's protocol (default tcp). netbird's STUN port
// is 3478/udp; forcing tcp here would publish the wrong protocol and // is 3478/udp; forcing tcp here would publish the wrong protocol and
// silently break relay discovery. // silently break relay discovery.
@ -308,15 +289,11 @@ impl PodmanClient {
"sctp" => "sctp", "sctp" => "sctp",
_ => "tcp", _ => "tcp",
}; };
let mut mapping = serde_json::json!({ port_mappings.push(serde_json::json!({
"container_port": port.container, "container_port": port.container,
"host_port": port.host, "host_port": port.host,
"protocol": protocol, "protocol": protocol,
}); }));
if !port.bind.is_empty() {
mapping["host_ip"] = serde_json::json!(port.bind);
}
port_mappings.push(mapping);
} }
let mut mounts = Vec::new(); let mut mounts = Vec::new();
@ -395,32 +372,12 @@ impl PodmanClient {
manifest.app.security.network_policy.as_str(), manifest.app.security.network_policy.as_str(),
); );
// Secret env travels by reference (podman injects the value at
// start), so it never shows up in `podman inspect` output. The
// combined content hash rides as a label for rotation-drift checks.
let mut secret_env_map = serde_json::Map::new();
for r in &manifest.app.container.secret_env_refs {
secret_env_map.insert(
r.env_key.clone(),
serde_json::Value::String(r.secret_name.clone()),
);
}
let mut labels_map = serde_json::Map::new();
if let Some(hash) = &manifest.app.container.secret_env_hash {
labels_map.insert(
crate::manifest::SECRET_ENV_HASH_LABEL.to_string(),
serde_json::Value::String(hash.clone()),
);
}
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"name": name, "name": name,
"image": image_ref, "image": image_ref,
"portmappings": port_mappings, "portmappings": port_mappings,
"mounts": mounts, "mounts": mounts,
"env": env_map, "env": env_map,
"secret_env": secret_env_map,
"labels": labels_map,
"entrypoint": manifest.app.container.entrypoint.clone(), "entrypoint": manifest.app.container.entrypoint.clone(),
"command": manifest.app.container.custom_args.clone(), "command": manifest.app.container.custom_args.clone(),
"hostadd": [ "hostadd": [
@ -885,9 +842,8 @@ mod tests {
assert!(!image_uses_insecure_registry( assert!(!image_uses_insecure_registry(
"23.182.128.160:3000/lfg2025/filebrowser:v2.27.0" "23.182.128.160:3000/lfg2025/filebrowser:v2.27.0"
)); ));
// HTTPS registries never match the insecure list.
assert!(!image_uses_insecure_registry( assert!(!image_uses_insecure_registry(
"ghcr.io/lfg2025/bitcoin-knots:latest" "git.tx1138.com/lfg2025/bitcoin-knots:latest"
)); ));
assert!(!image_uses_insecure_registry( assert!(!image_uses_insecure_registry(
"docker.io/library/nginx:latest" "docker.io/library/nginx:latest"

View File

@ -2,6 +2,7 @@ use crate::manifest::{AppManifest, BuildConfig};
use crate::podman_client::{ContainerState, ContainerStatus, PodmanClient}; use crate::podman_client::{ContainerState, ContainerStatus, PodmanClient};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use std::process::Command;
use std::time::Duration; use std::time::Duration;
use tokio::process::Command as TokioCommand; use tokio::process::Command as TokioCommand;
@ -82,18 +83,6 @@ pub trait ContainerRuntime: Send + Sync {
/// `create_container` / `image_exists` calls. Stdout/stderr are collected /// `create_container` / `image_exists` calls. Stdout/stderr are collected
/// and included in the error on failure; on success they are discarded. /// and included in the error on failure; on success they are discarded.
async fn build_image(&self, config: &BuildConfig) -> Result<()>; async fn build_image(&self, config: &BuildConfig) -> Result<()>;
/// Register the app's resolved secret-env entries in the runtime's
/// secret store (idempotent — skips entries whose content hash already
/// matches). Called before `create_container` for manifests with
/// `secret_env_refs`, so the create can reference secrets by name and
/// the values never appear in inspect output or unit files. Default is
/// a no-op for runtimes without a secret store (mocks, docker — the
/// docker fallback injects plain env in `create_container` instead).
async fn ensure_env_secrets(&self, refs: &[crate::manifest::SecretEnvRef]) -> Result<()> {
let _ = refs;
Ok(())
}
} }
pub struct PodmanRuntime { pub struct PodmanRuntime {
@ -143,68 +132,6 @@ impl ContainerRuntime for PodmanRuntime {
self.client.pull_image(image, signature).await self.client.pull_image(image, signature).await
} }
async fn ensure_env_secrets(&self, refs: &[crate::manifest::SecretEnvRef]) -> Result<()> {
use crate::manifest::{secret_env_content_hash, SECRET_HASH_LABEL};
use tokio::io::AsyncWriteExt;
for r in refs {
let hash = secret_env_content_hash(&[(r.env_key.clone(), r.value.clone())]);
// Skip the write when the stored secret already has this content
// (label round-trip beats rewriting the secret store every
// reconcile). Any inspect failure just falls through to create.
let fmt = format!("{{{{ index .Spec.Labels \"{SECRET_HASH_LABEL}\" }}}}");
if let Ok(out) = self
.podman_cli(&["secret", "inspect", &r.secret_name, "--format", &fmt])
.await
{
if out.status.success()
&& String::from_utf8_lossy(&out.stdout).trim() == hash
{
continue;
}
}
// Value goes via stdin — never argv, never a temp file.
let mut cmd = TokioCommand::new("podman");
cmd.args([
"secret",
"create",
"--replace",
"--label",
&format!("{SECRET_HASH_LABEL}={hash}"),
&r.secret_name,
"-",
]);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
let mut child = cmd
.spawn()
.with_context(|| format!("spawning podman secret create {}", r.secret_name))?;
{
let mut stdin = child
.stdin
.take()
.context("podman secret create stdin unavailable")?;
stdin.write_all(r.value.as_bytes()).await?;
// drop closes the pipe so podman sees EOF
}
let out = tokio::time::timeout(PODMAN_CLI_DEFAULT_TIMEOUT, child.wait_with_output())
.await
.with_context(|| format!("podman secret create {} timed out", r.secret_name))??;
if !out.status.success() {
anyhow::bail!(
"podman secret create {} failed: {}",
r.secret_name,
String::from_utf8_lossy(&out.stderr)
);
}
}
Ok(())
}
async fn create_container( async fn create_container(
&self, &self,
manifest: &AppManifest, manifest: &AppManifest,
@ -572,8 +499,6 @@ fn is_missing_container_error(stderr: &str) -> bool {
let stderr = stderr.to_ascii_lowercase(); let stderr = stderr.to_ascii_lowercase();
stderr.contains("no container with name or id") stderr.contains("no container with name or id")
|| stderr.contains("no such container") || stderr.contains("no such container")
// podman 5.x `inspect` phrasing: `Error: no such object: "name"`
|| stderr.contains("no such object")
|| stderr.contains("does not exist") || stderr.contains("does not exist")
|| stderr.contains("not found") || stderr.contains("not found")
} }
@ -622,12 +547,7 @@ impl DockerRuntime {
#[async_trait] #[async_trait]
impl ContainerRuntime for DockerRuntime { impl ContainerRuntime for DockerRuntime {
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> { async fn pull_image(&self, image: &str, _signature: Option<&str>) -> Result<()> {
// Same signature gate as the podman path — the docker fallback is
// dev-only, but a declared signature must never be skippable by
// switching runtimes.
crate::image_verify::enforce_signature_claim(image, signature, false).await?;
let mut cmd = self.docker_async(); let mut cmd = self.docker_async();
cmd.arg("pull").arg(image); cmd.arg("pull").arg(image);
@ -697,12 +617,6 @@ impl ContainerRuntime for DockerRuntime {
for env in &manifest.app.environment { for env in &manifest.app.environment {
cmd.arg("-e").arg(env); cmd.arg("-e").arg(env);
} }
// Dev-only fallback: docker has no rootless secret store, so secret
// env rides as plain env here. The podman path (production) passes
// these by secret reference instead — see ensure_env_secrets.
for r in &manifest.app.container.secret_env_refs {
cmd.arg("-e").arg(format!("{}={}", r.env_key, r.value));
}
// Resource limits // Resource limits
if let Some(cpu) = manifest.app.resources.cpu_limit { if let Some(cpu) = manifest.app.resources.cpu_limit {
@ -939,11 +853,11 @@ pub struct AutoRuntime {
impl AutoRuntime { impl AutoRuntime {
pub async fn new(user: String) -> Result<Self> { pub async fn new(user: String) -> Result<Self> {
// Try Podman first // Try Podman first
if Self::check_podman_available().await { if Self::check_podman_available() {
Ok(Self { Ok(Self {
runtime: Box::new(PodmanRuntime::new(user)), runtime: Box::new(PodmanRuntime::new(user)),
}) })
} else if Self::check_docker_available().await { } else if Self::check_docker_available() {
Ok(Self { Ok(Self {
runtime: Box::new(DockerRuntime::new(user)), runtime: Box::new(DockerRuntime::new(user)),
}) })
@ -952,20 +866,12 @@ impl AutoRuntime {
} }
} }
async fn check_podman_available() -> bool { fn check_podman_available() -> bool {
TokioCommand::new("podman") Command::new("podman").arg("--version").output().is_ok()
.arg("--version")
.output()
.await
.is_ok()
} }
async fn check_docker_available() -> bool { fn check_docker_available() -> bool {
TokioCommand::new("docker") Command::new("docker").arg("--version").output().is_ok()
.arg("--version")
.output()
.await
.is_ok()
} }
} }
@ -975,10 +881,6 @@ impl ContainerRuntime for AutoRuntime {
self.runtime.pull_image(image, signature).await self.runtime.pull_image(image, signature).await
} }
async fn ensure_env_secrets(&self, refs: &[crate::manifest::SecretEnvRef]) -> Result<()> {
self.runtime.ensure_env_secrets(refs).await
}
async fn create_container( async fn create_container(
&self, &self,
manifest: &AppManifest, manifest: &AppManifest,
@ -1037,16 +939,6 @@ mod tests {
use super::*; use super::*;
use std::collections::HashMap; use std::collections::HashMap;
#[test]
fn missing_container_classifier_covers_podman5_phrasings() {
// podman 5.x `inspect` phrasing for a missing container.
assert!(is_missing_container_error(
"Error: no such object: \"mempool\""
));
assert!(is_missing_container_error("Error: no such container x"));
assert!(!is_missing_container_error("Error: OCI runtime error"));
}
fn cfg(context: &str, tag: &str, dockerfile: &str, args: &[(&str, &str)]) -> BuildConfig { fn cfg(context: &str, tag: &str, dockerfile: &str, args: &[(&str, &str)]) -> BuildConfig {
BuildConfig { BuildConfig {
context: context.to_string(), context: context.to_string(),

View File

@ -1,23 +0,0 @@
[package]
name = "archipelago-openwrt"
version = "0.1.0"
edition = "2021"
description = "OpenWrt gateway integration for Archipelago — TollGate provisioning over SSH/UCI"
[lib]
name = "archipelago_openwrt"
path = "src/lib.rs"
[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "1.0"
tracing = "0.1"
ssh2 = "0.9"
async-trait = "0.1"
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
[dev-dependencies]
tokio-test = "0.4"

View File

@ -1,72 +0,0 @@
use anyhow::Result;
use std::net::{IpAddr, SocketAddr, TcpStream};
use std::time::Duration;
use tracing::{debug, info};
use crate::Router;
const SSH_PORT: u16 = 22;
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
/// Scan a CIDR subnet and return IP addresses of OpenWrt routers.
///
/// Probes TCP/22, then verifies /etc/openwrt_release over SSH.
/// `ssh_user` and `ssh_password` are used for the verification probe only.
pub async fn scan_subnet(
subnet_base: [u8; 4],
prefix_len: u8,
ssh_user: &str,
ssh_password: &str,
) -> Vec<IpAddr> {
let host_count = host_count_for_prefix(prefix_len);
let base_u32 = u32::from_be_bytes(subnet_base);
let mask = !((1u32 << (32 - prefix_len)) - 1);
let network = base_u32 & mask;
let mut candidates = Vec::new();
for i in 1..host_count {
let ip_u32 = network + i;
let ip = IpAddr::V4(std::net::Ipv4Addr::from(ip_u32));
if tcp_reachable(ip, SSH_PORT) {
candidates.push(ip);
}
}
info!("{} hosts with TCP/22 open in /{}", candidates.len(), prefix_len);
let mut routers = Vec::new();
for ip in candidates {
match verify_openwrt(ip, ssh_user, ssh_password) {
Ok(true) => {
info!("OpenWrt detected at {}", ip);
routers.push(ip);
}
Ok(false) => debug!("{} is not OpenWrt", ip),
Err(e) => debug!("{} probe failed: {}", ip, e),
}
}
routers
}
/// Check whether a known IP is an OpenWrt router.
pub fn probe(ip: IpAddr, ssh_user: &str, ssh_password: &str) -> Result<bool> {
verify_openwrt(ip, ssh_user, ssh_password)
}
fn tcp_reachable(ip: IpAddr, port: u16) -> bool {
TcpStream::connect_timeout(&SocketAddr::new(ip, port), PROBE_TIMEOUT).is_ok()
}
fn verify_openwrt(ip: IpAddr, user: &str, password: &str) -> Result<bool> {
let router = Router::connect_password(&ip.to_string(), SSH_PORT, user, password)?;
let (out, code) = router.run("cat /etc/openwrt_release")?;
Ok(code == 0 && out.contains("OpenWrt"))
}
fn host_count_for_prefix(prefix_len: u8) -> u32 {
if prefix_len >= 32 {
return 1;
}
1u32 << (32 - prefix_len)
}

View File

@ -1,9 +0,0 @@
pub mod detect;
pub mod opkg;
pub mod router;
pub mod tollgate;
pub mod uci;
pub mod wan;
pub mod wifi_scan;
pub use router::Router;

View File

@ -1,117 +0,0 @@
use anyhow::Result;
use tracing::info;
use crate::Router;
/// Which package manager is available on this router.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PkgManager {
/// Traditional opkg (OpenWrt ≤24.x).
Opkg,
/// OpenWrt 25.x+ — apk is the native manager, opkg is not in repos.
ApkNative,
}
impl Router {
/// Detect which package manager is available.
///
/// - If `/usr/bin/opkg` exists → `PkgManager::Opkg` (nothing to do).
/// - If `/usr/bin/apk` exists → run `apk update` (switching repos to HTTP
/// first to work around missing CA bundle on fresh images), then try
/// `apk add opkg`. If opkg is in the repos → `Opkg`. If not (OpenWrt
/// 25.x) → `ApkNative`.
/// - Neither found → error.
pub fn opkg_check(&self) -> Result<PkgManager> {
let (_, code) = self.run("test -x /usr/bin/opkg")?;
if code == 0 {
return Ok(PkgManager::Opkg);
}
let (_, apk_code) = self.run("test -x /usr/bin/apk")?;
if apk_code == 0 {
info!("[{}] opkg not found — using apk (OpenWrt 25.x+)", self.host);
// Fresh images ship without a CA bundle; switch repos to HTTP so
// apk's wget can reach the package index without TLS verification.
self.run_ok("sed -i 's|https://|http://|g' /etc/apk/repositories 2>/dev/null || true")?;
let (update_out, update_code) = self.run("/usr/bin/apk update 2>&1")?;
if update_code != 0 {
anyhow::bail!(
"apk update failed (exit {}) — router may have no internet access. \
Ensure WAN/internet is working on the router before provisioning.\n{}",
update_code,
update_out.trim()
);
}
// Try to install opkg (only available on some 25.x builds).
let (add_out, add_code) = self.run("/usr/bin/apk add opkg 2>&1")?;
if add_code == 0 {
return Ok(PkgManager::Opkg);
}
if add_out.contains("no such package") || add_out.contains("unable to select") {
info!("[{}] opkg not in apk repos — staying in apk-native mode", self.host);
return Ok(PkgManager::ApkNative);
}
anyhow::bail!("apk add opkg failed (exit {}): {}", add_code, add_out.trim());
}
anyhow::bail!(
"opkg not found at /usr/bin/opkg — this router's firmware may not \
support package management (TollGate requires a standard OpenWrt build)"
);
}
/// `opkg update` — refresh package lists.
pub fn opkg_update(&self) -> Result<()> {
info!("[{}] opkg update", self.host);
self.run_ok("/usr/bin/opkg update")?;
Ok(())
}
/// Install a package, skipping if already installed.
pub fn opkg_install(&self, package: &str) -> Result<()> {
// Check if already installed to avoid unnecessary network traffic.
let (_, code) = self.run(&format!("/usr/bin/opkg list-installed | grep -q '^{} '", package))?;
if code == 0 {
info!("[{}] {} already installed", self.host, package);
return Ok(());
}
info!("[{}] opkg install {}", self.host, package);
self.run_ok(&format!("/usr/bin/opkg install {}", package))?;
Ok(())
}
/// Remove a package.
pub fn opkg_remove(&self, package: &str) -> Result<()> {
info!("[{}] opkg remove {}", self.host, package);
self.run_ok(&format!("/usr/bin/opkg remove {}", package))?;
Ok(())
}
/// Install a standard OpenWrt package via whichever manager is active.
///
/// Unlike `tollgate-module-basic-go` itself (which falls back to manual
/// `.ipk` extraction and therefore skips dependency resolution — see
/// `tollgate::install`), packages like `nodogsplash` are in every
/// upstream OpenWrt feed, so a plain `apk add` / `opkg install` works
/// even in `ApkNative` mode.
pub fn install_package(&self, pkg_mgr: PkgManager, package: &str) -> Result<()> {
match pkg_mgr {
PkgManager::Opkg => self.opkg_install(package),
PkgManager::ApkNative => self.apk_install(package),
}
}
/// `apk add <package>`, skipping if already installed.
pub fn apk_install(&self, package: &str) -> Result<()> {
let (_, code) = self.run(&format!("apk info -e {} >/dev/null 2>&1", package))?;
if code == 0 {
info!("[{}] {} already installed", self.host, package);
return Ok(());
}
info!("[{}] apk add {}", self.host, package);
self.run_ok(&format!("/usr/bin/apk add {}", package))?;
Ok(())
}
}

View File

@ -1,105 +0,0 @@
use anyhow::{Context, Result};
use ssh2::Session;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::path::Path;
use tracing::debug;
/// An active SSH connection to an OpenWrt router.
pub struct Router {
pub host: String,
pub port: u16,
session: Session,
}
impl Router {
/// Connect to an OpenWrt router via SSH using a private key.
pub fn connect(host: &str, port: u16, user: &str, key_path: &Path) -> Result<Self> {
let addr = format!("{}:{}", host, port);
let tcp = TcpStream::connect(&addr)
.with_context(|| format!("TCP connect to {}", addr))?;
let mut session = Session::new().context("create SSH session")?;
session.set_tcp_stream(tcp);
session.handshake().context("SSH handshake")?;
session
.userauth_pubkey_file(user, None, key_path, None)
.with_context(|| format!("SSH auth as {} with key {:?}", user, key_path))?;
Ok(Self {
host: host.to_string(),
port,
session,
})
}
/// Connect using a password (fallback for routers not yet provisioned with a key).
pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
let addr = format!("{}:{}", host, port);
let tcp = TcpStream::connect(&addr)
.with_context(|| format!("TCP connect to {}", addr))?;
let mut session = Session::new().context("create SSH session")?;
session.set_tcp_stream(tcp);
session.handshake().context("SSH handshake")?;
session
.userauth_password(user, password)
.with_context(|| format!("SSH password auth as {}", user))?;
Ok(Self {
host: host.to_string(),
port,
session,
})
}
/// Run a command and return (stdout, exit_code).
pub fn run(&self, cmd: &str) -> Result<(String, i32)> {
debug!("ssh [{}] $ {}", self.host, cmd);
let mut channel = self.session.channel_session().context("open channel")?;
channel.exec(cmd).with_context(|| format!("exec: {}", cmd))?;
let mut stdout = String::new();
channel.read_to_string(&mut stdout).context("read stdout")?;
channel.wait_close().context("wait close")?;
let exit = channel.exit_status().context("exit status")?;
Ok((stdout, exit))
}
/// Run a command, fail if exit code is non-zero.
pub fn run_ok(&self, cmd: &str) -> Result<String> {
let (out, code) = self.run(cmd)?;
if code != 0 {
anyhow::bail!("command `{}` exited with code {}: {}", cmd, code, out.trim());
}
Ok(out)
}
/// Verify the remote device is actually running OpenWrt.
pub fn verify_openwrt(&self) -> Result<String> {
let release = self
.run_ok("cat /etc/openwrt_release")
.context("read /etc/openwrt_release — is this an OpenWrt device?")?;
Ok(release)
}
/// Upload file contents to the router over SCP, overwriting any existing
/// file at `remote_path`. Used for config files that aren't UCI-backed
/// (e.g. `/etc/tollgate/config.json`), where `uci_*` helpers don't apply.
pub fn upload_file(&self, remote_path: &str, contents: &[u8]) -> Result<()> {
let mut channel = self
.session
.scp_send(Path::new(remote_path), 0o644, contents.len() as u64, None)
.with_context(|| format!("scp_send to {}", remote_path))?;
channel
.write_all(contents)
.with_context(|| format!("write contents to {}", remote_path))?;
channel.send_eof().context("scp send_eof")?;
channel.wait_eof().context("scp wait_eof")?;
channel.close().context("scp close")?;
channel.wait_close().context("scp wait_close")?;
Ok(())
}
}

View File

@ -1,99 +0,0 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::Router;
/// TollGate provisioning parameters.
///
/// `mint_url` must be the externally-reachable URL of the Archy Cashu mint —
/// TollGate customers connect from outside the Archy node's loopback, so
/// localhost URLs will not work. Resolve this from the running mint app before
/// calling `provision`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TollGateConfig {
/// SSID name for the pay-as-you-go network.
pub ssid: String,
/// Externally-reachable URL of the Archy Cashu mint.
pub mint_url: String,
/// Price in satoshis per `step_size` interval.
pub price_sats: u64,
/// Step size in milliseconds (default: 60000 = 1 minute).
pub step_size_ms: u64,
/// Minimum steps a customer must purchase at once.
pub min_steps: u32,
/// Whether the TollGate service should be running and enabled at boot.
pub enabled: bool,
}
impl Default for TollGateConfig {
fn default() -> Self {
Self {
ssid: "archipelago".to_string(),
mint_url: String::new(), // must be set by caller from the running mint app
price_sats: 10,
step_size_ms: 60_000,
min_steps: 1,
enabled: true,
}
}
}
/// Write TollGate UCI configuration and commit.
///
/// `tollgate-wrt` never reads UCI — see `apply_daemon_config` below for the
/// config it actually consumes. These `tollgate.main.*` keys exist only for
/// this project's own status display / detection probes (`uci get
/// tollgate.main.enabled` etc.); changing pricing or the mint here has no
/// effect on what the daemon advertises or accepts.
pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> {
router.uci_apply(
"tollgate",
&[
("tollgate.main", "tollgate"),
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
("tollgate.main.metric", "milliseconds"),
("tollgate.main.step_size", &cfg.step_size_ms.to_string()),
("tollgate.main.min_steps", &cfg.min_steps.to_string()),
("tollgate.main.price_per_step", &cfg.price_sats.to_string()),
("tollgate.main.currency", "sat"),
("tollgate.main.mint_url", &cfg.mint_url),
],
)?;
Ok(())
}
/// Write the config `tollgate-wrt` actually reads: `/etc/tollgate/config.json`
/// (schema `v0.0.6`/`v0.0.7`, see `config_manager` in the upstream Go source).
///
/// Merges into whatever config.json already exists (the daemon writes a
/// default on first boot) rather than overwriting it wholesale — fields this
/// project doesn't manage (`profit_share`, `upstream_detector`,
/// `upstream_session_manager`/`chandler`, `relays`, ...) must survive
/// re-provisioning.
///
/// Must run before the daemon is (re)started — it only reads this file at
/// startup, it does not hot-reload.
pub fn apply_daemon_config(router: &Router, cfg: &TollGateConfig) -> Result<()> {
let existing = router.run_ok("cat /etc/tollgate/config.json 2>/dev/null || echo '{}'")?;
let mut doc: serde_json::Value =
serde_json::from_str(existing.trim()).unwrap_or_else(|_| serde_json::json!({}));
doc["metric"] = serde_json::json!("milliseconds");
doc["step_size"] = serde_json::json!(cfg.step_size_ms);
doc["accepted_mints"] = serde_json::json!([{
"url": cfg.mint_url,
"min_balance": 64,
"balance_tolerance_percent": 10,
"payout_interval_seconds": 60,
"min_payout_amount": 128,
"price_per_step": cfg.price_sats,
"price_unit": "sats",
"purchase_min_steps": cfg.min_steps,
}]);
let json_str = serde_json::to_string_pretty(&doc).context("serialize config.json")?;
router
.upload_file("/etc/tollgate/config.json", json_str.as_bytes())
.context("upload /etc/tollgate/config.json")?;
Ok(())
}

View File

@ -1,254 +0,0 @@
use anyhow::Result;
use tracing::info;
use crate::Router;
/// The OpenWrt package name for the TollGate reference implementation.
const TOLLGATE_PACKAGE: &str = "tollgate-module-basic-go";
/// Direct-download fallback URLs by opkg architecture string.
/// Used when the package is not in any configured feed.
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.2.0
fn ipk_url(arch: &str) -> Option<&'static str> {
match arch {
"mips_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mips_24kc.ipk"),
"mipsel_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mipsel_24kc.ipk"),
"aarch64_cortex-a53" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a53.ipk"),
"aarch64_cortex-a72" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a72.ipk"),
"arm_cortex-a7" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/arm_cortex-a7.ipk"),
_ => None,
}
}
/// Install tollgate-module-basic-go via opkg (OpenWrt ≤24.x).
///
/// Tries opkg first (works if a custom feed is configured). Falls back to
/// downloading the .ipk directly from GitHub releases if opkg can't find it.
/// Caller is responsible for running `opkg_update` first.
pub fn install_tollgate(router: &Router) -> Result<()> {
info!("[{}] Installing {}", router.host, TOLLGATE_PACKAGE);
// Fast path: standard opkg install (or already installed).
if router.opkg_install(TOLLGATE_PACKAGE).is_ok() {
return Ok(());
}
// Package not in any feed — download the .ipk directly.
let arch = router
.run_ok("/usr/bin/opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'")?;
let arch = arch.trim();
let url = ipk_url(arch).ok_or_else(|| {
anyhow::anyhow!(
"No pre-built TollGate package for architecture '{}'. \
Add a custom opkg feed or build from source.",
arch
)
})?;
info!("[{}] Downloading TollGate for {} from GitHub releases", router.host, arch);
router.run_ok(&format!("wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1", url))?;
install_ipk(router, "/tmp/tollgate.ipk")
}
/// Install tollgate-module-basic-go on OpenWrt 25.x where opkg is not available.
///
/// Downloads the .ipk from GitHub releases and extracts it manually using
/// BusyBox `ar` and `tar` (both present on all OpenWrt images).
pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
info!("[{}] Installing {} (apk-native mode)", router.host, TOLLGATE_PACKAGE);
// Already installed? The service binary is /usr/bin/tollgate-wrt (per its
// init.d script) — TOLLGATE_PACKAGE is only the opkg/apk package name,
// never an on-disk filename, so it can't be used for the file-existence
// fallback below.
let (_, code) = router.run(&format!(
"apk list --installed 2>/dev/null | grep -q '^{}' || \
test -f /usr/bin/tollgate-wrt 2>/dev/null",
TOLLGATE_PACKAGE
))?;
if code == 0 {
info!("[{}] {} already installed", router.host, TOLLGATE_PACKAGE);
return Ok(());
}
// Get architecture from /etc/openwrt_release.
// The variable is DISTRIB_ARCH on most builds; OPENWRT_ARCH on some.
// Fall back to apk --print-arch, then uname -m.
let arch_raw = router.run_ok(
". /etc/openwrt_release 2>/dev/null \
&& a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \
&& [ -n \"$a\" ] && echo \"$a\" \
|| /usr/bin/apk --print-arch 2>/dev/null \
|| uname -m"
)?;
// Normalise: uname -m returns bare "mipsel"/"mips"; map to 24kc variant
// which is the standard for home-router MIPS builds.
let arch = match arch_raw.trim() {
"mipsel" => "mipsel_24kc",
"mips" => "mips_24kc",
other => other,
};
info!("[{}] detected arch: {:?}", router.host, arch);
if arch.is_empty() {
anyhow::bail!("Could not determine router architecture");
}
let url = ipk_url(arch).ok_or_else(|| {
anyhow::anyhow!(
"No pre-built TollGate package for architecture '{}'. \
Add a custom feed or build from source.",
arch
)
})?;
info!("[{}] Downloading TollGate for {} from GitHub releases", router.host, arch);
// --no-check-certificate: fresh OpenWrt 25.x images ship without a CA bundle;
// GitHub serves releases over HTTPS so wget would otherwise reject the cert.
let (dl_out, dl_code) = router.run(&format!(
"wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1", url
))?;
if dl_code != 0 {
anyhow::bail!("TollGate download failed: {}", dl_out.trim());
}
// Sanity-check: a real .ipk is at least 50 KB.
// If wget captured an HTML error page it will be tiny.
let (size_out, _) = router.run("wc -c < /tmp/tollgate.ipk 2>/dev/null")?;
let size: u64 = size_out.trim().parse().unwrap_or(0);
if size < 50_000 {
anyhow::bail!(
"Downloaded TollGate package is only {}B — wget likely captured an error page. \
Check router internet access and that the release URL is reachable.",
size
);
}
install_ipk(router, "/tmp/tollgate.ipk")
}
/// Extract and install an .ipk file without opkg.
///
/// An .ipk is an `ar` archive containing `data.tar.gz` (package files) and
/// `control.tar.gz` (metadata + postinst script).
fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
// Check for disk space first (rough: need at least ~1 MB free on /overlay).
// TollGate is a Go binary — typically 58 MB on flash.
let (df_out, _) = router.run("df /overlay 2>/dev/null | awk 'NR==2{print $4}'")?;
let free_kb: u64 = df_out.trim().parse().unwrap_or(u64::MAX);
if free_kb < 5120 {
anyhow::bail!(
"Not enough flash space for TollGate: only {}kB free on /overlay \
(need 5MB). Free up space first or use a router with more storage.",
free_kb
);
}
router.run_ok("rm -rf /tmp/_tg_install && mkdir -p /tmp/_tg_install")?;
// OpenWrt 25.x BusyBox does not include `ar` — install binutils via
// whichever package manager is available before trying to unpack the ipk.
let (_, ar_found) = router.run("command -v ar >/dev/null 2>&1")?;
if ar_found != 0 {
info!("[{}] ar not found, installing binutils", router.host);
let (pkg_out, pkg_code) = router.run(
"apk add binutils 2>&1 || opkg install binutils 2>&1"
)?;
if pkg_code != 0 {
anyhow::bail!(
"TollGate installation failed: ar not available and binutils install failed: {}",
pkg_out.trim()
);
}
}
// Try standard opkg ar format first (ar archive → data.tar.gz inside).
let (ar_out, ar_code) = router.run(&format!(
"cd /tmp/_tg_install && ar x {} 2>&1", ipk_path
))?;
if ar_code != 0 {
// Fallback: some builds produce the .ipk as a gzip tarball rather than
// a classic `ar` archive. This can still contain the same three ipk
// members (debian-binary/data.tar.gz/control.tar.gz) one level deep —
// just gzip-tarred together instead of ar'd — or, less commonly, a
// flat tarball of the real package files with no ipk structure at
// all. Extract to the scratch dir and check which shape it is before
// deciding how to install it.
info!("[{}] ar failed ({}), trying tar -xzf", router.host, ar_out.trim());
// List contents first — validates format without writing anything.
let (list_out, list_code) = router.run(&format!(
"tar -tzf {} 2>&1 | head -30", ipk_path
))?;
if list_code != 0 {
anyhow::bail!(
"TollGate installation failed: file is not an ar archive or gzip tar.\n\
ar: {}\ntar -t: {}",
ar_out.trim(), list_out.trim()
);
}
info!("[{}] ipk contents:\n{}", router.host, list_out.trim());
router.run_ok(&format!("tar -xzf {} -C /tmp/_tg_install 2>&1", ipk_path))?;
let (_, nested) = router.run("test -f /tmp/_tg_install/data.tar.gz")?;
if nested != 0 {
// Genuinely flat tarball, no ipk structure — its contents are the
// real package files, already unpacked into the scratch dir.
let (ov_df, _) = router.run("df / 2>/dev/null | awk 'NR==2{print $4}'")?;
let overlay_free_kb: u64 = ov_df.trim().parse().unwrap_or(0);
if overlay_free_kb < 5120 {
anyhow::bail!(
"Not enough space to install TollGate: only {}kB free on /. \
Need at least 5MB. Free up flash space on the router first \
(e.g. remove unused packages with `apk del `).",
overlay_free_kb
);
}
let (cp_out, cp_code) = router.run("cp -a /tmp/_tg_install/. / 2>&1")?;
if cp_code != 0 {
anyhow::bail!("TollGate installation failed: file copy failed: {}", cp_out.trim());
}
// No package-manager postinst ran for these files either — see
// the uci-defaults note below.
router.run_ok(
"for f in /etc/uci-defaults/*; do \
[ -f \"$f\" ] && ( cd \"$(dirname \"$f\")\" && . \"$f\" ) && rm -f \"$f\"; \
done; uci commit 2>/dev/null; true"
)?;
router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?;
return Ok(());
}
// Nested ipk-member layout — fall through to the shared unpack below.
}
// Unpack data.tar.gz (the real payload) from either the `ar`-extracted or
// gzip-tar-extracted scratch dir, then run control.tar.gz's postinst.
let (tar_out, tar_code) = router.run(
"tar -xzf /tmp/_tg_install/data.tar.gz -C / 2>&1"
)?;
if tar_code != 0 {
anyhow::bail!("TollGate installation failed: data extract failed: {}", tar_out.trim());
}
// Run postinst if present (optional — failures are non-fatal).
router.run_ok(
"if tar -xzf /tmp/_tg_install/control.tar.gz -C /tmp/_tg_install 2>/dev/null; then \
chmod +x /tmp/_tg_install/postinst 2>/dev/null; \
/tmp/_tg_install/postinst configure 2>/dev/null || true; \
fi"
)?;
// `default_postinst` (what most packages' postinst calls, including
// this one) only runs pending /etc/uci-defaults/* scripts for packages
// it finds in opkg/apk's own file-list records. Since these files were
// extracted manually rather than through a real package-manager install,
// no such record exists, so run any pending scripts directly — this is
// exactly what opkg's install path (or the next reboot) would otherwise
// do for them, just without waiting for either.
router.run_ok(
"for f in /etc/uci-defaults/*; do \
[ -f \"$f\" ] && ( cd \"$(dirname \"$f\")\" && . \"$f\" ) && rm -f \"$f\"; \
done; uci commit 2>/dev/null; true"
)?;
router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?;
Ok(())
}

View File

@ -1,121 +0,0 @@
pub mod config;
pub mod install;
pub mod nodogsplash;
pub mod wifi;
pub use config::TollGateConfig;
pub use install::install_tollgate;
pub use wifi::provision_ssid;
use anyhow::{Context, Result};
use tracing::info;
use crate::{opkg::PkgManager, Router};
/// Full TollGate provisioning sequence:
/// 1. Install tollgate-module-basic-go
/// 2. Install NoDogSplash and immediately stop it (its postinst auto-starts
/// it against `br-lan` by default — see `nodogsplash::install_and_stop`)
/// 3. Write TollGate config: UCI (status/detection only) + the JSON file the
/// daemon actually reads
/// 4. Create the pay-as-you-go WiFi SSID and its dedicated bridge/network
/// 5. Configure NoDogSplash to gate that bridge (now that it exists) —
/// client gating; tollgate-wrt has no enforcement code of its own
/// 6. Restart affected services
pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
info!("[{}] Starting TollGate provisioning", router.host);
let pkg_mgr = router.opkg_check()?;
match pkg_mgr {
PkgManager::Opkg => {
router.opkg_update()?;
install_tollgate(router)?;
}
PkgManager::ApkNative => {
install::install_tollgate_apk_native(router)?;
}
}
// NoDogSplash is a hard runtime dependency of tollgate-wrt (upstream's
// package declares `+nodogsplash`), but neither install path above pulls
// it in: the opkg fast path only resolves deps against a real feed, and
// the raw .ipk-extraction fallback (used whenever the package isn't in a
// feed, and always on ApkNative) skips dependency resolution entirely.
// Without it, tollgate-wrt runs and accepts payments but never actually
// blocks unpaid clients. Install + stop happens before anything else so
// its auto-started default config (gating br-lan) is live for as little
// time as possible.
nodogsplash::install_and_stop(router, pkg_mgr)
.context("install nodogsplash — tollgate-wrt cannot gate clients without it")?;
// Wire NoDogSplash's webroot to TollGate's actual payment portal instead
// of the generic stock splash page it ships with. Confirmed live: without
// this, "click continue" on the stock page authorizes the client via
// NoDogSplash's own built-in handler with zero payment involved.
nodogsplash::install_captive_portal_symlink(router).context(
"wire up TollGate's captive portal — without it NoDogSplash serves its own \
generic splash page, which authorizes clients on click with no payment",
)?;
config::apply(router, config)?;
wifi::provision_ssid(router, config)?;
// Must come after provision_ssid (which creates br-tollgate) and before
// the daemon restart below — config.json is only read at startup.
config::apply_daemon_config(router, config)
.context("write /etc/tollgate/config.json — tollgate-wrt reads this, not UCI")?;
// Also must come after provision_ssid: points gatewayinterface at
// br-tollgate, which provision_ssid is what creates.
nodogsplash::configure(router, config)
.context("configure nodogsplash — tollgate-wrt cannot gate clients without it")?;
restart_services(router, config.enabled)?;
nodogsplash::restart(router)?;
info!("[{}] TollGate provisioning complete", router.host);
Ok(())
}
/// Applies `enabled` to the actual running service, not just the UCI value —
/// the tollgate-wrt init script doesn't consult `tollgate.main.enabled`
/// itself, so toggling it requires an explicit enable/start or disable/stop.
///
/// The service's init script is `/etc/init.d/tollgate-wrt` (its actual
/// on-disk name — "tollgate" alone does not exist).
fn restart_services(router: &Router, enabled: bool) -> Result<()> {
if enabled {
router.run_ok("/etc/init.d/tollgate-wrt enable")?;
router.run_ok(
"/etc/init.d/tollgate-wrt restart || /etc/init.d/tollgate-wrt start"
)?;
} else {
router.run_ok("/etc/init.d/tollgate-wrt stop || true")?;
router.run_ok("/etc/init.d/tollgate-wrt disable || true")?;
}
router.run_ok("/etc/init.d/network restart")?;
// Reload wireless so wireless.tollgate.disabled takes effect on the radio —
// `network restart` alone doesn't reliably reconfigure wifi interfaces.
router.run_ok("wifi down 2>&1; wifi up 2>&1")?;
// Observed live, twice, in two different ways: netifd can lose the race
// to claim br-tollgate as the wifi vif attaches to it during the restart
// above. The first time it showed up as netifd reporting
// "up: false, DEVICE_CLAIM_FAILED"; the second time netifd reported the
// interface up with its address assigned while the kernel-level device
// genuinely had none (`ip -4 addr show br-tollgate` empty) — dnsmasq
// logged "DHCP packet received on br-tollgate which has no address" and
// silently dropped every DISCOVER. A single blind ifdown/ifup isn't
// trustworthy here — verify the address actually landed at the kernel
// level (not just what netifd claims) and retry the cycle if not, since
// NoDogSplash refuses to start against an interface that isn't really up
// and dnsmasq will silently refuse to answer DHCP without erroring loudly.
router.run_ok(
"sleep 2; \
for i in 1 2 3 4 5; do \
ifdown tollgate 2>&1; sleep 1; ifup tollgate 2>&1; sleep 2; \
ip -4 addr show br-tollgate 2>/dev/null | grep -q 'inet ' && break; \
echo \"br-tollgate has no kernel-level IPv4 address after cycle $i, retrying\"; \
done; \
ip -4 addr show br-tollgate 2>/dev/null | grep -q 'inet ' || \
{ echo 'br-tollgate never got a kernel-level IPv4 address after 5 cycles'; exit 1; }"
)?;
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More