Compare commits
No commits in common. "main" and "feat/mesh-archy-command" have entirely different histories.
main
...
feat/mesh-
24
CLAUDE.md
24
CLAUDE.md
@ -23,31 +23,9 @@ Detailed sub-plans (all linked from the master):
|
||||
- App platform / packaging phases + security model → `docs/APP-PACKAGING-MIGRATION-PLAN.md`
|
||||
- Registry-distributed manifests (in progress) → `docs/registry-manifest-design.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`
|
||||
|
||||
## 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)
|
||||
|
||||
- **Rootless Podman only.** No rootful, no Docker-socket mounts, no privileged
|
||||
|
||||
@ -8,9 +8,9 @@ Be respectful. We follow the [Contributor Covenant](https://www.contributor-cove
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository on the project's Gitea instance
|
||||
2. Clone your fork: `git clone <your-fork-url>/archy.git`
|
||||
3. Set up the dev environment (see `docs/developer-guide.md`)
|
||||
1. Fork the repository
|
||||
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/archy.git`
|
||||
3. Set up the dev environment (see `docs/development-setup.md`)
|
||||
4. Create a feature branch: `git checkout -b feature/your-feature`
|
||||
|
||||
## Development Setup
|
||||
|
||||
@ -122,7 +122,7 @@ echo ""
|
||||
# Install 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
|
||||
echo " - Installing $app dependencies..."
|
||||
cd "apps/$app"
|
||||
@ -161,6 +161,6 @@ echo " http://localhost:8100"
|
||||
echo ""
|
||||
echo "For more information, see:"
|
||||
echo " - README.md"
|
||||
echo " - docs/developer-guide.md"
|
||||
echo " - docs/development-setup.md"
|
||||
echo " - apps/QUICKSTART.md"
|
||||
echo ""
|
||||
|
||||
151
README.md
151
README.md
@ -2,99 +2,71 @@
|
||||
|
||||
> 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.
|
||||
|
||||
[](https://www.debian.org/)
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[]()
|
||||
|
||||
## 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
|
||||
|
||||
### 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
|
||||
- **LND** and **Core Lightning** with channel management
|
||||
- **Bitcoin Knots** full node with pruning support
|
||||
- **LND** Lightning Network daemon with channel management
|
||||
- **ElectrumX** Electrum server for wallet connectivity
|
||||
- **BTCPay Server** for accepting Bitcoin payments
|
||||
- **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+)
|
||||
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.
|
||||
|
||||
### 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
|
||||
### Self-Hosted Apps (29)
|
||||
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.
|
||||
|
||||
### Decentralized Identity
|
||||
- Ed25519 node identity with DID Documents (did:key)
|
||||
- Multi-identity management (Personal/Business/Anonymous)
|
||||
- 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) record sync between federated nodes over Tor
|
||||
- Decentralized Web Node (DWN) with bidirectional sync over Tor
|
||||
- Nostr relay integration and NIP-07 signing for iframe apps
|
||||
|
||||
### Multi-Node Federation
|
||||
- Invite-based node joining over Tor hidden services
|
||||
- Trust levels (Trusted/Verified/Untrusted) with DID-based auth
|
||||
- State sync and app deployment across federated nodes
|
||||
- File sharing with access controls (free/peers-only/paid via Lightning, on-chain, or ecash)
|
||||
- Bidirectional DWN state sync between federated nodes
|
||||
- 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
|
||||
- OTA updates from a self-hosted Gitea release server, Ed25519-signature-verified against a pinned release-root key
|
||||
- Resumable downloads, automatic pre-update backup, rollback with a post-update self-verify window
|
||||
- Manual, scheduled-check, and auto-apply modes (auto-apply refuses unsigned manifests)
|
||||
- OTA updates from self-hosted Gitea (git.tx1138.com) with SHA256 verification
|
||||
- Three update modes: Manual, Daily Check, Auto Apply (3 AM window)
|
||||
- Rollback support with automatic backup before applying
|
||||
- Full UI for update management in Settings
|
||||
|
||||
### Security
|
||||
- Argon2id password hashing (transparent upgrade from legacy hashes), ChaCha20-Poly1305 encrypted secrets at rest
|
||||
- Rootless Podman: read-only root, cap-drop ALL with a reviewed allow-list, no-new-privileges
|
||||
- Signed release manifests and signed app catalog (Ed25519, pinned trust anchor)
|
||||
- TOTP two-factor authentication, per-endpoint rate limiting, CSRF protection
|
||||
- AppArmor profiles for container confinement; Tor hidden services for inter-node traffic
|
||||
- Independent security audit of an early version archived in [`docs/archive/`](docs/archive/security-code-audit-2026-03.md); top findings since remediated
|
||||
|
||||
## Roadmap
|
||||
|
||||
**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).
|
||||
- ChaCha20-Poly1305 encrypted secrets at rest, Argon2id password hashing
|
||||
- Rootless Podman: read-only root, cap-drop ALL, non-root user, no-new-privileges
|
||||
- TOTP two-factor authentication
|
||||
- Per-endpoint rate limiting, CSRF protection, input validation
|
||||
- AppArmor profiles for container confinement
|
||||
- Tor hidden services for all inter-node communication
|
||||
- All crypto and container dependencies pinned to exact versions
|
||||
- Full penetration test completed (33 findings, all remediated)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 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`
|
||||
3. Boot from USB on target hardware and follow the automated installer
|
||||
4. Access the web UI at `http://<device-ip>`
|
||||
5. Set your password and complete the onboarding wizard (seed backup, DID identity)
|
||||
3. Boot from USB on target hardware
|
||||
4. Follow the automated installer
|
||||
5. Access the web UI at `http://<device-ip>`
|
||||
6. Set your password and start the onboarding wizard
|
||||
|
||||
### 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 |
|
||||
| **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
|
||||
|
||||
@ -122,15 +94,7 @@ npm run type-check # TypeScript validation
|
||||
npm run build # Production build → web/dist/neode-ui/
|
||||
```
|
||||
|
||||
### Backend Development
|
||||
|
||||
```bash
|
||||
cd core # Rust workspace root (no Cargo.toml at repo root)
|
||||
cargo build
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Deploy to a Test Node
|
||||
### Deploy to Server
|
||||
|
||||
```bash
|
||||
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
|
||||
@ -140,57 +104,54 @@ cargo test
|
||||
### Release (tarball-only)
|
||||
|
||||
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
|
||||
./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
|
||||
|
||||
```
|
||||
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)
|
||||
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678, ~380 RPC methods)
|
||||
│ ├── core/archipelago/ — API, orchestrator + reconciler, mesh, identity,
|
||||
│ │ federation, wallet, updates, marketplace
|
||||
│ ├── core/container/ — Podman client, manifest schema, Quadlet compiler,
|
||||
│ │ health monitor, signed app catalog
|
||||
│ ├── core/security/ — AppArmor/seccomp policy, secrets manager
|
||||
│ ├── 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)
|
||||
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678)
|
||||
│ ├── core/archipelago/ — RPC endpoints, auth, identity, federation, mesh
|
||||
│ ├── core/container/ — PodmanClient (REST API socket), manifests, health
|
||||
│ ├── core/security/ — AppArmor, secrets, Cosign image verification
|
||||
│ └── 6 more crates — models, helpers, js-engine, performance, etc.
|
||||
├── Vue 3 Frontend (Composition API + TypeScript strict + Pinia + Tailwind)
|
||||
└── 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
|
||||
|
||||
| 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 |
|
||||
| [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 Manifest Spec](docs/app-manifest-spec.md) | The `manifest.yml` schema |
|
||||
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide |
|
||||
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions |
|
||||
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery |
|
||||
| [Production Master Plan](docs/PRODUCTION-MASTER-PLAN.md) | North star and workstream narrative |
|
||||
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Live, priority-ordered open items |
|
||||
| [Test Gate](tests/lifecycle/TESTING.md) | Production lifecycle test gate (definition of done) |
|
||||
| [Archive](docs/archive/) | Historical audits, session logs, shipped designs |
|
||||
| [Security Audit](docs/security-code-audit-2026-03.md) | Penetration test findings |
|
||||
| [Master Plan](docs/MASTER_PLAN.md) | Phased roadmap and task tracking |
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
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
|
||||
|
||||
## License
|
||||
@ -199,4 +160,4 @@ Debian 13 (Trixie)
|
||||
|
||||
## 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/)
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
app:
|
||||
id: archy-btcpay-db
|
||||
name: BTCPay Postgres
|
||||
version: "15.17"
|
||||
version: 15.17
|
||||
description: Postgres backend for BTCPay and NBXplorer.
|
||||
|
||||
container:
|
||||
image: 146.59.87.168:3000/lfg2025/postgres:15.17
|
||||
image: git.tx1138.com/lfg2025/postgres:15.17
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
data_uid: "100998:100998"
|
||||
|
||||
@ -5,7 +5,7 @@ app:
|
||||
description: MariaDB backend for the mempool explorer stack.
|
||||
|
||||
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
|
||||
network: archy-net
|
||||
data_uid: "100998:100998"
|
||||
|
||||
@ -5,7 +5,7 @@ app:
|
||||
description: BTCPay blockchain indexer service.
|
||||
|
||||
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
|
||||
network: archy-net
|
||||
secret_env:
|
||||
|
||||
@ -17,13 +17,6 @@ app:
|
||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||
# 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)";
|
||||
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";
|
||||
fi;
|
||||
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
|
||||
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
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
@ -71,17 +64,9 @@ app:
|
||||
network_policy: isolated
|
||||
|
||||
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
|
||||
container: 8332
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
- host: 8333
|
||||
container: 8333
|
||||
protocol: tcp
|
||||
|
||||
@ -17,13 +17,6 @@ app:
|
||||
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||
# 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)";
|
||||
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";
|
||||
fi;
|
||||
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
|
||||
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
|
||||
derived_env:
|
||||
- key: DISK_GB
|
||||
@ -71,17 +64,9 @@ app:
|
||||
network_policy: isolated
|
||||
|
||||
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
|
||||
container: 8332
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
- host: 8333
|
||||
container: 8333
|
||||
protocol: tcp
|
||||
|
||||
@ -27,7 +27,7 @@ app:
|
||||
apparmor_profile: did-wallet
|
||||
|
||||
ports:
|
||||
- host: 8088
|
||||
- host: 8083
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
|
||||
@ -42,7 +42,7 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8080
|
||||
endpoint: http://localhost:8083
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@ -5,7 +5,7 @@ app:
|
||||
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
|
||||
|
||||
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
|
||||
network: archy-net
|
||||
entrypoint: ["sh", "-lc"]
|
||||
|
||||
@ -5,7 +5,7 @@ app:
|
||||
description: Baseline Archipelago file manager service.
|
||||
|
||||
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
|
||||
network: archy-net
|
||||
custom_args: ["--config", "/data/.filebrowser.json"]
|
||||
|
||||
@ -30,13 +30,7 @@ app:
|
||||
disk_limit: 200Gi
|
||||
|
||||
security:
|
||||
# Runs as container root over a data tree the legacy installer chowned
|
||||
# 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]
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
network_policy: isolated
|
||||
|
||||
|
||||
@ -29,13 +29,13 @@ app:
|
||||
apparmor_profile: lightning-stack
|
||||
|
||||
ports:
|
||||
- host: 9738
|
||||
- host: 9737
|
||||
container: 9735
|
||||
protocol: tcp # P2P
|
||||
- host: 10010
|
||||
container: 10009
|
||||
protocol: tcp # gRPC
|
||||
- host: 8091
|
||||
- host: 8087
|
||||
container: 8080
|
||||
protocol: tcp # REST/Web UI
|
||||
|
||||
@ -53,7 +53,7 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8080
|
||||
endpoint: http://localhost:8087
|
||||
path: /v1/getinfo
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@ -8,13 +8,6 @@ app:
|
||||
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
|
||||
pull_policy: if-not-present
|
||||
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:
|
||||
- key: BITCOIND_RPCPASS
|
||||
secret_file: bitcoin-rpc-password
|
||||
@ -52,6 +45,7 @@ app:
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- BITCOIND_HOST=bitcoin-knots
|
||||
- BITCOIND_RPCUSER=archipelago
|
||||
- NETWORK=mainnet
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ app:
|
||||
description: Backend API for mempool explorer.
|
||||
|
||||
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
|
||||
network: archy-net
|
||||
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
|
||||
|
||||
@ -27,7 +27,7 @@ app:
|
||||
apparmor_profile: morphos-server
|
||||
|
||||
ports:
|
||||
- host: 8089
|
||||
- host: 8086
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
|
||||
@ -43,7 +43,7 @@ app:
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8080
|
||||
endpoint: http://localhost:8086
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@ -21,188 +21,28 @@ app:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: nostr-relay
|
||||
|
||||
ports:
|
||||
- host: 8090
|
||||
container: 7777
|
||||
protocol: tcp # HTTP/WebSocket (strfry listens on 7777)
|
||||
- host: 8082
|
||||
container: 8080
|
||||
protocol: tcp # HTTP/WebSocket
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/strfry
|
||||
target: /app/strfry-db
|
||||
target: /strfry
|
||||
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:
|
||||
- path: /var/lib/archipelago/strfry-config/strfry.conf
|
||||
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
|
||||
}
|
||||
}
|
||||
environment:
|
||||
- RELAY_NAME=Archipelago Strfry Relay
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
# In-container probe: must target the CONTAINER port (7777), not the host
|
||||
# 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
|
||||
endpoint: http://localhost:8082
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
81
core/Cargo.lock
generated
81
core/Cargo.lock
generated
@ -99,7 +99,6 @@ version = "1.7.99-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
"archipelago-openwrt",
|
||||
"archipelago-performance",
|
||||
"archipelago-security",
|
||||
"argon2",
|
||||
@ -169,7 +168,6 @@ dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hex",
|
||||
"hyper 0.14.32",
|
||||
"indexmap",
|
||||
"log",
|
||||
@ -177,29 +175,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"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]]
|
||||
name = "archipelago-performance"
|
||||
version = "0.1.0"
|
||||
@ -2859,32 +2840,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@ -3626,18 +3581,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "papaya"
|
||||
version = "0.2.4"
|
||||
@ -3816,12 +3759,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
@ -5052,18 +4989,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@ -5851,12 +5776,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vergen"
|
||||
version = "9.1.0"
|
||||
|
||||
@ -4,7 +4,6 @@ resolver = "2"
|
||||
members = [
|
||||
"archipelago",
|
||||
"container",
|
||||
"openwrt",
|
||||
"performance",
|
||||
"security",
|
||||
]
|
||||
|
||||
@ -43,7 +43,6 @@ futures-util = "0.3"
|
||||
|
||||
# Our modules
|
||||
archipelago-container = { path = "../container" }
|
||||
archipelago-openwrt = { path = "../openwrt" }
|
||||
archipelago-security = { path = "../security" }
|
||||
archipelago-performance = { path = "../performance" }
|
||||
|
||||
|
||||
@ -39,17 +39,6 @@ impl ApiHandler {
|
||||
|
||||
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;
|
||||
if let Ok(json_msg) = serde_json::to_string(&initial_msg) {
|
||||
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);
|
||||
}
|
||||
|
||||
let mut state_rx = state_manager.subscribe();
|
||||
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||
tokio::pin!(ping_interval);
|
||||
let mut last_client_activity = Instant::now();
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
use super::RpcHandler;
|
||||
#[cfg(debug_assertions)]
|
||||
use super::DEV_DEFAULT_PASSWORD;
|
||||
use super::{RpcHandler, DEV_DEFAULT_PASSWORD};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
@ -16,10 +14,7 @@ impl RpcHandler {
|
||||
|
||||
let is_setup = self.auth_manager.is_setup().await?;
|
||||
if !is_setup {
|
||||
// Dev BUILDS only: allow the default password so the UI can log
|
||||
// in without running setup. cfg-gated so no release binary can
|
||||
// carry the bypass, whatever its runtime config says.
|
||||
#[cfg(debug_assertions)]
|
||||
// Dev mode: allow default password so UI can log in without running setup
|
||||
if self.config.dev_mode && password == DEV_DEFAULT_PASSWORD {
|
||||
tracing::info!("[onboarding] login via dev default password");
|
||||
return Ok(serde_json::Value::Null);
|
||||
@ -146,19 +141,6 @@ impl RpcHandler {
|
||||
|
||||
self.auth_manager.setup_user(password).await?;
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@ -862,9 +862,7 @@ async fn hydrate_tor_endpoint(data_dir: &Path, state: &mut BitcoinRelayState) {
|
||||
let onion = onion.trim().trim_end_matches('/').to_string();
|
||||
if !onion.is_empty() {
|
||||
state.settings.tor_endpoint = Some(format!("http://{onion}/"));
|
||||
if let Err(e) = save_relay_state(data_dir, state).await {
|
||||
tracing::warn!("Failed to persist relay tor endpoint: {e:#}");
|
||||
}
|
||||
let _ = save_relay_state(data_dir, state).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -267,16 +267,13 @@ impl RpcHandler {
|
||||
.context("Failed to connect to peer")?;
|
||||
// Record which transport actually reached the peer (B14) so the UI
|
||||
// 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,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||
@ -351,16 +348,13 @@ impl RpcHandler {
|
||||
.await
|
||||
.context("Failed to connect to peer")?;
|
||||
// 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,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
.await;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
@ -503,16 +497,13 @@ impl RpcHandler {
|
||||
}
|
||||
};
|
||||
// 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,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
// 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,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
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,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
.await;
|
||||
|
||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
return Ok(serde_json::json!({
|
||||
@ -1034,16 +1019,13 @@ impl RpcHandler {
|
||||
.await
|
||||
.context("Failed to connect to peer for preview")?;
|
||||
// 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,
|
||||
None,
|
||||
Some(onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
.await;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
|
||||
@ -223,7 +223,6 @@ impl RpcHandler {
|
||||
"network.list-interfaces" => self.handle_network_list_interfaces().await,
|
||||
"network.scan-wifi" => self.handle_network_scan_wifi().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.dns-status" => self.handle_network_dns_status().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.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
|
||||
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
|
||||
"wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await,
|
||||
|
||||
@ -18,24 +18,6 @@ impl RpcHandler {
|
||||
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.
|
||||
pub(super) async fn handle_network_configure_wifi(
|
||||
&self,
|
||||
@ -345,27 +327,6 @@ fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
|
||||
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.
|
||||
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
|
||||
let conn_name = format!("archipelago-wifi-{ssid}");
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use anyhow::Result;
|
||||
use tracing::warn;
|
||||
|
||||
impl RpcHandler {
|
||||
/// 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;
|
||||
set.clear();
|
||||
}
|
||||
if let Err(e) = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await {
|
||||
warn!("Failed to persist cleared radio-contact blocklist: {e:#}");
|
||||
}
|
||||
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await;
|
||||
|
||||
// Actually DELETE each radio contact from the firmware table (via
|
||||
// CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next
|
||||
|
||||
@ -67,32 +67,6 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"Container",
|
||||
"Image",
|
||||
"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 {
|
||||
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()
|
||||
}
|
||||
|
||||
#[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.
|
||||
/// Deterministic: same session token always produces the same CSRF 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
|
||||
}
|
||||
|
||||
/// The TCP peer address of the connection a request arrived on, injected
|
||||
/// into request extensions by the server accept loop.
|
||||
#[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> {
|
||||
/// Extract the client IP from request headers (X-Real-IP or X-Forwarded-For).
|
||||
pub(super) fn extract_client_ip(headers: &hyper::HeaderMap) -> IpAddr {
|
||||
headers
|
||||
.get("x-real-ip")
|
||||
.or_else(|| headers.get("x-forwarded-for"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.trim().parse::<IpAddr>().ok())
|
||||
}
|
||||
|
||||
#[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()
|
||||
);
|
||||
}
|
||||
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
|
||||
@ -23,7 +23,6 @@ mod names;
|
||||
mod network;
|
||||
mod node;
|
||||
mod nostr;
|
||||
mod openwrt;
|
||||
mod package;
|
||||
mod peers;
|
||||
mod response;
|
||||
@ -58,13 +57,9 @@ use middleware::{
|
||||
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
|
||||
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
|
||||
};
|
||||
pub use middleware::PeerAddr;
|
||||
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
|
||||
|
||||
/// 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 struct RpcHandler {
|
||||
@ -373,7 +368,7 @@ impl RpcHandler {
|
||||
|
||||
// Rate limit login attempts
|
||||
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 {
|
||||
return Ok(self.rate_limit_response());
|
||||
}
|
||||
@ -381,7 +376,7 @@ impl RpcHandler {
|
||||
|
||||
// Rate limit sensitive endpoints
|
||||
{
|
||||
let client_ip = extract_client_ip(&parts);
|
||||
let client_ip = extract_client_ip(&parts.headers);
|
||||
if !self
|
||||
.endpoint_rate_limiter
|
||||
.check(&rpc_req.method, client_ip)
|
||||
@ -455,7 +450,7 @@ impl RpcHandler {
|
||||
let mut response = json_response(StatusCode::OK, &resp_body);
|
||||
|
||||
// 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(
|
||||
&rpc_req.method,
|
||||
&mut rpc_resp,
|
||||
|
||||
@ -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()?,
|
||||
])
|
||||
}
|
||||
@ -114,31 +114,6 @@ impl RpcHandler {
|
||||
Err(e) => {
|
||||
error!("package.install {} failed: {:#}", package_id_spawn, e);
|
||||
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
|
||||
// vanish from My Apps mid-install / between retry-loop
|
||||
// attempts (e.g. tailscale's entrypoint failure). Leave
|
||||
|
||||
@ -94,11 +94,35 @@ async fn dynamic_app_config(
|
||||
))
|
||||
}
|
||||
|
||||
/// Validate a Docker image reference. Delegates to the shared policy in
|
||||
/// `container::image_policy` — the same rules the orchestrator enforces at
|
||||
/// its pull sites, so the two layers can't drift apart.
|
||||
/// Trusted Docker registries. Only images from these sources are allowed.
|
||||
#[allow(dead_code)]
|
||||
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 {
|
||||
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.
|
||||
@ -660,15 +684,10 @@ pub(super) async fn get_app_config(
|
||||
),
|
||||
"bitcoin-core" => (
|
||||
vec![
|
||||
// RPC + ZMQ are auth-only/unauthenticated: host-local ONLY —
|
||||
// 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(),
|
||||
"8332:8332".to_string(),
|
||||
"8333:8333".to_string(),
|
||||
"127.0.0.1:28332:28332".to_string(),
|
||||
"127.0.0.1:28333:28333".to_string(),
|
||||
"28332:28332".to_string(),
|
||||
"28333:28333".to_string(),
|
||||
],
|
||||
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||
vec![],
|
||||
@ -688,17 +707,12 @@ pub(super) async fn get_app_config(
|
||||
// effectively pinned at 2 by --cpus=2 (now removed).
|
||||
// -maxconnections=125 — default but explicit, so ops can
|
||||
// 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![
|
||||
"-server=1".to_string(),
|
||||
"-rpcbind=0.0.0.0".to_string(),
|
||||
"-rpcallowip=0.0.0.0/0".to_string(),
|
||||
"-rpcport=8332".to_string(),
|
||||
"-printtoconsole=0".to_string(),
|
||||
"-printtoconsole=1".to_string(),
|
||||
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
||||
format!("-dbcache={}", bitcoin_dbcache_mb()),
|
||||
"-par=0".to_string(),
|
||||
@ -707,15 +721,10 @@ pub(super) async fn get_app_config(
|
||||
),
|
||||
"bitcoin" | "bitcoin-knots" => (
|
||||
vec![
|
||||
// RPC + ZMQ are auth-only/unauthenticated: host-local ONLY —
|
||||
// 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(),
|
||||
"8332:8332".to_string(),
|
||||
"8333:8333".to_string(),
|
||||
"127.0.0.1:28332:28332".to_string(),
|
||||
"127.0.0.1:28333:28333".to_string(),
|
||||
"28332:28332".to_string(),
|
||||
"28333:28333".to_string(),
|
||||
],
|
||||
vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||
vec![],
|
||||
|
||||
@ -58,7 +58,6 @@ fn archival_bitcoin_required_message(package_id: &str) -> String {
|
||||
}
|
||||
|
||||
/// Snapshot of which dependency services are currently running.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct RunningDeps {
|
||||
pub has_bitcoin: 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
|
||||
/// unpruned node while building their indexes. A pruned Bitcoin node can be
|
||||
/// 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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@ -497,11 +312,10 @@ fn check_blockchain_info_for_pruning(package_id: &str, json: &serde_json::Value)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn detect_disk_gb() -> u64 {
|
||||
let output = tokio::process::Command::new("df")
|
||||
fn detect_disk_gb() -> u64 {
|
||||
let output = std::process::Command::new("df")
|
||||
.args(["-BG", "/var/lib/archipelago"])
|
||||
.output()
|
||||
.await;
|
||||
.output();
|
||||
let Ok(output) = output else {
|
||||
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,
|
||||
/// leaving the stack down until the health monitor recovers it minutes later.
|
||||
/// 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> {
|
||||
if containers.is_empty() {
|
||||
// Nothing is live under any known name. For known stacks, resurrect
|
||||
// every member via its app id (see stack_member_app_ids). Otherwise
|
||||
// fall back to the package id so a single-container app whose
|
||||
// container matches its id still gets one start attempt.
|
||||
let members = stack_member_app_ids(package_id);
|
||||
if !members.is_empty() {
|
||||
return members.iter().map(|s| s.to_string()).collect();
|
||||
}
|
||||
// Nothing is live under any known name. Fall back to the package id so
|
||||
// a single-container app whose container matches its id still gets one
|
||||
// start attempt; multi-container stacks with no live members are
|
||||
// surfaced as "no containers" by the caller's emptiness check.
|
||||
return vec![package_id.to_string()];
|
||||
}
|
||||
let order = startup_order(package_id);
|
||||
@ -753,35 +546,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_present_containers_empty_resurrects_stack_members() {
|
||||
// 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.
|
||||
fn order_present_containers_empty_falls_back_to_package_id() {
|
||||
assert_eq!(
|
||||
order_present_containers("mempool", vec![]),
|
||||
vec!["archy-mempool-db", "mempool-api", "archy-mempool-web"]
|
||||
);
|
||||
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()]
|
||||
vec!["mempool".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
@ -857,218 +625,6 @@ mod tests {
|
||||
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]
|
||||
fn mempool_api_is_directly_installable_and_covered_by_the_archival_gate() {
|
||||
// `mempool-api` is a legitimate direct `package.install` target
|
||||
|
||||
@ -3,10 +3,9 @@ use super::config::{
|
||||
is_readonly_compatible, is_valid_docker_image,
|
||||
};
|
||||
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,
|
||||
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL,
|
||||
DEP_WAIT_MAX_ATTEMPTS,
|
||||
needs_archy_net, RunningDeps,
|
||||
};
|
||||
use super::progress::parse_pull_progress;
|
||||
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.
|
||||
pub(in crate::api::rpc) async fn install_log(msg: &str) {
|
||||
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 line = format!("[{}] {}\n", ts, msg);
|
||||
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)
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@ -274,7 +265,8 @@ impl RpcHandler {
|
||||
.await;
|
||||
|
||||
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?;
|
||||
}
|
||||
|
||||
@ -297,11 +289,9 @@ impl RpcHandler {
|
||||
// Dependency checks. Prefer the scanner's cached package state so a
|
||||
// congested Podman API does not turn an already-running dependency into
|
||||
// a false install failure. Fall back to a bounded direct Podman probe
|
||||
// only when the cache does not show the dependency. When the dependency
|
||||
// is installed but not Running yet (the "clicked Install LND 55s before
|
||||
// Bitcoin was up" race), wait up to ~3 minutes for it instead of
|
||||
// failing instantly.
|
||||
let deps = self.gate_install_deps(package_id).await?;
|
||||
// only when the cache does not show the dependency.
|
||||
let deps = self.running_deps_for_install(package_id).await?;
|
||||
check_install_deps(package_id, &deps)?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
let repaired_bitcoin_conf =
|
||||
@ -330,20 +320,8 @@ impl RpcHandler {
|
||||
// mode).
|
||||
// The adoption block is being phased out as apps move to the
|
||||
// orchestrator path. Non-orchestrator apps still hit it.
|
||||
let orchestrator_managed = match self.orchestrator.as_ref() {
|
||||
// Migration allowlist OR any app whose manifest the orchestrator
|
||||
// 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,
|
||||
};
|
||||
let orchestrator_managed =
|
||||
should_try_orchestrator_install(package_id, self.orchestrator.is_some());
|
||||
|
||||
// Check if container already exists (legacy adoption — non-orchestrator
|
||||
// apps only).
|
||||
@ -738,24 +716,6 @@ impl RpcHandler {
|
||||
self.create_data_dirs(package_id, &volumes).await;
|
||||
|
||||
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(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 --
|
||||
|
||||
/// 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
|
||||
// silently pruned down to 550 MB. Users who want a pruned node can
|
||||
// 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!(
|
||||
"\
|
||||
# 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\
|
||||
rpcthreads=16\n\
|
||||
rpcworkqueue=256\n\
|
||||
printtoconsole=0\n",
|
||||
printtoconsole=1\n",
|
||||
rpcauth_line
|
||||
);
|
||||
tokio::fs::create_dir_all(bitcoin_dir)
|
||||
@ -2232,14 +2166,13 @@ async fn ensure_host_port_listener(
|
||||
container_name: &str,
|
||||
runtime_ports: &[String],
|
||||
) -> Result<()> {
|
||||
let mut port = runtime_ports
|
||||
let Some(port) = runtime_ports
|
||||
.first()
|
||||
.and_then(|p| p.split(':').next())
|
||||
.and_then(|p| p.parse::<u16>().ok());
|
||||
if port.is_none() {
|
||||
port = published_host_port(container_name).await;
|
||||
}
|
||||
let Some(port) = port.or_else(|| required_host_port(package_id)) else {
|
||||
.and_then(|p| p.parse::<u16>().ok())
|
||||
.or_else(|| published_host_port(container_name))
|
||||
.or_else(|| required_host_port(package_id))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@ -2285,11 +2218,10 @@ async fn ensure_host_port_listener(
|
||||
))
|
||||
}
|
||||
|
||||
async fn published_host_port(container_name: &str) -> Option<u16> {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
fn published_host_port(container_name: &str) -> Option<u16> {
|
||||
let output = std::process::Command::new("podman")
|
||||
.args(["port", container_name])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
|
||||
@ -61,31 +61,6 @@ impl RpcHandler {
|
||||
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.
|
||||
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
|
||||
@ -63,8 +63,6 @@ impl RpcHandler {
|
||||
|
||||
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
|
||||
vec![orchestrator_app_id(package_id).to_string()]
|
||||
} else if let Some(members) = orchestrator_stack_members(self.orchestrator.is_some(), package_id) {
|
||||
members
|
||||
} else {
|
||||
ordered_containers_for_start(package_id).await?
|
||||
};
|
||||
@ -93,10 +91,7 @@ impl RpcHandler {
|
||||
))
|
||||
.await;
|
||||
|
||||
let op_lock = app_op_lock(package_id);
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let _op_guard = op_lock.lock().await;
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_start(orchestrator.as_ref(), &to_start).await
|
||||
} else {
|
||||
@ -107,13 +102,6 @@ impl RpcHandler {
|
||||
reconcile_companions_for(&companion_app_id).await;
|
||||
set_package_state(&state_manager, &package_id_owned, PackageState::Running)
|
||||
.await;
|
||||
cascade_restart_address_caching_dependents(
|
||||
orchestrator.as_ref(),
|
||||
&state_manager,
|
||||
&data_dir,
|
||||
&package_id_owned,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(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));
|
||||
}
|
||||
|
||||
// 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
|
||||
// crash recovery don't auto-restart mid-flight. Ordering is
|
||||
// load-bearing — see runtime.rs:145-148 original note.
|
||||
@ -186,17 +158,9 @@ impl RpcHandler {
|
||||
for name in &containers {
|
||||
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 to_stop = to_stop_ids.unwrap_or_else(|| containers.clone());
|
||||
let to_stop = containers.clone();
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let state_manager = Arc::clone(&self.state_manager);
|
||||
let pre_state =
|
||||
@ -208,9 +172,7 @@ impl RpcHandler {
|
||||
))
|
||||
.await;
|
||||
|
||||
let op_lock = app_op_lock(package_id);
|
||||
tokio::spawn(async move {
|
||||
let _op_guard = op_lock.lock().await;
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_stop(orchestrator.as_ref(), &to_stop).await
|
||||
} else {
|
||||
@ -251,21 +213,14 @@ impl RpcHandler {
|
||||
|
||||
let single_orchestrator_app =
|
||||
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()]
|
||||
} else {
|
||||
get_containers_for_app(package_id).await?
|
||||
};
|
||||
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);
|
||||
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||
}
|
||||
containers = members.iter().map(|s| s.to_string()).collect();
|
||||
tracing::warn!("package.restart {}: no containers found", package_id);
|
||||
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||
}
|
||||
|
||||
// Restart does not mark user-stopped; user wants the app to keep
|
||||
@ -280,12 +235,6 @@ impl RpcHandler {
|
||||
let companion_app_id = package_id_owned.clone();
|
||||
let to_restart = if single_orchestrator_app {
|
||||
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 {
|
||||
ordered_containers_for_start(package_id).await?
|
||||
};
|
||||
@ -300,10 +249,7 @@ impl RpcHandler {
|
||||
))
|
||||
.await;
|
||||
|
||||
let op_lock = app_op_lock(package_id);
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let _op_guard = op_lock.lock().await;
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_restart(orchestrator.as_ref(), &to_restart).await
|
||||
} else {
|
||||
@ -314,13 +260,6 @@ impl RpcHandler {
|
||||
reconcile_companions_for(&companion_app_id).await;
|
||||
set_package_state(&state_manager, &package_id_owned, PackageState::Running)
|
||||
.await;
|
||||
cascade_restart_address_caching_dependents(
|
||||
orchestrator.as_ref(),
|
||||
&state_manager,
|
||||
&data_dir,
|
||||
&package_id_owned,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(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 {
|
||||
Ok(()) => wait_after_orchestrator_start(name).await,
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
// Collect instead of `?`: aborting here skipped every later
|
||||
// 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));
|
||||
}
|
||||
do_package_start(&[name.clone()]).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
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 {
|
||||
stderr.contains("no such container")
|
||||
|| 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("not found")
|
||||
}
|
||||
@ -1084,13 +1015,6 @@ async fn do_orchestrator_package_stop(
|
||||
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) => {
|
||||
tracing::error!(container = %name, error = %e, "orchestrator stop failed");
|
||||
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 {
|
||||
startup_order(package_id).is_empty()
|
||||
&& matches!(
|
||||
@ -2120,18 +1947,6 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
||||
"archy-btcpay-db".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
|
||||
// stacks.rs. Without this, uninstalling "immich" only disabled the
|
||||
// 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 {
|
||||
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]
|
||||
fn runtime_host_ports_are_manifest_derived_for_public_apps() {
|
||||
assert_eq!(runtime_host_ports("photoprism"), vec![2342]);
|
||||
@ -2179,25 +1978,6 @@ mod tests {
|
||||
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]
|
||||
fn immich_uninstall_covers_every_sibling_orchestrator_app_id() {
|
||||
// Regression: uninstalling "immich" used to only disable the
|
||||
|
||||
@ -130,8 +130,6 @@ async fn wait_for_stack_container_absent(container_name: &str, timeout: Duration
|
||||
fn is_missing_container_error(stderr: &str) -> bool {
|
||||
stderr.contains("no such container")
|
||||
|| 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("not found")
|
||||
}
|
||||
@ -1011,9 +1009,9 @@ impl RpcHandler {
|
||||
return Ok(adopted);
|
||||
}
|
||||
|
||||
// Dependency check: Bitcoin must be running. Bounded wait covers the
|
||||
// "installed but still starting" race instead of failing instantly.
|
||||
self.gate_install_deps("btcpay-server").await?;
|
||||
// Dependency check: Bitcoin must be running
|
||||
let deps = super::dependencies::detect_running_deps().await?;
|
||||
super::dependencies::check_install_deps("btcpay-server", &deps)?;
|
||||
|
||||
install_log("INSTALL START: btcpay-server (stack: postgres + nbxplorer + btcpay)").await;
|
||||
|
||||
|
||||
@ -172,36 +172,18 @@ impl RpcHandler {
|
||||
|
||||
/// Manual "check for updates": refresh the remote app catalog now. The
|
||||
/// 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
|
||||
/// bytes changed, the orchestrator's manifest overlay is reloaded in the
|
||||
/// 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`.
|
||||
/// catalog on its next cycle and pushes it to the UI. 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(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
match crate::container::app_catalog::refresh_catalog(&self.config.data_dir).await {
|
||||
Ok(refresh) => {
|
||||
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",
|
||||
"refreshed": true,
|
||||
"catalog_apps": refresh.apps,
|
||||
"catalog_changed": refresh.changed,
|
||||
"manifests_reloaded": manifests_reloaded,
|
||||
}))
|
||||
}
|
||||
Ok(count) => Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": true,
|
||||
"catalog_apps": count,
|
||||
})),
|
||||
Err(e) => Ok(serde_json::json!({
|
||||
"status": "ok",
|
||||
"refreshed": false,
|
||||
|
||||
@ -26,36 +26,6 @@ impl Drop for OnboardingMnemonicState {
|
||||
|
||||
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
|
||||
/// 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
|
||||
@ -238,17 +208,6 @@ impl RpcHandler {
|
||||
let phrase = words.join(" ");
|
||||
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.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
crate::identity::NodeIdentity::from_seed(&identity_dir, &seed).await?;
|
||||
|
||||
@ -575,7 +575,7 @@ impl RpcHandler {
|
||||
// Restart the service via systemd
|
||||
tokio::spawn(async {
|
||||
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"])
|
||||
.spawn();
|
||||
});
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -101,45 +101,19 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
||||
.trim_end_matches('.');
|
||||
let lower = detail.to_lowercase();
|
||||
let state = if lower.contains("verifying blocks") {
|
||||
Some("verifying blocks after restart")
|
||||
} else if lower.contains("connection reset") {
|
||||
Some("starting up and not yet accepting RPC connections")
|
||||
"verifying blocks after restart"
|
||||
} 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") {
|
||||
Some("busy and not answering RPC before the timeout")
|
||||
"busy and not answering RPC before the timeout"
|
||||
} else {
|
||||
None
|
||||
"starting or busy syncing"
|
||||
};
|
||||
|
||||
// Recognized transient causes get a clean human sentence only — the raw
|
||||
// transport error (URLs, repeated "os error 104" chains) is operator
|
||||
// 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 {
|
||||
detail.to_string()
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
let base = if has_cached_state {
|
||||
format!("Bitcoin node is {state}; showing last known state and retrying.")
|
||||
if has_cached_state {
|
||||
format!("Bitcoin node is {state}; showing last known state and retrying. Detail: {detail}")
|
||||
} else {
|
||||
format!("Bitcoin node is {state}; retrying automatically.")
|
||||
};
|
||||
match detail {
|
||||
Some(detail) => format!("{base} Detail: {detail}"),
|
||||
None => base,
|
||||
format!("Bitcoin node is {state}; retrying automatically. Detail: {detail}")
|
||||
}
|
||||
}
|
||||
|
||||
@ -304,39 +278,4 @@ mod tests {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,16 +39,6 @@ const KIOSK_LAUNCHER: &str =
|
||||
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||
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_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
|
||||
/// 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"),
|
||||
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 {
|
||||
Ok(n) if n > 0 => info!(tightened = n, "Tightened mode on secret files"),
|
||||
Ok(_) => debug!("Secrets directory already at expected mode"),
|
||||
@ -423,14 +408,6 @@ ensure_line() {
|
||||
ensure_line server=1
|
||||
ensure_line rpcallowip=0.0.0.0/0
|
||||
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
|
||||
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> {
|
||||
// Dev-box guard: on contributors' laptops `/home/archipelago/archy` is
|
||||
// typically a symlink into the git checkout, and writing through it
|
||||
|
||||
@ -19,11 +19,6 @@
|
||||
//! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert
|
||||
//! `signature` + `signed_by` over the canonical form, matching exactly
|
||||
//! 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};
|
||||
@ -52,15 +47,9 @@ pub fn run() -> Result<()> {
|
||||
.context("usage: archipelago ceremony sign <file.json>")?;
|
||||
cmd_sign(&file)
|
||||
}
|
||||
"verify" => {
|
||||
let file = std::env::args()
|
||||
.nth(3)
|
||||
.context("usage: archipelago ceremony verify <file.json>")?;
|
||||
cmd_verify(&file)
|
||||
}
|
||||
other => {
|
||||
bail!(
|
||||
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file> | verify <file>",
|
||||
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file>",
|
||||
other
|
||||
)
|
||||
}
|
||||
@ -118,33 +107,6 @@ fn cmd_sign(path: &str) -> Result<()> {
|
||||
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.
|
||||
fn load_release_root_key() -> Result<SigningKey> {
|
||||
let phrase = read_mnemonic()?;
|
||||
|
||||
@ -81,11 +81,10 @@ pub struct Config {
|
||||
|
||||
impl Config {
|
||||
/// Detect primary host IP (first non-loopback IPv4)
|
||||
async fn detect_host_ip() -> Result<String> {
|
||||
let output = tokio::process::Command::new("hostname")
|
||||
fn detect_host_ip() -> Result<String> {
|
||||
let output = std::process::Command::new("hostname")
|
||||
.args(["-I"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to run hostname -I")?;
|
||||
let s = String::from_utf8_lossy(&output.stdout);
|
||||
let ip = s
|
||||
@ -211,9 +210,7 @@ impl Config {
|
||||
if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") {
|
||||
config.host_ip = ip;
|
||||
} else {
|
||||
config.host_ip = Self::detect_host_ip()
|
||||
.await
|
||||
.unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
config.host_ip = Self::detect_host_ip().unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
}
|
||||
|
||||
// Ensure data directory exists
|
||||
|
||||
@ -339,31 +339,18 @@ fn catalog_urls_from_mirrors(mirrors: &[crate::update::UpdateMirror]) -> Vec<Str
|
||||
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
|
||||
/// `<data_dir>/app-catalog.json`. Returns the app count and whether the cache
|
||||
/// changed. Best-effort: a fetch failure leaves the existing cache untouched
|
||||
/// `<data_dir>/app-catalog.json`. Returns the number of apps in the catalog on
|
||||
/// success. Best-effort: a fetch failure leaves the existing cache untouched
|
||||
/// (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)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let urls = catalog_urls_from_mirrors(&mirrors);
|
||||
if urls.is_empty() {
|
||||
debug!("app-catalog: no mirror-derived URLs to fetch from");
|
||||
return Ok(CatalogRefresh {
|
||||
apps: 0,
|
||||
changed: false,
|
||||
});
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
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;
|
||||
for url in &urls {
|
||||
match fetch_one(&client, url).await {
|
||||
Ok((catalog, body)) => {
|
||||
Ok(catalog) => {
|
||||
let count = catalog.apps.len();
|
||||
let changed = write_cache(data_dir, &body)?;
|
||||
if changed {
|
||||
// Invalidate the in-process cache so the next read re-parses.
|
||||
*CACHE.lock().unwrap() = None;
|
||||
}
|
||||
info!(
|
||||
"app-catalog: refreshed from {} ({} apps{})",
|
||||
url,
|
||||
count,
|
||||
if changed { ", changed" } else { ", unchanged" }
|
||||
);
|
||||
return Ok(CatalogRefresh {
|
||||
apps: count,
|
||||
changed,
|
||||
});
|
||||
write_cache(data_dir, &catalog)?;
|
||||
// Invalidate the in-process cache so the next read re-parses.
|
||||
*CACHE.lock().unwrap() = None;
|
||||
info!("app-catalog: refreshed from {} ({} apps)", url, count);
|
||||
return Ok(count);
|
||||
}
|
||||
Err(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")))
|
||||
}
|
||||
|
||||
async fn fetch_one(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
) -> anyhow::Result<(AppCatalog, String)> {
|
||||
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<AppCatalog> {
|
||||
let resp = client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
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
|
||||
/// 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> {
|
||||
fn write_cache(data_dir: &Path, catalog: &AppCatalog) -> anyhow::Result<()> {
|
||||
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));
|
||||
std::fs::write(&tmp, body)?;
|
||||
let json = serde_json::to_string_pretty(catalog)?;
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &dest)?;
|
||||
Ok(true)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -501,32 +462,6 @@ mod tests {
|
||||
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]
|
||||
fn entry_carries_embedded_manifest() {
|
||||
let json = r#"{
|
||||
|
||||
@ -424,7 +424,7 @@ fn build_unit(spec: &CompanionSpec, image: &str) -> QuadletUnit {
|
||||
ports: spec
|
||||
.ports
|
||||
.iter()
|
||||
.map(|(host, container)| (*host, *container, "tcp".into(), String::new()))
|
||||
.map(|(host, container)| (*host, *container, "tcp".into()))
|
||||
.collect(),
|
||||
extra_podman_args: vec![],
|
||||
depends_on: vec![],
|
||||
|
||||
@ -1,95 +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.
|
||||
pub const TRUSTED_REGISTRIES: &[&str] = &[
|
||||
"docker.io",
|
||||
"ghcr.io",
|
||||
"localhost",
|
||||
"git.tx1138.com",
|
||||
"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",
|
||||
"git.tx1138.com/lfg2025/x:2",
|
||||
"146.59.87.168:3000/archy/bitcoin-knots:28.1",
|
||||
] {
|
||||
assert!(is_valid_docker_image(img), "{img} should be accepted");
|
||||
}
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
@ -43,11 +43,7 @@ pub enum EnsureOutcome {
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
pub async fn ensure_config(
|
||||
paths: &EnsurePaths,
|
||||
rpc_pass: &str,
|
||||
bitcoin_host: &str,
|
||||
) -> Result<EnsureOutcome> {
|
||||
pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result<EnsureOutcome> {
|
||||
fs::create_dir_all(&paths.data_dir)
|
||||
.await
|
||||
.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)
|
||||
.await
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@ -72,11 +68,12 @@ restlisten=0.0.0.0:8080\n\
|
||||
bitcoin.active=true\n\
|
||||
bitcoin.mainnet=true\n\
|
||||
bitcoin.node=bitcoind\n\
|
||||
bitcoind.rpchost={bitcoin_host}:8332\n\
|
||||
bitcoind.rpchost=bitcoin-knots:8332\n\
|
||||
bitcoind.rpcuser=archipelago\n\
|
||||
bitcoind.rpcpass={rpc_pass}\n\
|
||||
bitcoind.rpcpass={}\n\
|
||||
bitcoind.rpcpolling=true\n\
|
||||
bitcoind.estimatemode=ECONOMICAL\n"
|
||||
bitcoind.estimatemode=ECONOMICAL\n",
|
||||
rpc_pass
|
||||
);
|
||||
|
||||
write_config_atomically(paths, &conf).await?;
|
||||
@ -656,14 +653,13 @@ fn shell_quote(s: &str) -> String {
|
||||
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_host_line = format!("bitcoind.rpchost={bitcoin_host}:8332");
|
||||
[
|
||||
"bitcoin.active=true",
|
||||
"bitcoin.mainnet=true",
|
||||
"bitcoin.node=bitcoind",
|
||||
rpc_host_line.as_str(),
|
||||
"bitcoind.rpchost=bitcoin-knots:8332",
|
||||
rpc_pass_line.as_str(),
|
||||
]
|
||||
.iter()
|
||||
@ -682,7 +678,7 @@ mod tests {
|
||||
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);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
assert!(conf.contains("bitcoin.active=true"));
|
||||
@ -701,46 +697,17 @@ mod tests {
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "first", "bitcoin-knots").await.unwrap(),
|
||||
ensure_config(&paths, "first").await.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "second", "bitcoin-knots").await.unwrap(),
|
||||
ensure_config(&paths, "second").await.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
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]
|
||||
async fn ensure_config_repairs_incomplete_existing_config() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
@ -754,7 +721,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "repaired", "bitcoin-knots").await.unwrap(),
|
||||
ensure_config(&paths, "repaired").await.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
|
||||
@ -7,7 +7,6 @@ pub mod dev_orchestrator;
|
||||
pub mod docker_packages;
|
||||
pub mod filebrowser;
|
||||
pub mod hooks;
|
||||
pub mod image_policy;
|
||||
pub mod image_versions;
|
||||
pub mod lnd;
|
||||
pub mod prod_orchestrator;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -140,17 +140,8 @@ pub struct QuadletUnit {
|
||||
// Backend-manifest extensions (Phase 3.1). Companion units leave
|
||||
// these defaulted; the renderer skips empty/false directives so a
|
||||
// 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, String)>,
|
||||
pub ports: Vec<(u16, u16, 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 add_hosts: Vec<(String, String)>,
|
||||
pub network_aliases: Vec<String>,
|
||||
@ -242,19 +233,13 @@ impl QuadletUnit {
|
||||
// (exit 125). Skip publishing in host mode — matches the NetworkMode
|
||||
// doc note that Podman discards port mappings under host networking.
|
||||
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() {
|
||||
"tcp"
|
||||
} else {
|
||||
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}");
|
||||
} else {
|
||||
let _ = writeln!(s, "PublishPort={bind}:{host}:{container}/{p}");
|
||||
}
|
||||
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
|
||||
}
|
||||
}
|
||||
for env in &self.environment {
|
||||
@ -262,12 +247,6 @@ impl QuadletUnit {
|
||||
// accepts that form on a single Environment= line per pair.
|
||||
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 {
|
||||
let _ = writeln!(s, "AddDevice={dev}");
|
||||
}
|
||||
@ -433,39 +412,9 @@ impl QuadletUnit {
|
||||
ports: app
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
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()))
|
||||
.map(|p| (p.host, p.container, p.protocol.clone()))
|
||||
.collect(),
|
||||
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(),
|
||||
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
||||
// 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}")
|
||||
};
|
||||
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 (btcpay's dotnet base) ship neither but do have bash —
|
||||
// fall through to a bash /dev/tcp connect probe there so the
|
||||
// container can't report healthy while its port is dead
|
||||
// (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.
|
||||
// Node images ship neither. Use whichever probe helper exists and
|
||||
// skip Podman health if the image has none; host-side lifecycle
|
||||
// probes still verify reachability.
|
||||
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",
|
||||
final_url, helper_timeout, tcp_host, tcp_port
|
||||
"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
|
||||
)
|
||||
}
|
||||
"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 {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
@ -921,24 +847,6 @@ mod tests {
|
||||
use super::*;
|
||||
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 {
|
||||
QuadletUnit {
|
||||
name: "archy-bitcoin-ui".into(),
|
||||
@ -1000,7 +908,7 @@ mod tests {
|
||||
// Podman rejects PublishPort with Network=host (crash-loop exit 125).
|
||||
let mut u = sample_unit();
|
||||
u.network = NetworkMode::Host;
|
||||
u.ports = vec![(3000, 3000, "tcp".into(), String::new())];
|
||||
u.ports = vec![(3000, 3000, "tcp".into())];
|
||||
let s = u.render();
|
||||
assert!(s.contains("Network=host"));
|
||||
assert!(!s.contains("PublishPort"));
|
||||
@ -1010,7 +918,7 @@ mod tests {
|
||||
fn render_non_host_network_emits_publish_ports() {
|
||||
let mut u = sample_unit();
|
||||
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();
|
||||
assert!(s.contains("PublishPort=3000:3000/tcp"));
|
||||
}
|
||||
@ -1128,29 +1036,6 @@ mod tests {
|
||||
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]
|
||||
fn render_emits_backend_directives_when_set() {
|
||||
let u = QuadletUnit {
|
||||
@ -1160,10 +1045,7 @@ mod tests {
|
||||
network: NetworkMode::Bridge("archy-net".into()),
|
||||
cap_drop_all: true,
|
||||
cap_add: vec!["NET_BIND_SERVICE".into()],
|
||||
ports: vec![
|
||||
(8332, 8332, "tcp".into(), String::new()),
|
||||
(8333, 8333, "tcp".into(), String::new()),
|
||||
],
|
||||
ports: vec![(8332, 8332, "tcp".into()), (8333, 8333, "tcp".into())],
|
||||
environment: vec![
|
||||
"BITCOIN_RPC_USER=archipelago".into(),
|
||||
"BITCOIN_RPC_PASS=secret".into(),
|
||||
@ -1258,7 +1140,7 @@ app:
|
||||
assert!(u.read_only_root);
|
||||
assert!(u.no_new_privileges);
|
||||
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.bind_mounts.len(), 1);
|
||||
assert_eq!(
|
||||
@ -1482,22 +1364,7 @@ app:
|
||||
let h = translate_health_check(&http).expect("http must translate");
|
||||
assert_eq!(
|
||||
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"
|
||||
);
|
||||
|
||||
// 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)
|
||||
"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"
|
||||
);
|
||||
|
||||
let cmdck = HealthCheck {
|
||||
|
||||
@ -155,9 +155,7 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
.collect::<Vec<_>>();
|
||||
if changed {
|
||||
// Persist so the next load doesn't have to re-merge.
|
||||
if let Err(e) = save_registries(data_dir, &config).await {
|
||||
tracing::warn!("Failed to persist migrated registry config: {e:#}");
|
||||
}
|
||||
let _ = save_registries(data_dir, &config).await;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@ -25,26 +25,6 @@ pub trait ContainerOrchestrator: Send + Sync {
|
||||
/// to a manifest the orchestrator already knows about.
|
||||
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.
|
||||
async fn start(&self, app_id: &str) -> Result<()>;
|
||||
|
||||
|
||||
@ -49,46 +49,6 @@ pub fn is_recovery_complete() -> bool {
|
||||
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 ───────────────────────────────────────────────
|
||||
// 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.
|
||||
@ -218,17 +178,10 @@ pub async fn check_for_crash(data_dir: &Path) -> Result<Option<Vec<RunningContai
|
||||
old_pid
|
||||
);
|
||||
|
||||
// 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.
|
||||
// Check if that PID is actually still running (zombie/stuck process)
|
||||
if !old_pid.is_empty() {
|
||||
if let Ok(pid) = old_pid.parse::<u32>() {
|
||||
if pid != std::process::id()
|
||||
&& is_process_running(pid)
|
||||
&& process_is_archipelago(pid)
|
||||
{
|
||||
if is_process_running(pid) {
|
||||
warn!(
|
||||
"Previous process (PID {}) is still running — not a crash, skipping recovery",
|
||||
pid
|
||||
@ -358,8 +311,6 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
failed: Vec::new(),
|
||||
};
|
||||
|
||||
pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
|
||||
|
||||
for (i, record) in containers.iter().enumerate() {
|
||||
info!(
|
||||
"Recovering container: {} (image: {})",
|
||||
@ -422,7 +373,6 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
||||
if !started {
|
||||
report.failed.push(record.name.clone());
|
||||
}
|
||||
pending_boot_start_done(&record.name);
|
||||
}
|
||||
|
||||
report
|
||||
@ -441,16 +391,6 @@ fn is_process_running(pid: u32) -> bool {
|
||||
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.
|
||||
/// Runs on every startup to ensure containers come back after clean reboots.
|
||||
/// 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;
|
||||
|
||||
// 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 {
|
||||
if user_stopped.contains(*container) {
|
||||
info!("Skipping user-stopped container: {}", container);
|
||||
continue;
|
||||
}
|
||||
|
||||
let state = container_state(container).await;
|
||||
match state {
|
||||
Some(state) if state == "running" => {
|
||||
pending_boot_start_done(container);
|
||||
continue;
|
||||
}
|
||||
match container_state(container).await {
|
||||
Some(state) if state == "running" => continue,
|
||||
Some(_) => {}
|
||||
None => {
|
||||
pending_boot_start_done(container);
|
||||
continue;
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
|
||||
repair_stack_network_aliases(stack).await;
|
||||
@ -524,7 +446,6 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
||||
} else {
|
||||
report.failed.push((*container).to_string());
|
||||
}
|
||||
pending_boot_start_done(container);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
// operator can intentionally bring back a previously removed peer.
|
||||
// Propagate failure BEFORE mutating the node list: with the tombstone
|
||||
// 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")?;
|
||||
let _ = untombstone_did(data_dir, &node.did).await;
|
||||
nodes.push(node);
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
@ -184,16 +179,12 @@ pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode
|
||||
if nodes.len() == before {
|
||||
anyhow::bail!("No federated node with DID {}", did);
|
||||
}
|
||||
save_nodes(data_dir, &nodes).await?;
|
||||
// Tombstone the DID so transitive federation discovery (a still-federated
|
||||
// peer advertising this DID as one of *its* trusted peers) can't silently
|
||||
// re-add it. Tombstone FIRST and propagate failure: a remove whose
|
||||
// tombstone never landed isn't a remove — the peer would quietly
|
||||
// reappear after the next sync. Tombstoning is idempotent, so if the
|
||||
// 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?;
|
||||
// re-add it. Best-effort: a failed tombstone write must not fail the
|
||||
// remove the operator asked for.
|
||||
let _ = tombstone_did(data_dir, did).await;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
|
||||
@ -49,16 +49,13 @@ pub async fn sync_with_peer(
|
||||
|
||||
// Record transport used so the UI badge on this peer's card reflects
|
||||
// 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,
|
||||
Some(&peer.did),
|
||||
Some(&peer.onion),
|
||||
&transport.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to persist peer transport badge: {e:#}");
|
||||
}
|
||||
.await;
|
||||
|
||||
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
|
||||
let state_val = result
|
||||
|
||||
@ -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.
|
||||
struct RestartTracker {
|
||||
attempts: HashMap<String, u32>,
|
||||
@ -1072,19 +1057,6 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
||||
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
|
||||
if let Some(prev) = prev_tier {
|
||||
if tier > prev {
|
||||
@ -1484,30 +1456,6 @@ mod tests {
|
||||
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]
|
||||
fn test_container_tier_dependent() {
|
||||
assert_eq!(container_tier("lnd"), StartupTier::DependentService);
|
||||
|
||||
@ -26,7 +26,6 @@ use tokio::sync::Notify;
|
||||
use tracing::info;
|
||||
|
||||
mod api;
|
||||
mod app_ops;
|
||||
mod auth;
|
||||
mod avatar;
|
||||
mod backup;
|
||||
@ -99,15 +98,11 @@ async fn main() -> Result<()> {
|
||||
let startup_start = std::time::Instant::now();
|
||||
crash_recovery::init_start_time();
|
||||
|
||||
// Initialize tracing. Default to `info`: production units don't set
|
||||
// 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.
|
||||
// Initialize tracing
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
.unwrap_or_else(|_| "archipelago=debug,info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
@ -154,18 +149,13 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// Check for a crash marker BEFORE writing our own. The old order wrote
|
||||
// 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.
|
||||
// Write PID marker early so we can detect crashes on next startup
|
||||
crash_recovery::write_pid_marker(&config.data_dir).await?;
|
||||
|
||||
// Run crash recovery before starting the manifest reconciler. Both paths
|
||||
// mutate Podman; running them concurrently can corrupt transient runtime
|
||||
// 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)) => {
|
||||
info!(
|
||||
"🔧 Recovering {} containers from previous crash...",
|
||||
@ -222,10 +212,7 @@ async fn main() -> Result<()> {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(r)) => info!(
|
||||
"🛰️ app-catalog refreshed before manifest load ({} apps)",
|
||||
r.apps
|
||||
),
|
||||
Ok(Ok(n)) => info!("🛰️ app-catalog refreshed before manifest load ({n} apps)"),
|
||||
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)"),
|
||||
}
|
||||
|
||||
@ -580,9 +580,7 @@ pub(super) async fn handle_identity_received(
|
||||
// Update peer record
|
||||
let peer = MeshPeer {
|
||||
contact_id,
|
||||
// .get(): a malformed DID shorter than the "did:key:" prefix must
|
||||
// not panic the listener on a radio-supplied string.
|
||||
advert_name: format!("Archy-{}", did.get(8..16.min(did.len())).unwrap_or(did)),
|
||||
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
|
||||
did: Some(did.to_string()),
|
||||
pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||
// The advert signature was verified above, so this is an authenticated
|
||||
|
||||
@ -423,7 +423,6 @@ pub fn spawn_mesh_listener(
|
||||
lora_region: Option<String>,
|
||||
channel_name: Option<String>,
|
||||
device_kind: Option<super::types::DeviceType>,
|
||||
reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
|
||||
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: mpsc::Receiver<MeshCommand>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
@ -458,7 +457,6 @@ pub fn spawn_mesh_listener(
|
||||
lora_region.as_deref(),
|
||||
channel_name.as_deref(),
|
||||
device_kind,
|
||||
reticulum_tcp.clone(),
|
||||
&mut shutdown,
|
||||
&mut cmd_rx,
|
||||
)
|
||||
|
||||
@ -261,7 +261,7 @@ impl MeshRadioDevice {
|
||||
/// `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
|
||||
/// 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(
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: &str,
|
||||
@ -274,34 +274,6 @@ async fn auto_detect_and_open(
|
||||
}
|
||||
for path in &paths {
|
||||
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) {
|
||||
match MeshcoreDevice::open(path).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"),
|
||||
}
|
||||
}
|
||||
// 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!(
|
||||
"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 {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
|
||||
@ -412,55 +390,25 @@ async fn open_preferred_path(
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
|
||||
Err(e) => Err(e).context("Preferred path is not a working Meshtastic device"),
|
||||
Ok(info) => return Ok((MeshRadioDevice::Meshtastic(dev), info)),
|
||||
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,
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
)
|
||||
.await
|
||||
{
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
)
|
||||
.await
|
||||
.context("Could not open Reticulum TCP server interface")?
|
||||
}
|
||||
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:
|
||||
@ -679,19 +627,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
||||
advert_name: contact.advert_name.clone(),
|
||||
did: existing.and_then(|p| p.did.clone()),
|
||||
pubkey_hex: Some(contact.public_key_hex.clone()),
|
||||
// Reticulum carries the archipelago identity in-band with
|
||||
// the contact snapshot itself (see `ParsedContact::
|
||||
// arch_pubkey_hex`'s doc comment) — prefer it when this
|
||||
// refresh's snapshot has one. Otherwise preserve whatever
|
||||
// was bound by an earlier identity advert (Meshcore/
|
||||
// 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())),
|
||||
// Preserve any archipelago identity bound by an earlier
|
||||
// identity advert — NEVER overwrite it with the firmware
|
||||
// contact key, or a signed `!ai` query from this peer would
|
||||
// fail authentication after the next contact refresh.
|
||||
arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()),
|
||||
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
|
||||
// Meshtastic-only today (see ParsedContact) — falls back to
|
||||
// whatever was already known if this refresh's contact
|
||||
@ -847,17 +787,11 @@ pub(super) async fn run_mesh_session(
|
||||
lora_region: Option<&str>,
|
||||
channel_name: Option<&str>,
|
||||
device_kind: Option<DeviceType>,
|
||||
reticulum_tcp: Option<ReticulumTcpConfig>,
|
||||
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
||||
) -> Result<()> {
|
||||
// Detect device — TCP Reticulum config (radio-less) takes priority when
|
||||
// set, otherwise try the preferred serial path, falling back to
|
||||
// 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 {
|
||||
// Detect device — try preferred path first, fall back to auto-detect
|
||||
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
|
||||
match open_preferred_path(
|
||||
path,
|
||||
data_dir,
|
||||
|
||||
@ -180,12 +180,6 @@ impl MeshtasticDevice {
|
||||
"Failed to open serial port {} (permission denied? device busy?)",
|
||||
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");
|
||||
|
||||
Ok(Self {
|
||||
@ -982,7 +976,6 @@ impl MeshtasticDevice {
|
||||
snr,
|
||||
lat,
|
||||
lon,
|
||||
arch_pubkey_hex: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1055,7 +1048,6 @@ fn packet_to_inbound_frame(
|
||||
snr: None,
|
||||
lat: None,
|
||||
lon: None,
|
||||
arch_pubkey_hex: None,
|
||||
});
|
||||
if packet.rx_rssi.is_some() {
|
||||
contact.rssi = packet.rx_rssi.map(|v| v as i16);
|
||||
|
||||
@ -258,31 +258,7 @@ impl TypedEnvelope {
|
||||
}
|
||||
}
|
||||
|
||||
/// Signing preimage v2: binds the anti-replay `seq` so a radio MITM
|
||||
/// 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.
|
||||
/// Verify signature if present.
|
||||
pub fn verify_signature(&self, verifying_key: &ed25519_dalek::VerifyingKey) -> Result<bool> {
|
||||
let Some(sig_bytes) = &self.sig else {
|
||||
return Ok(false);
|
||||
@ -290,14 +266,13 @@ impl TypedEnvelope {
|
||||
let signature =
|
||||
ed25519_dalek::Signature::from_slice(sig_bytes).context("Invalid signature bytes")?;
|
||||
|
||||
if verifying_key
|
||||
.verify_strict(&self.signing_preimage_v2(), &signature)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
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());
|
||||
|
||||
verifying_key
|
||||
.verify_strict(&self.signing_preimage_v1(), &signature)
|
||||
.verify_strict(&sign_data, &signature)
|
||||
.context("Signature verification failed")?;
|
||||
Ok(true)
|
||||
}
|
||||
@ -309,25 +284,12 @@ impl TypedEnvelope {
|
||||
|
||||
/// Set the outbound sequence number. Called by the send path after the
|
||||
/// target's counter has been incremented. Safe to call AFTER `new_signed`
|
||||
/// because the v1 signature covers `(t, v, ts)` — not `seq`. Once the
|
||||
/// fleet is on a build whose `verify_signature` accepts the v2 preimage,
|
||||
/// flip senders to sign AFTER seq allocation via `signed_with_seq`.
|
||||
/// because the signature covers `(t, v, ts)` — not `seq`.
|
||||
pub fn with_seq(mut self, seq: u64) -> Self {
|
||||
self.seq = seq;
|
||||
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].
|
||||
pub fn to_wire(&self) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
@ -854,37 +816,6 @@ mod tests {
|
||||
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]
|
||||
fn test_invoice_payload_roundtrip() {
|
||||
let invoice = InvoicePayload {
|
||||
|
||||
@ -392,13 +392,6 @@ pub struct MeshConfig {
|
||||
/// strict-probe auto-detect.
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@ -429,7 +422,6 @@ impl Default for MeshConfig {
|
||||
assistant_backend: default_assistant_backend(),
|
||||
assistant_allowed_contacts: Vec::new(),
|
||||
device_kind: None,
|
||||
reticulum_tcp: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -712,7 +704,6 @@ impl MeshService {
|
||||
self.config.lora_region.clone(),
|
||||
self.config.channel_name.clone(),
|
||||
self.config.device_kind,
|
||||
self.config.reticulum_tcp.clone(),
|
||||
shutdown_rx,
|
||||
cmd_rx,
|
||||
);
|
||||
@ -829,9 +820,7 @@ impl MeshService {
|
||||
timestamp: header.timestamp,
|
||||
announced_by: bha_did.clone(),
|
||||
};
|
||||
if let Err(e) = bha_cache.store_header(payload).await {
|
||||
warn!("Failed to persist block-header cache: {e:#}");
|
||||
}
|
||||
let _ = bha_cache.store_header(payload).await;
|
||||
|
||||
// Build signed announcement and broadcast
|
||||
match bitcoin_relay::build_block_header_announcement(
|
||||
@ -2232,133 +2221,4 @@ mod tests {
|
||||
assert!(loaded.enabled);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -406,15 +406,6 @@ pub struct ParsedContact {
|
||||
/// contact has shared one.
|
||||
pub lat: 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.
|
||||
@ -466,7 +457,6 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
|
||||
snr: None,
|
||||
lat: None,
|
||||
lon: None,
|
||||
arch_pubkey_hex: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -47,14 +47,7 @@ const KISS_CMD_PLATFORM: u8 = 0x48;
|
||||
const KISS_CMD_MCU: u8 = 0x49;
|
||||
|
||||
const PROBE_BAUD: u32 = 115200;
|
||||
// 800ms was too tight for real hardware: confirmed on a genuine Heltec V4
|
||||
// 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);
|
||||
const PROBE_READ_TIMEOUT: Duration = Duration::from_millis(800);
|
||||
|
||||
/// Prefix marking an LXMF `content` string as base64 of a raw binary
|
||||
/// 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
|
||||
/// 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
|
||||
/// by the daemon in its announce app_data as `ARCHY:2:{ed}:{x25519}` — the
|
||||
/// 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.
|
||||
fn daemon_command(
|
||||
socket_path: &Path,
|
||||
iface: &ReticulumInterface<'_>,
|
||||
serial_port: &str,
|
||||
identity_key: &Path,
|
||||
archy_ed_pubkey_hex: Option<&str>,
|
||||
archy_x25519_pubkey_hex: Option<&str>,
|
||||
@ -139,72 +94,35 @@ fn daemon_command(
|
||||
cmd.arg("--identity-key")
|
||||
.arg(identity_key)
|
||||
.arg("--socket")
|
||||
.arg(socket_path);
|
||||
match iface {
|
||||
ReticulumInterface::Serial(path) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
.arg(socket_path)
|
||||
.arg("--serial-port")
|
||||
.arg(serial_port);
|
||||
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
|
||||
cmd.arg("--archy-ed-pubkey-hex")
|
||||
.arg(ed)
|
||||
.arg("--archy-x25519-pubkey-hex")
|
||||
.arg(x);
|
||||
}
|
||||
// Run the daemon as its own process-group leader. The packaged binary is
|
||||
// a PyInstaller one-file bootloader that forks the real Python process;
|
||||
// making it a group leader lets shutdown signal the WHOLE group so the
|
||||
// forked child can't be orphaned and keep holding the serial port.
|
||||
// Deliberately NOT `kill_on_drop`: that SIGKILLs the bootloader the instant
|
||||
// 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)
|
||||
cmd.kill_on_drop(true)
|
||||
// Run the daemon as its own process-group leader. The packaged binary is
|
||||
// a PyInstaller one-file bootloader that forks the real Python process;
|
||||
// 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.
|
||||
.process_group(0)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
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).
|
||||
#[derive(Clone)]
|
||||
struct ReticulumPeer {
|
||||
dest_hash: [u8; 16],
|
||||
display_name: String,
|
||||
/// Archy ed25519 identity hex, once carried in this peer's own announce
|
||||
/// app-data blob (`ARCHY:n:...`) — see `handle_event`'s "announce" arm.
|
||||
/// Unlike Meshcore/Meshtastic, this arrives in-band with the same event
|
||||
/// 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).
|
||||
/// Archy ed25519 identity hex, once carried in a verified announce
|
||||
/// app-data blob. Not yet wired (TODO, lands with the signed-announce
|
||||
/// work) — present so `get_contacts` has a stable shape to extend into.
|
||||
arch_pubkey_hex: Option<String>,
|
||||
reachable: bool,
|
||||
}
|
||||
@ -271,62 +189,11 @@ impl ReticulumLink {
|
||||
our_x25519_pubkey_hex: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
probe_rnode(path).await.context("RNode KISS detect failed")?;
|
||||
Self::spawn(
|
||||
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
|
||||
Self::spawn(path, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await
|
||||
}
|
||||
|
||||
async fn spawn(
|
||||
iface: ReticulumInterface<'_>,
|
||||
path: &str,
|
||||
data_dir: &Path,
|
||||
our_ed_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))
|
||||
.await;
|
||||
}
|
||||
let label = iface.label();
|
||||
let iface_key = label.replace(['/', ' ', ':', ','], "_");
|
||||
let socket_path = runtime_dir.join(format!("{iface_key}.sock"));
|
||||
let socket_path = runtime_dir.join(format!(
|
||||
"{}.sock",
|
||||
path.replace(['/', ' '], "_")
|
||||
));
|
||||
if socket_path.exists() {
|
||||
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");
|
||||
if !identity_key.exists() {
|
||||
anyhow::bail!(
|
||||
@ -372,70 +229,58 @@ impl ReticulumLink {
|
||||
|
||||
let mut cmd = daemon_command(
|
||||
&socket_path,
|
||||
&iface,
|
||||
path,
|
||||
&identity_key,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
);
|
||||
cmd.env("TMPDIR", &tmp_dir);
|
||||
let child = cmd
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.context("Failed to spawn reticulum-daemon — is it installed/packaged?")?;
|
||||
|
||||
// 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 stream = loop {
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
anyhow::bail!("reticulum-daemon did not create its RPC socket in time");
|
||||
}
|
||||
match UnixStream::connect(&socket_path).await {
|
||||
Ok(s) => break s,
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(150)).await,
|
||||
}
|
||||
};
|
||||
let (read_half, write_half) = stream.into_split();
|
||||
let mut reader = BufReader::new(read_half);
|
||||
|
||||
let mut line = String::new();
|
||||
tokio::time::timeout(Duration::from_secs(10), reader.read_line(&mut line))
|
||||
.await
|
||||
.context("Timed out waiting for reticulum-daemon ready event")?
|
||||
.context("reticulum-daemon RPC connection closed before ready")?;
|
||||
let ready: Value = serde_json::from_str(line.trim())
|
||||
.context("reticulum-daemon sent a non-JSON ready line")?;
|
||||
if ready.get("event").and_then(Value::as_str) != Some("ready") {
|
||||
anyhow::bail!("reticulum-daemon's first message was not 'ready': {ready}");
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
||||
let stream = loop {
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
let _ = child.start_kill();
|
||||
anyhow::bail!("reticulum-daemon did not create its RPC socket in time");
|
||||
}
|
||||
let dest_hash_hex = ready
|
||||
.get("dest_hash")
|
||||
.and_then(Value::as_str)
|
||||
.context("ready event missing dest_hash")?;
|
||||
let dest_hash = parse_hash16(dest_hash_hex)?;
|
||||
let display_name = ready
|
||||
.get("display_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
|
||||
info!(
|
||||
iface = %label,
|
||||
dest_hash = %dest_hash_hex,
|
||||
"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);
|
||||
match UnixStream::connect(&socket_path).await {
|
||||
Ok(s) => break s,
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(150)).await,
|
||||
}
|
||||
};
|
||||
let (read_half, write_half) = stream.into_split();
|
||||
let mut reader = BufReader::new(read_half);
|
||||
|
||||
let mut line = String::new();
|
||||
tokio::time::timeout(Duration::from_secs(10), reader.read_line(&mut line))
|
||||
.await
|
||||
.context("Timed out waiting for reticulum-daemon ready event")?
|
||||
.context("reticulum-daemon RPC connection closed before ready")?;
|
||||
let ready: Value = serde_json::from_str(line.trim())
|
||||
.context("reticulum-daemon sent a non-JSON ready line")?;
|
||||
if ready.get("event").and_then(Value::as_str) != Some("ready") {
|
||||
anyhow::bail!("reticulum-daemon's first message was not 'ready': {ready}");
|
||||
}
|
||||
let dest_hash_hex = ready
|
||||
.get("dest_hash")
|
||||
.and_then(Value::as_str)
|
||||
.context("ready event missing dest_hash")?;
|
||||
let dest_hash = parse_hash16(dest_hash_hex)?;
|
||||
let display_name = ready
|
||||
.get("display_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
|
||||
info!(
|
||||
path = %path,
|
||||
dest_hash = %dest_hash_hex,
|
||||
"Reticulum daemon ready"
|
||||
);
|
||||
|
||||
let mut link = Self {
|
||||
device_path: label,
|
||||
device_path: path.to_string(),
|
||||
socket_path,
|
||||
child,
|
||||
writer: write_half,
|
||||
@ -699,7 +544,6 @@ impl ReticulumLink {
|
||||
snr: None,
|
||||
lat: None,
|
||||
lon: None,
|
||||
arch_pubkey_hex: p.arch_pubkey_hex.clone(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@ -778,21 +622,17 @@ impl ReticulumLink {
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
// If the announce app_data is an ARCHY:n: identity blob (see
|
||||
// daemon_command's doc comment), bind the ed25519 hex directly
|
||||
// onto this RNS-hash-keyed peer below (unambiguous — it came
|
||||
// in the same event as `hash`) AND surface it through the same
|
||||
// channel-text path meshcore/Meshtastic identity adverts use
|
||||
// daemon_command's doc comment), bind it onto this peer AND
|
||||
// surface it through the SAME channel-text path
|
||||
// meshcore/Meshtastic identity adverts use
|
||||
// (frames::handle_channel_payload -> parse_identity_broadcast
|
||||
// -> handle_identity_received), so it also lands on that
|
||||
// transport's federation-twin peer for UI/contact purposes.
|
||||
// `group_peer_twins` can then collapse the two rows since both
|
||||
// now carry the same `arch_pubkey_hex`, instead of relying on
|
||||
// `bind_federation_twins`'s advert_name matching, which never
|
||||
// matches here — see `display_name` below.
|
||||
let parsed_identity = app_data_text
|
||||
// -> handle_identity_received -> bind_federation_twins), so a
|
||||
// Reticulum-carried identity merges into the same conversation
|
||||
// as that node's other-transport twins — zero new bind logic.
|
||||
let is_identity_blob = app_data_text
|
||||
.as_deref()
|
||||
.and_then(protocol::parse_identity_broadcast);
|
||||
let is_identity_blob = parsed_identity.is_some();
|
||||
.map(|t| protocol::parse_identity_broadcast(t).is_some())
|
||||
.unwrap_or(false);
|
||||
if is_identity_blob {
|
||||
let text = app_data_text.clone().unwrap();
|
||||
let mut data = Vec::with_capacity(7 + text.len());
|
||||
@ -805,7 +645,6 @@ impl ReticulumLink {
|
||||
bytes_consumed: 0,
|
||||
});
|
||||
}
|
||||
let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
|
||||
|
||||
let display_name = app_data_text
|
||||
.filter(|_| !is_identity_blob)
|
||||
@ -815,14 +654,11 @@ impl ReticulumLink {
|
||||
.and_modify(|p| {
|
||||
p.display_name = display_name.clone();
|
||||
p.reachable = true;
|
||||
if arch_pubkey_hex.is_some() {
|
||||
p.arch_pubkey_hex = arch_pubkey_hex.clone();
|
||||
}
|
||||
})
|
||||
.or_insert(ReticulumPeer {
|
||||
dest_hash: hash,
|
||||
display_name,
|
||||
arch_pubkey_hex,
|
||||
arch_pubkey_hex: None,
|
||||
reachable: true,
|
||||
});
|
||||
self.persist_peers();
|
||||
@ -1068,14 +904,6 @@ fn parse_hash16(hex_str: &str) -> Result<[u8; 16]> {
|
||||
async fn probe_rnode(path: &str) -> Result<()> {
|
||||
let port = serial2_tokio::SerialPort::open(path, PROBE_BAUD)
|
||||
.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] = [
|
||||
KISS_FEND,
|
||||
KISS_CMD_DETECT,
|
||||
@ -1122,12 +950,22 @@ fn contains_detect_resp(buf: &[u8]) -> bool {
|
||||
|
||||
impl Drop for ReticulumLink {
|
||||
fn drop(&mut self) {
|
||||
// Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`).
|
||||
// The old immediate SIGTERM+SIGKILL never let the PyInstaller bootloader
|
||||
// run its exit cleanup, stranding a 48M `_MEI*` extraction dir on every
|
||||
// reconnect/restart until /tmp filled; the grace period fixes that, and
|
||||
// the per-spawn TMPDIR wipe in `spawn` covers any dir that still leaks.
|
||||
terminate_group(&self.child);
|
||||
// The packaged daemon is a PyInstaller one-file bootloader that forks the
|
||||
// real Python process into the same (leader) group we spawned it in.
|
||||
// SIGKILLing only the bootloader (what `start_kill`/`kill_on_drop` do)
|
||||
// orphans that child, which keeps holding the serial port — the root
|
||||
// cause of daemons piling up across reconnects and jamming the RNode.
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,9 +176,7 @@ async fn fire_due(scheduler: &Arc<MeshScheduler>, state: &Arc<MeshState>) {
|
||||
}
|
||||
q.retain(|m| !to_remove.contains(&m.id));
|
||||
}
|
||||
if let Err(e) = scheduler.save().await {
|
||||
warn!("Failed to persist mesh outbox after sweep: {e:#}");
|
||||
}
|
||||
let _ = scheduler.save().await;
|
||||
}
|
||||
|
||||
/// Hand a due message to the radio. Returns true if it was sent (or should be
|
||||
|
||||
@ -57,12 +57,6 @@ impl MeshcoreDevice {
|
||||
"Failed to open serial port {} (permission denied? device busy?)",
|
||||
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");
|
||||
|
||||
|
||||
@ -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
|
||||
/// active device's own name, since one session owns exactly one device.
|
||||
/// Federation sends/receives are labelled "fips"/"tor" elsewhere — this only
|
||||
|
||||
@ -267,10 +267,7 @@ impl Server {
|
||||
info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices);
|
||||
mesh_config.enabled = true;
|
||||
mesh_config.device_path = Some(devices[0].clone());
|
||||
if let Err(e) = crate::mesh::save_config(&data_dir, &mesh_config).await
|
||||
{
|
||||
warn!("Failed to persist auto-detected mesh config: {e:#}");
|
||||
}
|
||||
let _ = crate::mesh::save_config(&data_dir, &mesh_config).await;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1034,13 +1031,9 @@ async fn accept_loop(
|
||||
let permit = active_connections.clone().acquire_owned().await;
|
||||
tokio::spawn(async move {
|
||||
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();
|
||||
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()) {
|
||||
let resp = hyper::Response::builder()
|
||||
.status(hyper::StatusCode::NOT_FOUND)
|
||||
@ -1184,30 +1177,6 @@ fn merge_preserving_transitional(
|
||||
{
|
||||
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
|
||||
// failed or Archipelago restarted before the spawned task could revert
|
||||
// state. Let the scanner recover the UI immediately instead of
|
||||
@ -1234,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 {
|
||||
let msg = format!("{:#}", error);
|
||||
msg.contains("podman ps") && msg.contains("timed out")
|
||||
@ -1269,31 +1223,6 @@ async fn scan_and_update_packages(
|
||||
pkg.state = crate::data_model::PackageState::Stopped;
|
||||
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;
|
||||
|
||||
@ -1344,19 +1273,6 @@ async fn scan_and_update_packages(
|
||||
absence_tracker.remove(id);
|
||||
let existing = merged.get(id);
|
||||
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) => {
|
||||
let entered = *transitional_since.entry(id.clone()).or_insert(now);
|
||||
let timeout = transitional_stuck_timeout(&existing_entry.state);
|
||||
@ -1738,49 +1654,6 @@ mod merge_tests {
|
||||
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]
|
||||
fn merges_fresh_observability_fields() {
|
||||
// Non-state observability fields (health, exit_code, installed)
|
||||
|
||||
@ -23,36 +23,26 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
/// TTL keeps the result responsive to daemon flaps without pounding DBus.
|
||||
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 {
|
||||
identity_dir: PathBuf,
|
||||
availability: std::sync::Arc<AvailabilityCache>,
|
||||
available_cached: AtomicBool,
|
||||
available_cached_at_ms: AtomicU64,
|
||||
}
|
||||
|
||||
impl FipsTransport {
|
||||
pub fn new(identity_dir: &Path) -> Self {
|
||||
Self {
|
||||
identity_dir: identity_dir.to_path_buf(),
|
||||
availability: std::sync::Arc::new(AvailabilityCache {
|
||||
available: AtomicBool::new(false),
|
||||
probed_at_ms: AtomicU64::new(0),
|
||||
probe_in_flight: AtomicBool::new(false),
|
||||
}),
|
||||
available_cached: AtomicBool::new(false),
|
||||
available_cached_at_ms: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_daemon_active() -> bool {
|
||||
// Blocking probe — only ever run on a dedicated background thread
|
||||
// (see is_available), never on a tokio worker. Short-circuit if
|
||||
// either the archipelago-managed unit or the upstream fips.service
|
||||
// is active — legacy/dev nodes run only the upstream unit.
|
||||
// Cheap blocking probe: spawn `systemctl is-active` synchronously.
|
||||
// Short-circuit if either the archipelago-managed unit or the
|
||||
// upstream fips.service is active — legacy/dev nodes run only the
|
||||
// upstream unit.
|
||||
for unit in [
|
||||
crate::fips::SERVICE_UNIT,
|
||||
crate::fips::UPSTREAM_SERVICE_UNIT,
|
||||
@ -80,30 +70,14 @@ impl NodeTransport for FipsTransport {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let cached_at = self.availability.probed_at_ms.load(Ordering::Relaxed);
|
||||
let cached = self.availability.available.load(Ordering::Relaxed);
|
||||
let cached_at = self.available_cached_at_ms.load(Ordering::Relaxed);
|
||||
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 probed_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.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
|
||||
let val = Self::probe_daemon_active();
|
||||
self.available_cached.store(val, Ordering::Relaxed);
|
||||
self.available_cached_at_ms.store(now_ms, Ordering::Relaxed);
|
||||
val
|
||||
}
|
||||
|
||||
fn send<'a>(
|
||||
|
||||
@ -16,13 +16,9 @@ use ed25519_dalek::VerifyingKey;
|
||||
|
||||
/// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars).
|
||||
///
|
||||
/// Pinned 2026-07-02 from the release-root signing ceremony
|
||||
/// (signer did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). The
|
||||
/// corresponding mnemonic is held offline by the publisher — see
|
||||
/// `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");
|
||||
/// TODO(dht Phase 0): bake the real value here after the signing ceremony.
|
||||
/// Generate it with: `scripts/release-root-ceremony.sh pubkey`.
|
||||
pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> = None;
|
||||
|
||||
const ENV_OVERRIDE: &str = "ARCHY_RELEASE_ROOT_PUBKEY";
|
||||
|
||||
@ -55,14 +51,9 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pinned_constant_parses_to_a_valid_key() {
|
||||
// The release-root anchor is pinned (ceremony 2026-07-02); it must be
|
||||
// present and a well-formed 32-byte Ed25519 key.
|
||||
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"
|
||||
);
|
||||
fn unset_constant_is_none() {
|
||||
// Default build ships no pinned anchor yet.
|
||||
assert!(RELEASE_ROOT_PUBKEY_HEX.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -149,18 +149,6 @@ mod tests {
|
||||
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]
|
||||
fn unsigned_document_reports_unsigned() {
|
||||
let doc = json!({"schema": 1, "apps": {}});
|
||||
@ -168,31 +156,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_verifies_and_anchors_to_pinned_key() {
|
||||
// With the anchor pinned to the signer, verification succeeds AND
|
||||
// reports anchored == true (signer identity confirmed).
|
||||
pin_test_key_as_anchor();
|
||||
fn roundtrip_verifies() {
|
||||
let signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn signature_survives_key_reordering() {
|
||||
// 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 reparsed: Value =
|
||||
serde_json::from_str(&serde_json::to_string(&signed).unwrap()).unwrap();
|
||||
@ -204,9 +179,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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}));
|
||||
signed
|
||||
.as_object_mut()
|
||||
|
||||
@ -160,9 +160,7 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
|
||||
force_ovh_update_primary(&mut list);
|
||||
changed = changed || before_order != list.iter().map(|m| m.url.clone()).collect::<Vec<_>>();
|
||||
if changed {
|
||||
if let Err(e) = save_mirrors(data_dir, &list).await {
|
||||
warn!("Failed to persist migrated update-mirrors list: {e:#}");
|
||||
}
|
||||
let _ = save_mirrors(data_dir, &list).await;
|
||||
}
|
||||
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,
|
||||
/// otherwise the first entry in the mirrors list, otherwise the hard
|
||||
/// 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).
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@ -436,7 +406,6 @@ impl Default for UpdateState {
|
||||
rollback_available: false,
|
||||
schedule: UpdateSchedule::DailyCheck,
|
||||
manifest_mirror: None,
|
||||
manifest_signed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -513,64 +482,8 @@ async fn probe_frontend_once() -> Result<()> {
|
||||
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
|
||||
/// present, verify the node actually works on the new version — binary
|
||||
/// version matches the marker, frontend serves, backend RPC answers,
|
||||
/// rootless podman is reachable. On failure, trigger rollback and
|
||||
/// present, probe the frontend; on failure, trigger rollback and
|
||||
/// restart the service so the OLD binary boots.
|
||||
///
|
||||
/// This is the "post-OTA auto-rollback" guardrail. If ANY problem in
|
||||
@ -603,59 +516,34 @@ pub async fn verify_pending_update(data_dir: &Path) {
|
||||
info!(
|
||||
new_version = %marker.new_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");
|
||||
// Give the new service time to bind its listeners + nginx to
|
||||
// pick up any config changes. 15s matches what we observed on
|
||||
// .116 during the v1.7.40 rollout recovery.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
|
||||
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(PENDING_VERIFY_WINDOW_SECS);
|
||||
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
|
||||
// pick up any config changes. 15s matches what we observed on
|
||||
// .116 during the v1.7.40 rollout recovery.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
|
||||
|
||||
let deadline = std::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(PENDING_VERIFY_WINDOW_SECS);
|
||||
|
||||
while std::time::Instant::now() < deadline {
|
||||
attempt += 1;
|
||||
// All three must pass in the same attempt: static frontend via
|
||||
// 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(()) => {
|
||||
info!(attempt, "Post-OTA verification succeeded — clearing marker");
|
||||
clear_pending_verification(data_dir).await;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
tracing::warn!(attempt, error = %msg, "Post-OTA probe failed, retrying");
|
||||
last_err = Some(msg);
|
||||
}
|
||||
while std::time::Instant::now() < deadline {
|
||||
attempt += 1;
|
||||
match probe_frontend_once().await {
|
||||
Ok(()) => {
|
||||
info!(attempt, "Post-OTA verification succeeded — clearing marker");
|
||||
clear_pending_verification(data_dir).await;
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
tracing::warn!(attempt, error = %msg, "Post-OTA probe failed, retrying");
|
||||
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!(
|
||||
@ -733,7 +621,6 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
|
||||
// if there's genuinely something newer.
|
||||
state.available_update = None;
|
||||
state.manifest_mirror = None;
|
||||
state.manifest_signed = false;
|
||||
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;
|
||||
}
|
||||
match client.get(manifest_url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => match resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(parse_and_verify_manifest)
|
||||
Ok(resp) if resp.status().is_success() => match resp.json::<UpdateManifest>().await
|
||||
{
|
||||
Ok((mut manifest, signed)) => {
|
||||
Ok(mut manifest) => {
|
||||
rewrite_manifest_origins(&mut manifest, manifest_url);
|
||||
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!(
|
||||
current = %state.current_version,
|
||||
available = %manifest.version,
|
||||
mirror = %manifest_url,
|
||||
signed,
|
||||
"Update available"
|
||||
);
|
||||
state.available_update = Some(manifest);
|
||||
state.manifest_mirror = Some(manifest_url.clone());
|
||||
state.manifest_signed = signed;
|
||||
} else {
|
||||
// Manifest version matches us or is behind
|
||||
// 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.available_update = None;
|
||||
state.manifest_signed = false;
|
||||
handled = true;
|
||||
continue 'mirrors;
|
||||
}
|
||||
@ -1258,22 +1130,14 @@ async fn download_component_resumable(
|
||||
}
|
||||
}
|
||||
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;
|
||||
drop(file);
|
||||
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
|
||||
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
|
||||
anyhow::bail!("Download canceled");
|
||||
}
|
||||
// A failed flush/sync surfaces later as a size/sha mismatch and a
|
||||
// resume, but log it — this is where ENOSPC first shows itself.
|
||||
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:#}");
|
||||
}
|
||||
let _ = file.flush().await;
|
||||
let _ = file.sync_all().await;
|
||||
drop(file);
|
||||
if stream_err {
|
||||
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 state.update_in_progress {
|
||||
state.update_in_progress = false;
|
||||
if let Err(e) = save_state(data_dir, &state).await {
|
||||
warn!("Failed to persist cleared update_in_progress: {e:#}");
|
||||
}
|
||||
let _ = save_state(data_dir, &state).await;
|
||||
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(
|
||||
data_dir: std::path::PathBuf,
|
||||
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"
|
||||
// badges appear without waiting for the first hourly tick.
|
||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
|
||||
Err(e) => debug!(
|
||||
if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
debug!(
|
||||
"Update scheduler: initial app-catalog refresh failed: {}",
|
||||
e
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
loop {
|
||||
@ -1940,11 +1784,8 @@ pub async fn run_update_scheduler(
|
||||
// populates per-app update availability (the "Update" button still has
|
||||
// to be clicked — nothing auto-applies). Best-effort; on failure the
|
||||
// previously cached catalog stays in place (origin-always-wins).
|
||||
// A changed catalog also reloads the orchestrator's manifest overlay so
|
||||
// catalog-shipped manifest fixes apply without a service restart.
|
||||
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),
|
||||
if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
||||
debug!("Update scheduler: app-catalog refresh failed: {}", e);
|
||||
}
|
||||
|
||||
// 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");
|
||||
match check_for_updates(&data_dir).await {
|
||||
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");
|
||||
if let Err(e) = download_update(&data_dir).await {
|
||||
debug!("Update scheduler: download failed: {}", e);
|
||||
@ -2060,68 +1884,6 @@ pub async fn run_update_scheduler(
|
||||
mod tests {
|
||||
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]
|
||||
fn test_update_schedule_default_is_daily_check() {
|
||||
let schedule = UpdateSchedule::default();
|
||||
@ -2281,7 +2043,6 @@ mod tests {
|
||||
rollback_available: true,
|
||||
schedule: UpdateSchedule::AutoApply,
|
||||
manifest_mirror: None,
|
||||
manifest_signed: false,
|
||||
};
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let deserialized: UpdateState = serde_json::from_str(&json).unwrap();
|
||||
@ -2409,7 +2170,6 @@ mod tests {
|
||||
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||
.to_string(),
|
||||
),
|
||||
manifest_signed: false,
|
||||
};
|
||||
save_state(dir.path(), &state).await.unwrap();
|
||||
let loaded = load_state(dir.path()).await.unwrap();
|
||||
|
||||
@ -19,8 +19,6 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
log = "0.4"
|
||||
tracing = "0.1"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[lib]
|
||||
name = "archipelago_container"
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
pub mod bitcoin_simulator;
|
||||
pub mod health_monitor;
|
||||
pub mod image_verify;
|
||||
pub mod manifest;
|
||||
pub mod podman_client;
|
||||
pub mod port_manager;
|
||||
|
||||
@ -243,22 +243,6 @@ pub struct ContainerConfig {
|
||||
/// Example: `"100070:100070"` for Postgres' mapped subuid.
|
||||
#[serde(default)]
|
||||
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
|
||||
@ -281,75 +265,6 @@ pub struct SecretEnv {
|
||||
pub secret_file: String,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// (so the orchestrator knows which files to expect) but random in value.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@ -503,13 +418,6 @@ pub struct PortMapping {
|
||||
pub container: u16,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@ -518,38 +426,10 @@ impl From<(u16, u16)> for PortMapping {
|
||||
host,
|
||||
container,
|
||||
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)]
|
||||
pub struct Volume {
|
||||
#[serde(rename = "type")]
|
||||
@ -1010,16 +890,7 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
|
||||
"ports[{i}].protocol must be tcp or udp"
|
||||
)));
|
||||
}
|
||||
if !port.bind.is_empty() && port.bind.parse::<std::net::IpAddr>().is_err() {
|
||||
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())) {
|
||||
if !seen_host.insert((port.host, protocol.to_string())) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports contains duplicate host binding {}/{}",
|
||||
port.host, protocol
|
||||
@ -1338,19 +1209,6 @@ impl ContainerConfig {
|
||||
&self,
|
||||
provider: &dyn SecretsProvider,
|
||||
) -> 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());
|
||||
for e in &self.secret_env {
|
||||
let v = provider.read(&e.secret_file)?;
|
||||
@ -1362,28 +1220,12 @@ impl ContainerConfig {
|
||||
e.key, e.secret_file
|
||||
)));
|
||||
}
|
||||
out.push((e.key.clone(), v));
|
||||
out.push(format!("{}={}", e.key, v));
|
||||
}
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -1391,72 +1233,6 @@ mod tests {
|
||||
use std::fs;
|
||||
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]
|
||||
fn hooks_parse_and_validate() {
|
||||
let yaml = r#"
|
||||
@ -1989,8 +1765,6 @@ app:
|
||||
generated_secrets: vec![],
|
||||
generated_certs: vec![],
|
||||
data_uid: None,
|
||||
secret_env_refs: vec![],
|
||||
secret_env_hash: None,
|
||||
};
|
||||
let facts = HostFacts {
|
||||
host_ip: "192.168.1.116".to_string(),
|
||||
@ -2043,8 +1817,6 @@ app:
|
||||
generated_secrets: vec![],
|
||||
generated_certs: vec![],
|
||||
data_uid: None,
|
||||
secret_env_refs: vec![],
|
||||
secret_env_hash: None,
|
||||
};
|
||||
let p = MapSecretsProvider {
|
||||
data: HashMap::from([
|
||||
@ -2083,8 +1855,6 @@ app:
|
||||
generated_secrets: vec![],
|
||||
generated_certs: vec![],
|
||||
data_uid: None,
|
||||
secret_env_refs: vec![],
|
||||
secret_env_hash: None,
|
||||
};
|
||||
let p = MapSecretsProvider {
|
||||
data: HashMap::from([("bitcoin-rpc-password".to_string(), " \n".to_string())]),
|
||||
@ -2189,35 +1959,6 @@ app:
|
||||
"#,
|
||||
"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",
|
||||
r#"
|
||||
@ -2257,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]
|
||||
fn reviewed_host_bind_exceptions_parse() {
|
||||
let yaml = r#"
|
||||
|
||||
@ -252,17 +252,7 @@ impl PodmanClient {
|
||||
|
||||
// ─── Container Operations ────────────────────────────────────
|
||||
|
||||
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?;
|
||||
|
||||
pub async fn pull_image(&self, image: &str, _signature: Option<&str>) -> Result<()> {
|
||||
// Image pull uses CLI — it's a streaming operation that the API handles differently
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.arg("pull");
|
||||
@ -291,15 +281,6 @@ impl PodmanClient {
|
||||
// Build the container spec for the API
|
||||
let mut port_mappings = Vec::new();
|
||||
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
|
||||
// is 3478/udp; forcing tcp here would publish the wrong protocol and
|
||||
// silently break relay discovery.
|
||||
@ -308,15 +289,11 @@ impl PodmanClient {
|
||||
"sctp" => "sctp",
|
||||
_ => "tcp",
|
||||
};
|
||||
let mut mapping = serde_json::json!({
|
||||
port_mappings.push(serde_json::json!({
|
||||
"container_port": port.container,
|
||||
"host_port": port.host,
|
||||
"protocol": protocol,
|
||||
});
|
||||
if !port.bind.is_empty() {
|
||||
mapping["host_ip"] = serde_json::json!(port.bind);
|
||||
}
|
||||
port_mappings.push(mapping);
|
||||
}));
|
||||
}
|
||||
|
||||
let mut mounts = Vec::new();
|
||||
@ -395,32 +372,12 @@ impl PodmanClient {
|
||||
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!({
|
||||
"name": name,
|
||||
"image": image_ref,
|
||||
"portmappings": port_mappings,
|
||||
"mounts": mounts,
|
||||
"env": env_map,
|
||||
"secret_env": secret_env_map,
|
||||
"labels": labels_map,
|
||||
"entrypoint": manifest.app.container.entrypoint.clone(),
|
||||
"command": manifest.app.container.custom_args.clone(),
|
||||
"hostadd": [
|
||||
|
||||
@ -2,6 +2,7 @@ use crate::manifest::{AppManifest, BuildConfig};
|
||||
use crate::podman_client::{ContainerState, ContainerStatus, PodmanClient};
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
@ -82,18 +83,6 @@ pub trait ContainerRuntime: Send + Sync {
|
||||
/// `create_container` / `image_exists` calls. Stdout/stderr are collected
|
||||
/// and included in the error on failure; on success they are discarded.
|
||||
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 {
|
||||
@ -143,68 +132,6 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
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(
|
||||
&self,
|
||||
manifest: &AppManifest,
|
||||
@ -572,8 +499,6 @@ fn is_missing_container_error(stderr: &str) -> bool {
|
||||
let stderr = stderr.to_ascii_lowercase();
|
||||
stderr.contains("no container with name or id")
|
||||
|| 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("not found")
|
||||
}
|
||||
@ -622,12 +547,7 @@ impl DockerRuntime {
|
||||
|
||||
#[async_trait]
|
||||
impl ContainerRuntime for DockerRuntime {
|
||||
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?;
|
||||
|
||||
async fn pull_image(&self, image: &str, _signature: Option<&str>) -> Result<()> {
|
||||
let mut cmd = self.docker_async();
|
||||
cmd.arg("pull").arg(image);
|
||||
|
||||
@ -697,12 +617,6 @@ impl ContainerRuntime for DockerRuntime {
|
||||
for env in &manifest.app.environment {
|
||||
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
|
||||
if let Some(cpu) = manifest.app.resources.cpu_limit {
|
||||
@ -939,11 +853,11 @@ pub struct AutoRuntime {
|
||||
impl AutoRuntime {
|
||||
pub async fn new(user: String) -> Result<Self> {
|
||||
// Try Podman first
|
||||
if Self::check_podman_available().await {
|
||||
if Self::check_podman_available() {
|
||||
Ok(Self {
|
||||
runtime: Box::new(PodmanRuntime::new(user)),
|
||||
})
|
||||
} else if Self::check_docker_available().await {
|
||||
} else if Self::check_docker_available() {
|
||||
Ok(Self {
|
||||
runtime: Box::new(DockerRuntime::new(user)),
|
||||
})
|
||||
@ -952,20 +866,12 @@ impl AutoRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_podman_available() -> bool {
|
||||
TokioCommand::new("podman")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.is_ok()
|
||||
fn check_podman_available() -> bool {
|
||||
Command::new("podman").arg("--version").output().is_ok()
|
||||
}
|
||||
|
||||
async fn check_docker_available() -> bool {
|
||||
TokioCommand::new("docker")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.is_ok()
|
||||
fn check_docker_available() -> bool {
|
||||
Command::new("docker").arg("--version").output().is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
@ -975,10 +881,6 @@ impl ContainerRuntime for AutoRuntime {
|
||||
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(
|
||||
&self,
|
||||
manifest: &AppManifest,
|
||||
@ -1037,16 +939,6 @@ mod tests {
|
||||
use super::*;
|
||||
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 {
|
||||
BuildConfig {
|
||||
context: context.to_string(),
|
||||
|
||||
@ -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"
|
||||
@ -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)
|
||||
}
|
||||
@ -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;
|
||||
@ -1,90 +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(())
|
||||
}
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
use anyhow::{Context, Result};
|
||||
use ssh2::Session;
|
||||
use std::io::Read;
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
use anyhow::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.
|
||||
///
|
||||
/// Maps TIP-01 / TIP-02 fields onto UCI keys used by tollgate-module-basic-go.
|
||||
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(())
|
||||
}
|
||||
@ -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 5–8 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(())
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
pub mod config;
|
||||
pub mod install;
|
||||
pub mod wifi;
|
||||
|
||||
pub use config::TollGateConfig;
|
||||
pub use install::install_tollgate;
|
||||
pub use wifi::provision_ssid;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{opkg::PkgManager, Router};
|
||||
|
||||
/// Full TollGate provisioning sequence:
|
||||
/// 1. Install tollgate-module-basic-go
|
||||
/// 2. Write TollGate UCI config (pricing, mint URL)
|
||||
/// 3. Create the pay-as-you-go WiFi SSID
|
||||
/// 4. 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)?;
|
||||
}
|
||||
}
|
||||
config::apply(router, config)?;
|
||||
wifi::provision_ssid(router, config)?;
|
||||
restart_services(router, config.enabled)?;
|
||||
|
||||
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")?;
|
||||
Ok(())
|
||||
}
|
||||
@ -1,109 +0,0 @@
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
use crate::tollgate::TollGateConfig;
|
||||
use crate::Router;
|
||||
|
||||
/// Create (or update) the dedicated pay-as-you-go WiFi interface for TollGate.
|
||||
///
|
||||
/// Uses a fixed named section (`wireless.tollgate`) rather than `uci add`, so
|
||||
/// re-provisioning (e.g. editing price/mint URL after install) updates the
|
||||
/// same interface in place instead of piling up a new `wifi-iface` section —
|
||||
/// and therefore a new duplicate broadcast SSID — on every call.
|
||||
pub fn provision_ssid(router: &Router, cfg: &TollGateConfig) -> Result<()> {
|
||||
let radio = detect_radio(router).context("detect WiFi radio")?;
|
||||
info!("[{}] Using radio {} for TollGate SSID", router.host, radio);
|
||||
|
||||
router.uci_apply(
|
||||
"wireless",
|
||||
&[
|
||||
("wireless.tollgate", "wifi-iface"),
|
||||
("wireless.tollgate.device", &radio),
|
||||
("wireless.tollgate.mode", "ap"),
|
||||
("wireless.tollgate.ssid", &cfg.ssid),
|
||||
("wireless.tollgate.encryption", "none"),
|
||||
("wireless.tollgate.network", "tollgate"),
|
||||
// Disable 802.11r/k/v — unnecessary for transient pay-as-you-go clients.
|
||||
("wireless.tollgate.ieee80211r", "0"),
|
||||
// Stop broadcasting entirely when disabled, rather than leaving an
|
||||
// open SSID up that leads nowhere once the backend is stopped.
|
||||
("wireless.tollgate.disabled", if cfg.enabled { "0" } else { "1" }),
|
||||
],
|
||||
)?;
|
||||
|
||||
provision_network(router)?;
|
||||
provision_firewall(router)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a `tollgate` network interface (isolated LAN for TollGate clients).
|
||||
fn provision_network(router: &Router) -> Result<()> {
|
||||
router.uci_apply(
|
||||
"network",
|
||||
&[
|
||||
("network.tollgate", "interface"),
|
||||
("network.tollgate.proto", "static"),
|
||||
("network.tollgate.ipaddr", "192.168.99.1"),
|
||||
("network.tollgate.netmask", "255.255.255.0"),
|
||||
],
|
||||
)?;
|
||||
|
||||
// Enable DHCP for the tollgate interface.
|
||||
router.uci_apply(
|
||||
"dhcp",
|
||||
&[
|
||||
("dhcp.tollgate", "dhcp"),
|
||||
("dhcp.tollgate.interface", "tollgate"),
|
||||
("dhcp.tollgate.start", "100"),
|
||||
("dhcp.tollgate.limit", "150"),
|
||||
("dhcp.tollgate.leasetime", "5m"),
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add firewall zone for the tollgate interface.
|
||||
///
|
||||
/// TollGate itself gates forwarding via iptables; the firewall zone isolates
|
||||
/// tollgate clients from other LAN segments.
|
||||
fn provision_firewall(router: &Router) -> Result<()> {
|
||||
// Zone
|
||||
router.uci_apply(
|
||||
"firewall",
|
||||
&[
|
||||
("firewall.tollgate_zone", "zone"),
|
||||
("firewall.tollgate_zone.name", "tollgate"),
|
||||
("firewall.tollgate_zone.network", "tollgate"),
|
||||
("firewall.tollgate_zone.input", "ACCEPT"),
|
||||
("firewall.tollgate_zone.output", "ACCEPT"),
|
||||
("firewall.tollgate_zone.forward", "REJECT"),
|
||||
],
|
||||
)?;
|
||||
|
||||
// Forwarding rule: tollgate → wan (TollGate manages which clients can forward)
|
||||
router.uci_apply(
|
||||
"firewall",
|
||||
&[
|
||||
("firewall.tollgate_fwd", "forwarding"),
|
||||
("firewall.tollgate_fwd.src", "tollgate"),
|
||||
("firewall.tollgate_fwd.dest", "wan"),
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the first available wireless radio device name (e.g. "radio0").
|
||||
fn detect_radio(router: &Router) -> Result<String> {
|
||||
let out = router.run_ok("uci show wireless | grep -o 'wireless\\.radio[0-9]*\\.type' | head -1")?;
|
||||
// Extract "radioN" from "wireless.radioN.type"
|
||||
let radio = out
|
||||
.trim()
|
||||
.split('.')
|
||||
.nth(1)
|
||||
.unwrap_or("radio0")
|
||||
.to_string();
|
||||
Ok(radio)
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::Router;
|
||||
|
||||
/// Thin wrappers around `uci` CLI commands over SSH.
|
||||
impl Router {
|
||||
/// `uci get <key>` — returns trimmed value.
|
||||
pub fn uci_get(&self, key: &str) -> Result<String> {
|
||||
let out = self.run_ok(&format!("uci get {}", key))?;
|
||||
Ok(out.trim().to_string())
|
||||
}
|
||||
|
||||
/// `uci set <key>=<value>`
|
||||
pub fn uci_set(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.run_ok(&format!("uci set {}={}", key, shell_quote(value)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `uci add <config> <type>` — returns the new section name.
|
||||
pub fn uci_add(&self, config: &str, section_type: &str) -> Result<String> {
|
||||
let out = self.run_ok(&format!("uci add {} {}", config, section_type))?;
|
||||
Ok(out.trim().to_string())
|
||||
}
|
||||
|
||||
/// `uci add_list <key>=<value>`
|
||||
pub fn uci_add_list(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.run_ok(&format!("uci add_list {}={}", key, shell_quote(value)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `uci delete <key>`
|
||||
pub fn uci_delete(&self, key: &str) -> Result<()> {
|
||||
self.run_ok(&format!("uci delete {}", key))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `uci commit [<config>]`
|
||||
pub fn uci_commit(&self, config: Option<&str>) -> Result<()> {
|
||||
match config {
|
||||
Some(c) => self.run_ok(&format!("uci commit {}", c))?,
|
||||
None => self.run_ok("uci commit")?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Batch: apply a list of `(key, value)` pairs then commit the config.
|
||||
pub fn uci_apply(&self, config: &str, pairs: &[(&str, &str)]) -> Result<()> {
|
||||
// `uci set config.section=type` fails with "Entry not found" if
|
||||
// /etc/config/<config> doesn't exist yet — true for any config file
|
||||
// shipped by the base system (wireless, network, dhcp, ...) but not
|
||||
// for a package-defined namespace like "tollgate" that nothing has
|
||||
// created a default for. `touch` is a no-op if it already exists.
|
||||
self.run_ok(&format!("touch /etc/config/{}", config))?;
|
||||
for (key, value) in pairs {
|
||||
self.uci_set(key, value)?;
|
||||
}
|
||||
self.uci_commit(Some(config))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a value in single quotes, escaping any embedded single quotes.
|
||||
fn shell_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
@ -1,218 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
use crate::Router;
|
||||
|
||||
pub struct WispConfig {
|
||||
pub ssid: String,
|
||||
pub password: String,
|
||||
pub encryption: String, // psk2 | psk | sae | none
|
||||
pub dhcp_start: u32, // first address in DHCP pool (default 100 → .100)
|
||||
pub dhcp_limit: u32, // pool size (default 150 → .100–.249)
|
||||
pub masq: bool, // enable NAT on WAN zone (almost always true)
|
||||
}
|
||||
|
||||
pub fn configure_wisp(router: &Router, config: &WispConfig) -> Result<()> {
|
||||
info!("[{}] Configuring WISP → ssid={}", router.host, config.ssid);
|
||||
|
||||
let radio = detect_radio(router)?;
|
||||
|
||||
// Ensure the radio is enabled (disabled=1 by default on fresh flash)
|
||||
router.uci_set("wireless.radio0.disabled", "0")?;
|
||||
|
||||
// Create/update named sta wifi-iface "wwan" (idempotent: uci set creates if absent)
|
||||
router.uci_set("wireless.wwan", "wifi-iface")?;
|
||||
router.uci_set("wireless.wwan.device", &radio)?;
|
||||
router.uci_set("wireless.wwan.mode", "sta")?;
|
||||
router.uci_set("wireless.wwan.ssid", &config.ssid)?;
|
||||
router.uci_set("wireless.wwan.network", "wwan")?;
|
||||
router.uci_set("wireless.wwan.disabled", "0")?;
|
||||
router.uci_set("wireless.wwan.encryption", &config.encryption)?;
|
||||
if config.encryption != "none" && !config.password.is_empty() {
|
||||
router.uci_set("wireless.wwan.key", &config.password)?;
|
||||
}
|
||||
router.uci_commit(Some("wireless"))?;
|
||||
|
||||
// Create/update wwan network interface (DHCP)
|
||||
router.uci_set("network.wwan", "interface")?;
|
||||
router.uci_set("network.wwan.proto", "dhcp")?;
|
||||
router.uci_commit(Some("network"))?;
|
||||
|
||||
// Add wwan to the WAN firewall zone (walk zones by name)
|
||||
ensure_wwan_in_wan_zone(router)?;
|
||||
|
||||
// Configure LAN DHCP pool
|
||||
router.uci_set("dhcp.lan.start", &config.dhcp_start.to_string())?;
|
||||
router.uci_set("dhcp.lan.limit", &config.dhcp_limit.to_string())?;
|
||||
router.uci_commit(Some("dhcp"))?;
|
||||
|
||||
// Ensure masquerade on WAN zone so LAN clients reach the internet
|
||||
if config.masq {
|
||||
ensure_masq_on_wan_zone(router)?;
|
||||
}
|
||||
|
||||
// Full wifi cycle so wpa_supplicant restarts cleanly with the new config.
|
||||
// "wifi reload" is not enough on some drivers — it keeps stale state.
|
||||
let (down_out, down_code) = router.run("wifi down 2>&1")?;
|
||||
if down_code != 0 {
|
||||
info!("[{}] wifi down failed ({}): {}", router.host, down_code, down_out.trim());
|
||||
}
|
||||
let (up_out, up_code) = router.run("wifi up 2>&1")?;
|
||||
if up_code != 0 {
|
||||
info!("[{}] wifi up failed ({}): {} — falling back to network restart", router.host, up_code, up_out.trim());
|
||||
router.run_ok("/etc/init.d/network restart 2>&1")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_wan_status(router: &Router) -> serde_json::Value {
|
||||
let configured = router
|
||||
.uci_get("network.wwan.proto")
|
||||
.map(|v| v == "dhcp")
|
||||
.unwrap_or(false);
|
||||
|
||||
let ssid = router.uci_get("wireless.wwan.ssid").unwrap_or_default();
|
||||
let encryption = router.uci_get("wireless.wwan.encryption").unwrap_or_default();
|
||||
let radio0_disabled = router
|
||||
.uci_get("wireless.radio0.disabled")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
// Find the active sta-mode interface and its association state
|
||||
let iw_out = router.run_ok("iw dev 2>/dev/null").unwrap_or_default();
|
||||
let (sta_iface, assoc_ssid) = parse_sta_iface(&iw_out);
|
||||
|
||||
// Interface operstate (up / down / absent)
|
||||
let sta_state = if !sta_iface.is_empty() {
|
||||
router
|
||||
.run_ok(&format!("cat /sys/class/net/{}/operstate 2>/dev/null", sta_iface))
|
||||
.unwrap_or_else(|_| "unknown".into())
|
||||
.trim()
|
||||
.to_string()
|
||||
} else {
|
||||
"absent".to_string()
|
||||
};
|
||||
|
||||
// Source IP for reaching 8.8.8.8 — empty if no default route yet
|
||||
let ip = router
|
||||
.run_ok("ip -4 route get 8.8.8.8 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i==\"src\"){print $(i+1); exit}}'")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Recent wifi-related kernel/syslog lines for quick diagnosis
|
||||
let wifi_log = router
|
||||
.run_ok("logread 2>/dev/null | grep -iE 'wlan|wwan|wifi|assoc|deauth|auth fail|CTRL-EVENT|wpa_supplicant' | tail -8 2>/dev/null")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// LAN info for the DHCP setup display
|
||||
let lan_ip = router.uci_get("network.lan.ipaddr").unwrap_or_else(|_| "192.168.1.1".into());
|
||||
let lan_netmask = router.uci_get("network.lan.netmask").unwrap_or_else(|_| "255.255.255.0".into());
|
||||
let dhcp_start = router.uci_get("dhcp.lan.start").unwrap_or_else(|_| "100".into());
|
||||
let dhcp_limit = router.uci_get("dhcp.lan.limit").unwrap_or_else(|_| "150".into());
|
||||
|
||||
// Masquerade: check WAN zone
|
||||
let masq = {
|
||||
let script = "for i in $(seq 0 9); do \
|
||||
n=$(uci get firewall.@zone[$i].name 2>/dev/null) || break; \
|
||||
if [ \"$n\" = \"wan\" ]; then \
|
||||
uci get firewall.@zone[$i].masq 2>/dev/null; break; \
|
||||
fi; done";
|
||||
router.run_ok(script).unwrap_or_default().trim().to_string() == "1"
|
||||
};
|
||||
|
||||
info!("[{}] WAN status: configured={} ssid={:?} assoc={:?} sta_iface={:?} sta_state={:?} ip={:?} lan={} masq={}",
|
||||
router.host, configured, ssid, assoc_ssid, sta_iface, sta_state, ip, lan_ip, masq);
|
||||
if !wifi_log.is_empty() {
|
||||
info!("[{}] wifi_log: {}", router.host, wifi_log.replace('\n', " | "));
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"configured": configured,
|
||||
"ssid": ssid,
|
||||
"assoc_ssid": assoc_ssid,
|
||||
"encryption": encryption,
|
||||
"ip": ip,
|
||||
"internet": !ip.is_empty(),
|
||||
"radio0_disabled": radio0_disabled,
|
||||
"sta_iface": sta_iface,
|
||||
"sta_state": sta_state,
|
||||
"wifi_log": wifi_log,
|
||||
"lan_ip": lan_ip,
|
||||
"lan_netmask": lan_netmask,
|
||||
"dhcp_start": dhcp_start,
|
||||
"dhcp_limit": dhcp_limit,
|
||||
"masq": masq,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_sta_iface(iw_out: &str) -> (String, String) {
|
||||
let mut result_iface = String::new();
|
||||
let mut result_ssid = String::new();
|
||||
let mut current_iface = String::new();
|
||||
let mut current_type = String::new();
|
||||
let mut current_ssid = String::new();
|
||||
|
||||
for line in iw_out.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(name) = line.strip_prefix("Interface ") {
|
||||
// Save previous interface if it was a sta
|
||||
if current_type == "managed" && result_iface.is_empty() {
|
||||
result_iface = current_iface.clone();
|
||||
result_ssid = current_ssid.clone();
|
||||
}
|
||||
current_iface = name.trim().to_string();
|
||||
current_type.clear();
|
||||
current_ssid.clear();
|
||||
} else if let Some(t) = line.strip_prefix("type ") {
|
||||
current_type = t.trim().to_string();
|
||||
} else if let Some(s) = line.strip_prefix("ssid ") {
|
||||
current_ssid = s.trim().to_string();
|
||||
}
|
||||
}
|
||||
// Handle last block
|
||||
if current_type == "managed" && result_iface.is_empty() {
|
||||
result_iface = current_iface;
|
||||
result_ssid = current_ssid;
|
||||
}
|
||||
|
||||
(result_iface, result_ssid)
|
||||
}
|
||||
|
||||
fn detect_radio(router: &Router) -> Result<String> {
|
||||
// radio0 is universal; verify it exists
|
||||
let out = router.uci_get("wireless.radio0").unwrap_or_default();
|
||||
if !out.is_empty() {
|
||||
return Ok("radio0".to_string());
|
||||
}
|
||||
anyhow::bail!("No wireless radio (radio0) found in UCI config")
|
||||
}
|
||||
|
||||
fn ensure_masq_on_wan_zone(router: &Router) -> Result<()> {
|
||||
let script = "for i in $(seq 0 9); do \
|
||||
name=$(uci get firewall.@zone[$i].name 2>/dev/null) || break; \
|
||||
if [ \"$name\" = \"wan\" ]; then \
|
||||
uci set firewall.@zone[$i].masq=1 2>/dev/null; \
|
||||
uci commit firewall; \
|
||||
break; \
|
||||
fi; \
|
||||
done; echo ok";
|
||||
router.run_ok(script)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_wwan_in_wan_zone(router: &Router) -> Result<()> {
|
||||
// Walk zones 0-9, find the one named "wan", add wwan to its network list
|
||||
let script = "for i in $(seq 0 9); do \
|
||||
name=$(uci get firewall.@zone[$i].name 2>/dev/null) || break; \
|
||||
if [ \"$name\" = \"wan\" ]; then \
|
||||
uci add_list firewall.@zone[$i].network=wwan 2>/dev/null; \
|
||||
uci commit firewall; \
|
||||
break; \
|
||||
fi; \
|
||||
done; echo ok";
|
||||
router.run_ok(script)?;
|
||||
Ok(())
|
||||
}
|
||||
@ -1,177 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use crate::Router;
|
||||
|
||||
pub struct ScannedNetwork {
|
||||
pub ssid: String,
|
||||
pub bssid: String,
|
||||
pub signal: i32,
|
||||
pub channel: u8,
|
||||
pub encryption: String,
|
||||
}
|
||||
|
||||
pub fn scan_networks(router: &Router) -> Result<Vec<ScannedNetwork>> {
|
||||
let (iface, temp) = find_wireless_iface(router)?;
|
||||
let output = router.run_ok(&format!("iwinfo {} scan 2>&1", iface))?;
|
||||
let result = if output.contains("Scanning not possible") {
|
||||
// Vendor MediaTek `mt_wifi` driver (see find_wireless_iface) doesn't
|
||||
// support scanning through iwinfo/nl80211 at all. Fall back to its own
|
||||
// private ioctl site-survey, which works on the same interface.
|
||||
scan_via_mtk_site_survey(router, &iface)
|
||||
} else if output.contains("No scan results") || output.trim().is_empty() {
|
||||
Ok(vec![])
|
||||
} else {
|
||||
parse_iwinfo_scan(&output)
|
||||
};
|
||||
if temp {
|
||||
let _ = router.run(&format!("iw dev {} del 2>/dev/null", iface));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn scan_via_mtk_site_survey(router: &Router, iface: &str) -> Result<Vec<ScannedNetwork>> {
|
||||
let _ = router.run(&format!("iwpriv {} set SiteSurvey=1 2>/dev/null", iface));
|
||||
std::thread::sleep(std::time::Duration::from_secs(4));
|
||||
let output = router.run_ok(&format!("iwpriv {} get_site_survey 2>&1", iface))?;
|
||||
parse_mtk_site_survey(&output)
|
||||
}
|
||||
|
||||
/// Parses MediaTek's `iwpriv <iface> get_site_survey` fixed-width table.
|
||||
/// Column offsets come from the header row layout, which is part of the
|
||||
/// vendor SDK's ioctl response format shared across OEMs (GL.iNet, etc.),
|
||||
/// not something set per-device.
|
||||
fn parse_mtk_site_survey(output: &str) -> Result<Vec<ScannedNetwork>> {
|
||||
let mut networks = Vec::new();
|
||||
for line in output.lines() {
|
||||
if !line.trim_start().as_bytes().first().is_some_and(u8::is_ascii_digit) {
|
||||
continue; // skip header/summary lines; data rows start with an index
|
||||
}
|
||||
let ssid = line.get(8..41).unwrap_or("").trim().to_string();
|
||||
if ssid.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let bssid = line.get(41..61).unwrap_or("").trim().to_string();
|
||||
let security = line.get(61..84).unwrap_or("");
|
||||
let channel: u8 = line.get(4..8).and_then(|s| s.trim().parse().ok()).unwrap_or(0);
|
||||
let signal: i32 = line.get(84..92).and_then(|s| s.trim().parse().ok()).unwrap_or(-100);
|
||||
networks.push(ScannedNetwork {
|
||||
ssid,
|
||||
bssid,
|
||||
signal,
|
||||
channel,
|
||||
encryption: normalize_encryption(security),
|
||||
});
|
||||
}
|
||||
networks.sort_by(|a, b| b.signal.cmp(&a.signal));
|
||||
Ok(networks)
|
||||
}
|
||||
|
||||
/// Returns `(interface_name, is_temporary)`.
|
||||
/// If no interface exists, creates a temporary managed one directly on the PHY
|
||||
/// so we can scan without needing any UCI wifi-iface sections.
|
||||
fn find_wireless_iface(router: &Router) -> Result<(String, bool)> {
|
||||
// Fast path: an interface already exists (radio was enabled previously)
|
||||
let (out, _) = router.run("iw dev 2>/dev/null | awk '/Interface/{print $2}' | head -1")?;
|
||||
if !out.trim().is_empty() {
|
||||
return Ok((out.trim().to_string(), false));
|
||||
}
|
||||
|
||||
// Some vendor wifi drivers (e.g. MediaTek's out-of-tree `mt_wifi`/`mtk` SDK
|
||||
// driver used by GL.iNet and others) never register with cfg80211/mac80211,
|
||||
// so they have no `iw dev` entry and no /sys/class/ieee80211 phy even though
|
||||
// the radio is real and already up. `iwinfo` abstracts over those vendor
|
||||
// backends too, so fall back to its device listing before concluding there's
|
||||
// no radio at all.
|
||||
let (iwinfo_out, _) = router.run("iwinfo 2>/dev/null | awk '/^[A-Za-z]/{print $1; exit}'")?;
|
||||
if !iwinfo_out.trim().is_empty() {
|
||||
return Ok((iwinfo_out.trim().to_string(), false));
|
||||
}
|
||||
|
||||
// Find the phy — if this is empty the device has no WiFi hardware at all
|
||||
let (phy_out, _) = router.run("ls /sys/class/ieee80211/ 2>/dev/null | head -1")?;
|
||||
let phy = phy_out.trim().to_string();
|
||||
if phy.is_empty() {
|
||||
anyhow::bail!("No wireless radio found on this router");
|
||||
}
|
||||
|
||||
// Create a temporary managed interface directly on the PHY. This bypasses
|
||||
// netifd entirely so it works even when there are no wifi-iface sections in
|
||||
// UCI (common on a freshly-flashed device).
|
||||
tracing::info!("[{}] Creating temporary scan interface on {}", router.host, phy);
|
||||
// Remove any stale scan0 from a previous attempt, then add fresh
|
||||
let _ = router.run("iw dev scan0 del 2>/dev/null");
|
||||
router.run_ok(&format!(
|
||||
"iw phy {} interface add scan0 type managed 2>&1 && ip link set scan0 up 2>&1",
|
||||
phy
|
||||
))?;
|
||||
|
||||
Ok(("scan0".to_string(), true))
|
||||
}
|
||||
|
||||
fn parse_iwinfo_scan(output: &str) -> Result<Vec<ScannedNetwork>> {
|
||||
let mut networks: Vec<ScannedNetwork> = Vec::new();
|
||||
let mut current: Option<ScannedNetwork> = None;
|
||||
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("Cell ") {
|
||||
if let Some(n) = current.take() {
|
||||
if !n.ssid.is_empty() {
|
||||
networks.push(n);
|
||||
}
|
||||
}
|
||||
let bssid = line.split("Address:").nth(1).unwrap_or("").trim().to_string();
|
||||
current = Some(ScannedNetwork {
|
||||
ssid: String::new(),
|
||||
bssid,
|
||||
signal: -100,
|
||||
channel: 0,
|
||||
encryption: "none".to_string(),
|
||||
});
|
||||
} else if let Some(ref mut n) = current {
|
||||
if let Some(rest) = line.strip_prefix("ESSID:") {
|
||||
n.ssid = rest.trim().trim_matches('"').to_string();
|
||||
} else if line.contains("Channel:") && !line.starts_with("Encryption") {
|
||||
if let Some(ch_part) = line.split("Channel:").nth(1) {
|
||||
n.channel = ch_part.trim().split_whitespace().next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
} else if line.starts_with("Signal:") {
|
||||
if let Some(dbm_str) = line.split_whitespace().nth(1) {
|
||||
n.signal = dbm_str.parse().unwrap_or(-100);
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("Encryption:") {
|
||||
n.encryption = normalize_encryption(rest.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(n) = current {
|
||||
if !n.ssid.is_empty() {
|
||||
networks.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
networks.sort_by(|a, b| b.signal.cmp(&a.signal));
|
||||
Ok(networks)
|
||||
}
|
||||
|
||||
fn normalize_encryption(raw: &str) -> String {
|
||||
let lower = raw.to_lowercase();
|
||||
if lower.contains("wpa3") || lower.contains("sae") {
|
||||
"sae".to_string()
|
||||
} else if lower.contains("wpa2") || lower.contains("psk2") {
|
||||
"psk2".to_string()
|
||||
} else if lower.contains("wpa") {
|
||||
// CCMP/AES is WPA2's cipher suite — even if iwinfo labels it "WPA PSK (CCMP)"
|
||||
// it's a WPA2 network and we must use psk2 to associate correctly.
|
||||
if lower.contains("ccmp") || lower.contains("aes") {
|
||||
"psk2".to_string()
|
||||
} else {
|
||||
"psk".to_string()
|
||||
}
|
||||
} else if lower.contains("none") || lower.contains("open") || lower.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
lower
|
||||
}
|
||||
}
|
||||
111
core/security/src/image_verifier.rs
Normal file
111
core/security/src/image_verifier.rs
Normal file
@ -0,0 +1,111 @@
|
||||
// Container image signature verification using Cosign
|
||||
// Verifies that container images are signed and trusted
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::process::Command;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub struct ImageVerifier {
|
||||
cosign_public_key: Option<String>,
|
||||
require_signatures: bool,
|
||||
}
|
||||
|
||||
impl ImageVerifier {
|
||||
pub fn new(cosign_public_key: Option<String>) -> Self {
|
||||
Self {
|
||||
cosign_public_key,
|
||||
require_signatures: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a verifier that requires all images to be signed.
|
||||
pub fn new_strict(cosign_public_key: Option<String>) -> Self {
|
||||
Self {
|
||||
cosign_public_key,
|
||||
require_signatures: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a container image signature
|
||||
pub async fn verify_image(&self, image: &str, signature: Option<&str>) -> Result<bool> {
|
||||
if signature.is_none() && self.cosign_public_key.is_none() {
|
||||
if self.require_signatures {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Image '{}' has no signature and no cosign key is configured. \
|
||||
All container images must be signed for production use.",
|
||||
image
|
||||
));
|
||||
}
|
||||
warn!("No signature provided for image: {}", image);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Check if cosign is available
|
||||
let cosign_available = Command::new("cosign").arg("version").output().is_ok();
|
||||
|
||||
if !cosign_available {
|
||||
if self.require_signatures {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Cosign binary not found. Install cosign to verify container image signatures."
|
||||
));
|
||||
}
|
||||
warn!("Cosign not available, skipping signature verification");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// If public key is provided, use it for verification
|
||||
if let Some(ref public_key) = self.cosign_public_key {
|
||||
let output = Command::new("cosign")
|
||||
.arg("verify")
|
||||
.arg("--key")
|
||||
.arg(public_key)
|
||||
.arg(image)
|
||||
.output()
|
||||
.context("Failed to run cosign verify")?;
|
||||
|
||||
if output.status.success() {
|
||||
info!("Image signature verified: {}", image);
|
||||
return Ok(true);
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Signature verification failed: {}", stderr));
|
||||
}
|
||||
}
|
||||
|
||||
// If signature URL is provided, verify using that
|
||||
if let Some(sig_url) = signature {
|
||||
if sig_url.starts_with("cosign://") {
|
||||
// Extract signature reference
|
||||
let sig_ref = sig_url.strip_prefix("cosign://").unwrap();
|
||||
let output = Command::new("cosign")
|
||||
.arg("verify")
|
||||
.arg("--signature")
|
||||
.arg(sig_ref)
|
||||
.arg(image)
|
||||
.output()
|
||||
.context("Failed to run cosign verify")?;
|
||||
|
||||
if output.status.success() {
|
||||
info!("Image signature verified: {}", image);
|
||||
return Ok(true);
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Signature verification failed: {}", stderr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Check if an image has a signature
|
||||
pub async fn has_signature(&self, image: &str) -> bool {
|
||||
// Try to find signature in registry
|
||||
let output = Command::new("cosign")
|
||||
.arg("triangulate")
|
||||
.arg(image)
|
||||
.output();
|
||||
|
||||
output.is_ok() && output.unwrap().status.success()
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
pub mod container_policies;
|
||||
pub mod image_verifier;
|
||||
pub mod secrets_manager;
|
||||
|
||||
pub use container_policies::ContainerPolicyGenerator;
|
||||
pub use image_verifier::ImageVerifier;
|
||||
pub use secrets_manager::SecretsManager;
|
||||
|
||||
@ -1,411 +0,0 @@
|
||||
# Archipelago 1.8.0 — Release Hardening Plan & Tracker
|
||||
|
||||
> **The one living checklist for shipping 1.8.0.** Derived from a full-system deep
|
||||
> audit (2026-07-02): backend security, backend code-quality, frontend, mesh,
|
||||
> tests/release pipeline, and the ISO build. Supersedes nothing — it *sits above*
|
||||
> `docs/UNIFIED-TASK-TRACKER.md` (day-to-day) as the release exit-criteria list.
|
||||
> **Keep it updated: tick a box the moment an item lands, with the commit sha.**
|
||||
|
||||
**Definition of done for 1.8.0:** the supply chain is authenticated end-to-end
|
||||
(§A), OTA self-update is safe and rollback-proven on real hardware (§B), no
|
||||
secrets ship in the image (§F), and the single-node gate stays 5/5 green through
|
||||
all of it. Everything else is polish that should not block the tag.
|
||||
|
||||
**Legend:** `[ ]` open · `[~]` in progress · `[x]` done · 🔴 critical · 🟠 high ·
|
||||
🟡 medium · 🟢 low/polish · ⛔ blocked on you.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 The single most important insight
|
||||
|
||||
The **release signing ceremony (Workstream B) is the linchpin.** ✅ The ceremony
|
||||
KEY was generated (user confirmed 2026-07-02) — the hard offline part is done. But
|
||||
the outputs are **not yet wired into the repo**: `anchor.rs:21` is still `None` and
|
||||
`releases/app-catalog.json` carries no `signature`/`signed_by` (its `image_signature`
|
||||
fields are literal `"cosign://..."` placeholders). Three mechanical steps remain,
|
||||
split by who can run them: **(1)** pin the pubkey — needs only the *public* hex, can
|
||||
be done in-repo now; **(2)** sign the catalog with the `RELEASE_MASTER_MNEMONIC` —
|
||||
only the publisher, secret never touches a host; **(3)** implement + flip cosign
|
||||
enforcement on the pull path. Until (1)+(2) land, every "verify the signature" task
|
||||
below is written but not enforced. **This is still the critical path; §A converges on it.**
|
||||
|
||||
---
|
||||
|
||||
## §A — Supply-chain authentication (🔴 THE release blocker)
|
||||
|
||||
Today an attacker who controls the mirror IP (or any MITM on the plaintext HTTP
|
||||
path) can ship an arbitrary root binary, arbitrary container images, and an
|
||||
arbitrary app catalog to the entire fleet — fully unattended under
|
||||
`auto_apply`. These four items are one story and must land together.
|
||||
|
||||
- [x] 🔴 **Pin `RELEASE_ROOT_PUBKEY_HEX` + sign the catalog** — DONE 2026-07-02.
|
||||
`anchor.rs` pinned to `5d15cbee…d469951` (signer
|
||||
`did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur`); trust tests updated (16/16
|
||||
green). `releases/app-catalog.json` signed in place (`signed_by` matches, 64-byte sig);
|
||||
two blocking floats fixed en route (`archy-btcpay-db` version→string, `cpu_limit` 0.25→1).
|
||||
Ship order (backward-compatible): signed catalog goes out first (old binaries still accept
|
||||
it), pinned-anchor binary follows in the next build/OTA. **Still ahead:** (a) the
|
||||
pinned-anchor binary must actually be built + shipped for enforcement to be live on nodes;
|
||||
(b) flip "accept unsigned" → "reject unsigned" only after the whole fleet is on the pinned
|
||||
binary (`container/app_catalog.rs:397`, the `Unsigned` arm) — see the next item.
|
||||
- [~] 🔴 **Enforce a signature on the OTA manifest before trusting it.** Signature
|
||||
verification LANDED 2026-07-02: `check_for_updates` now fetches raw JSON and runs
|
||||
`trust::verify_detached` — a present-but-invalid/wrong-signer signature hard-rejects
|
||||
the mirror; unsigned manifests are offered for MANUAL apply only (`manifest_signed`
|
||||
surfaced in `UpdateState`) and **auto-apply refuses them**. Publisher side:
|
||||
`create-release.sh` signs the manifest inline (ceremony), `publish-release-assets.sh`
|
||||
hard-refuses to ship unsigned (grep + `ceremony verify` crypto gate), and
|
||||
`scripts/sign-manifest.sh` exists for re-signs. **Still open:** move the mirror
|
||||
to HTTPS + pinned cert (tracked with the next item); flip unsigned-manual-apply →
|
||||
hard-reject once the fleet is on a pinned-anchor binary.
|
||||
- [x] 🔴 **Implement container image signature verification (cosign).** DONE 2026-07-04
|
||||
(code path; enforcement dormant until the ceremony): new `container::image_verify`
|
||||
gates BOTH pull sites (`PodmanClient::pull_image` + the dev-only `DockerRuntime`).
|
||||
Claims classify as None / the literal `cosign://...` placeholder (every fleet
|
||||
manifest today → pull proceeds, logged) / Declared → `cosign verify --key
|
||||
/etc/archipelago/cosign.pub --insecure-ignore-tlog=true` (+ both insecure-registry
|
||||
flags for the HTTP mirror; flags verified against cosign docs), hard-fail on missing
|
||||
key, missing cosign binary, timeout, or bad signature — a declared signature can
|
||||
never be skipped, on either runtime. Key path overridable via
|
||||
`ARCHIPELAGO_COSIGN_PUBKEY`. Deleted the caller-less, blocking, wrong-CLI
|
||||
`security::ImageVerifier`. **Activation = ceremony work**: pin cosign.pub on nodes +
|
||||
install cosign + publish real `image_signature` values (in that order); tracked with
|
||||
the Workstream B signing ceremony item.
|
||||
- [ ] 🟠 **Move the image mirror to HTTPS; drop `--tls-verify=false`.**
|
||||
`podman_client.rs:641` `INSECURE_REGISTRY_HOSTS = ["146.59.87.168:3000"]` +
|
||||
`config.rs:104,124` allowlist pull images over unauthenticated HTTP. Remove the raw-IP
|
||||
entries; give the mirror a valid/pinned cert. (Same host also baked insecurely into
|
||||
the ISO — see §F.)
|
||||
- [x] 🟠 **Validate every image string at the pull site, not just the RPC boundary.**
|
||||
DONE 2026-07-03: policy extracted to `container::image_policy` (single source of truth;
|
||||
RPC-boundary check delegates to it) and BOTH orchestrator pull sites (`install_fresh` +
|
||||
`ensure_resolved_source_available`) hard-bail on refs that fail it. Policy accepts
|
||||
trusted-registry refs + registry-less Docker Hub shorthand (`grafana/grafana` — used by
|
||||
8 manifests, can't name an attacker host); rejects any explicit non-allowlisted
|
||||
registry host, shell metachars, malformed refs. 4 new unit tests; container 159 /
|
||||
package 46 green.
|
||||
|
||||
---
|
||||
|
||||
## §B — OTA self-update safety (🔴 1.8.0's headline feature is untested live)
|
||||
|
||||
The apply path itself is well-built (resumable download, staged-complete marker,
|
||||
atomic swap, single-depth backup). The gaps are **authenticity** (§A) and
|
||||
**verification depth** — plus the fact that the upgrade path has never run
|
||||
end-to-end on real hardware.
|
||||
|
||||
- [x] 🔴 **Deepen the post-OTA health check.** DONE 2026-07-03: `verify_pending_update`
|
||||
now requires, in the same attempt, (1) frontend 2xx/3xx via nginx, (2) backend RPC
|
||||
liveness — unauthenticated POST `/rpc/v1`; 401/403 = alive, 5xx/404/refused = dead,
|
||||
so a 502-behind-static-files release now rolls back, (3) rootless `podman ps`
|
||||
reachability; plus a pre-loop binary-version==marker assertion that catches a silent
|
||||
or half swap (new frontend + old binary) deterministically. Per-app container
|
||||
assertions deliberately EXCLUDED — the pre-Quadlet service restart legitimately kills
|
||||
containers and the reconciler can need minutes (false-rollback risk); revisit after
|
||||
the Phase-3 flip. LND-unlock-level checks remain out of scope for the 90s window.
|
||||
- [ ] 🟠 **Run one real upgrade-from-vN-1 soak on hardware before tagging.**
|
||||
No test installs the previous version, points it at a staged 1.8.0 manifest, applies,
|
||||
and asserts health + rollback. This is the top release risk for an OTA release. A
|
||||
two-VM (or two-node) harness is enough.
|
||||
- [x] 🟡 **Guard the frontend-build-no-op in the *actual* release path.** DONE 2026-07-08
|
||||
(`e77ccff0`): the grep guard is folded directly into `create-release.sh` right after its
|
||||
own `npm run build` (post-version-bump, pre-packaging — calling `run.sh --with-build` at
|
||||
stage 0 would have checked the pre-bump version). Verified both ways against the real
|
||||
dist: passes on the current version, trips on a missing one.
|
||||
- [x] 🟢 **publish-release-assets verifies size, not sha256.** DONE 2026-07-08 (`e77ccff0`):
|
||||
the verify loop now downloads each published asset and compares sha256 (and size)
|
||||
against the manifest. Verified live against the v1.7.99-alpha assets on the vps2 mirror.
|
||||
|
||||
---
|
||||
|
||||
## §C — Backend robustness (🟠 stability, mostly low-effort/high-ROI)
|
||||
|
||||
Note: the `.unwrap()`/`panic!` worry is a **non-issue** — nearly all are in test
|
||||
modules; production request/boot paths are essentially panic-free. The real risks:
|
||||
|
||||
- [x] 🟠 **Log swallowed persistence writes.** DONE 2026-07-02 (full-workspace re-inventory
|
||||
found 19 production sites): 16 converted to `if let Err(e) = … { warn!(…) }` — mesh
|
||||
config (`server.rs`), relay tor endpoint (`bitcoin_relay.rs`), update mirrors/state +
|
||||
staging flush/sync (`update.rs`), registry config, radio-contact blocklist, mesh outbox
|
||||
sweep (`scheduler.rs`), block-header cache (`mesh/mod.rs`), 7× peer-transport badge
|
||||
(`sync.rs` + `content.rs`). Federation tombstone/untombstone upgraded to hard errors
|
||||
(see §I). Install-log line write left fire-and-forget with an explanatory comment.
|
||||
- [x] 🟠 **Remove blocking `std::process::Command` from async handlers.** DONE 2026-07-03:
|
||||
converted to `tokio::process` — `published_host_port` (install), `detect_disk_gb`
|
||||
(dependencies), factory-reset restart (system/handlers), `config.rs detect_host_ip`,
|
||||
the orchestrator host-facts helpers (`detect_host_ip/mdns/disk_gb`, `bitcoin_host`,
|
||||
`resolve_dynamic_env` now async through all 6 call sites), and `AutoRuntime::new`
|
||||
probes. `transport/fips.rs is_available()` (sync trait method on the async route path)
|
||||
now serves the cached value and refreshes via a background thread (stale-while-
|
||||
revalidate) instead of blocking on systemctl. `image_verifier.rs` cosign sites have no
|
||||
callers yet — handled with the §A cosign item. Tests: container 155 / transport 29 /
|
||||
config 29 / package 46 all green.
|
||||
- [x] 🟡 **Restrict Bitcoin RPC exposure.** DECIDED (user, 2026-07-08: break external
|
||||
wallets) + DONE `dd61a204`: manifest port mappings grew a validated `bind` field;
|
||||
bitcoin-knots/-core publish 8332 on `127.0.0.1` + the archy-net gateway `10.89.0.1`
|
||||
only (in-node consumers dial host.archipelago/host.containers.internal → 10.89.0.1,
|
||||
unaffected; P2P 8333 stays public); legacy config.rs strings get the same incl.
|
||||
unauthenticated ZMQ 28332/28333. Unbound ports render byte-identical (no false-drift
|
||||
wave). **Still to roll out:** catalog regen + re-sign for catalog-covered nodes;
|
||||
each node's bitcoin container recreates once on deploy → restart lnd after (IP cache).
|
||||
- [x] 🟡 **Move secret env out of plaintext channels → podman secrets.** DONE 2026-07-05,
|
||||
**VERIFIED ON .228 2026-07-08**: recreate wave settled (only fedimint-gateway lagged —
|
||||
restart-sensitive, converged via a controlled quadlet restart, healthy, 0 plaintext
|
||||
password vars in inspect); btcpay inspect carries no secret values; 17 quadlet unit
|
||||
files use `Secret=` lines. Original notes:
|
||||
secret env no longer merges into `environment` — it would land in `podman inspect`
|
||||
AND as plaintext `Environment=` lines in Quadlet unit files on disk (the worse leak).
|
||||
New pipeline: `expand_and_partition_env` taints plain entries that interpolate
|
||||
secrets (btcpay's `Password=${BTCPAY_DB_PASS}` connection strings travel as secrets
|
||||
too), values register as podman secrets (stdin, `--replace`, content-hash label,
|
||||
per-app cache so steady-state reconciles are podman-free), containers reference
|
||||
them via `secret_env` (API) / `Secret=…,type=env` (Quadlet). Verified empirically
|
||||
on fleet podman 5.4.2: value absent from inspect, runtime injection works. Rotation
|
||||
drift via `io.archipelago.secret-env-hash` container label; pre-upgrade containers
|
||||
lack the label → ONE-TIME recreate wave on first reconcile after deploy (by design —
|
||||
scrubs plaintext secrets from existing container configs). Docker dev fallback keeps
|
||||
plain env (no secret store). `/proc/<pid>/environ` inside the container is unchanged
|
||||
(env is the app-compat contract); the closed leaks are inspect output + unit files.
|
||||
- [x] 🟡 **Harden rate-limit IP extraction.** DONE 2026-07-03: the accept loop injects the
|
||||
TCP `PeerAddr` into request extensions; `extract_client_ip` honors
|
||||
`X-Real-IP`/`X-Forwarded-For` ONLY when the connection is from loopback (our nginx,
|
||||
which sets `X-Real-IP $remote_addr`) — direct connections (e.g. the FIPS peer
|
||||
listener) bucket under their socket IP, so per-request header rotation no longer
|
||||
defeats the login limiter. 3 unit tests.
|
||||
- [x] 🟢 **Include `seq` in the mesh signed preimage.** DONE 2026-07-04 (receiver half):
|
||||
`verify_signature` accepts a v2 preimage `(t,v,ts,seq)` alongside legacy v1 `(t,v,ts)`;
|
||||
`signed_with_seq()` is the v2 sender path, deliberately NOT yet wired — receivers
|
||||
hard-drop bad signatures, so senders stay on v1 until the whole fleet verifies v2.
|
||||
The seq-tampering window closes only when the v1 arm is removed (track as a
|
||||
post-fleet-rollout follow-up). Unit tests cover v2 verify, v2 seq-tamper rejection,
|
||||
and v1 sign-then-set-seq compatibility.
|
||||
- [x] 🟢 **Guard the short-DID slice panic** (`mesh/listener/decode.rs:566`) and gate the
|
||||
dev-mode `password123` bypass (`auth.rs:18`) behind `#[cfg]`. DONE 2026-07-04:
|
||||
advert_name uses `.get()` fallback (malformed radio-supplied DID can't panic the
|
||||
listener); the pre-setup dev-password login + the constant itself are
|
||||
`#[cfg(debug_assertions)]` — no release binary carries the bypass regardless of
|
||||
runtime config.
|
||||
- [ ] 🟢 **Apply the seccomp/apparmor profile** — `security/src/container_policies.rs:71` is a
|
||||
TODO; the profile is defined but never applied to podman.
|
||||
- [ ] 🟡 **Manifests that hardcode secrets in plain `environment:` bypass the whole secret
|
||||
pipeline** (found during the 2026-07-08 .228 leak check): indeedhub-api/-ffmpeg ship
|
||||
`AES_MASTER_SECRET=0123456789abcdef…` and photoprism `PHOTOPRISM_ADMIN_PASSWORD=archipelago`
|
||||
as literal plaintext in quadlet unit files; grafana's `GF_SECURITY_ADMIN_PASSWORD=$${…}`
|
||||
reference never expands. Fix = declare them as `generated_secrets`/`secret_env` in the
|
||||
manifests — which means catalog regen + re-sign + republish (catalog overlay supremacy).
|
||||
- [x] 🟠 **Legacy `mempool` umbrella id destroys the split stack on stop→start (quadlet).**
|
||||
FOUND by the first quadlet-mode gate run on .228 (2026-07-08), FIXED `161a6e4d`:
|
||||
orchestrator start/stop/restart now alias `mempool` → the split members whenever the
|
||||
umbrella manifest was dropped (same alias install already used); podman-5
|
||||
`no such object` phrasing added to all 3 `is_missing_container_error` classifiers.
|
||||
Follow-up (open): reconciler-side cleanup of an orphan umbrella `mempool` container
|
||||
when the split stack owns the frontend, so the stale-tile state can't arise at all.
|
||||
- [x] 🟠 **Transitional package state sticks past the gate window on legacy apps**
|
||||
(vaultwarden:stop run C, jellyfin:stop run D, uptime-kuma:start run E — .228
|
||||
2026-07-09), FIXED `dd3afbba`: the scanner already saw the settled container every
|
||||
60s but `merge_preserving_transitional` refused to report it until the RPC worker
|
||||
wrote back — and workers legitimately trail the container by minutes (stop workers
|
||||
queue behind the orchestrator app_lock that reconcile's host-port repair holds
|
||||
through multi-minute stability waits; start workers hold `Starting` through
|
||||
readiness budgets up to 420s for uptime-kuma against a 240s gate window, with the
|
||||
20-minute Installing stuck-timeout as the only escape). New merge rules:
|
||||
(Stopping, Stopped)+user-stop-marker → Stopped; (Starting, Running) → Running;
|
||||
Restarting deliberately unresolved. Follow-up (open, same family as the op-lock
|
||||
known-limit): repair/readiness waits should abort early when a user-stop marker
|
||||
appears mid-wait, so an explicit stop is never queued behind a multi-minute repair.
|
||||
- [x] 🟢 **`install_log()` has been a no-op since April** — `/var/log/archipelago/
|
||||
container-installs.log` is 0 bytes: the service sandbox leaves /var/log read-only,
|
||||
the open() fails, fire-and-forget drops every line. FIXED `c3f0a306`: every line
|
||||
now mirrors to tracing/journald; file append stays best-effort.
|
||||
- [x] 🟢 **Gate tests 123/124 false-fail on user-stopped apps with lingering quadlet
|
||||
units** (run E: the inactive bitcoin-core of the multi-version pair), FIXED
|
||||
`2683ad4f0`: `use-quadlet-backends-install.bats` active-state asserts now honour
|
||||
`user-stopped.json`.
|
||||
- [ ] 🟠 **Backend recreate must cascade to dependent apps** (found on .228 2026-07-08):
|
||||
when bitcoin-knots was recreated mid-gate (one-time secret-env recreate) it got a new
|
||||
archy-net IP; **lnd caches the resolved backend IP** and kept dialing the dead one
|
||||
("no route to host") for 30+ min — chain-blind with open channels, silently (container
|
||||
"running", health green). Repair was a manual lnd restart. Fix = reconciler/health
|
||||
monitor restarts (or at least alerts on) apps whose declared backend container was
|
||||
recreated; same class applies to electrumx/btcpay/nbxplorer → bitcoin links.
|
||||
|
||||
---
|
||||
|
||||
## §D — Frontend security & performance (🟠)
|
||||
|
||||
The untrusted mesh/LoRa chat path is **safe** (interpolation, no `v-html` — good).
|
||||
The real issues are the app-bridge origin model and a bloated bundle.
|
||||
|
||||
- [x] 🟠 **Validate `event.origin` + add consent gates in the NIP-07 nostr bridge.**
|
||||
DONE 2026-07-02: `handleNostrRequest` rejects senders whose `event.origin` doesn't match
|
||||
the open app's URL origin, and ALL identity-sensitive methods (`getPublicKey`, `signEvent`,
|
||||
`nip04`/`nip44` encrypt+decrypt) now go through the consent/approved-origins gate, not just
|
||||
`signEvent`. Verified present in the built bundle.
|
||||
- [x] 🟠 **Origin-check the `share-to-mesh` handler.** DONE 2026-07-02: `App.vue`
|
||||
`onShareToMeshMessage` now requires `ev.origin === window.location.origin` (matching
|
||||
`Chat.vue`).
|
||||
- [ ] 🟡 **Decide the app-iframe isolation model.** `AppSessionFrame.vue:54` /
|
||||
`AppLauncherOverlay.vue:79` embed apps same-origin with no meaningful `sandbox`; a
|
||||
same-origin app can read the CSRF cookie + `localStorage`. Ideal fix (serve apps from a
|
||||
per-app subdomain origin) is architectural — at minimum decide + document for 1.8.0.
|
||||
- [ ] 🟡 **Shrink the 93 MB dist.** `assets/video/video-intro.mp4` is **14.7 MB**
|
||||
(precached by the service worker → blocks PWA install), plus ~18 MB of ~1 MB full-screen
|
||||
JPEGs. Convert backgrounds to WebP/AVIF at responsive sizes, lazy/stream the intro video,
|
||||
and exclude video/audio from the Workbox precache. Biggest, easiest perf win.
|
||||
- [x] 🟢 **DOMPurify the `Server.vue` QR SVG / guard `Mesh.vue` pollInterval / surface
|
||||
`curatedApps.ts` fetch failures.** DONE 2026-07-03: WireGuard peer QR now sanitized with
|
||||
the same `USE_PROFILES: {svg}` call as TwoFactorSection; Mesh poll interval guarded +
|
||||
nulled on unmount; catalog fetch failures log per-URL console.warn incl. the
|
||||
all-sources-failed fallback. Bundle-verified.
|
||||
|
||||
---
|
||||
|
||||
## §E — Mesh transports (🟢 mostly done — verify & polish)
|
||||
|
||||
Confirmed **fixed in HEAD:** B8 (1970 timestamps), B6 (inbound RX surfacing), the
|
||||
per-message transport pill, and the archy↔archy plain-TEXT-DM E2E fix. Remaining:
|
||||
|
||||
- [ ] 🟠 **Active Reticulum daemon-death detection.** `reticulum.rs:589` only `warn!`s on
|
||||
socket EOF and `try_recv_frame` then returns `Ok(None)` forever; nothing calls
|
||||
`child.try_wait()`. On an idle link a crashed daemon is invisible for up to 30 min (the
|
||||
RX-stall timeout). Treat socket EOF as `Err` → immediate respawn. (Pairs with the current
|
||||
`fix/reticulum-daemon-pdeathsig` branch work.)
|
||||
- [ ] 🟡 **Persist chat history across restarts.** `state.messages` boots empty
|
||||
(`listener/mod.rs:283`) while outbox/scheduler/peers survive — inconsistent; bubbles
|
||||
vanish on restart. Add `mesh-messages.json` mirroring the `scheduler.rs`/`outbox.rs`
|
||||
pattern (or explicitly accept the loss).
|
||||
- [ ] 🟡 **Tighten the 30 s legacy dedup** (`listener/mod.rs:383-389`) — it silently drops a
|
||||
peer legitimately sending identical text twice within 30 s.
|
||||
- [ ] 🟢 **Wire the PyInstaller daemon binary into the release tarball / deploy script**
|
||||
(Rust expects `/usr/local/bin/archy-reticulum-daemon`, `reticulum.rs:80`); add the RNode
|
||||
udev rule; finish `ARCHY:2:` announce→`arch_pubkey_hex` binding (`reticulum.rs:119`).
|
||||
- [ ] 🟢 **Duty-cycle guard for LoRa TX** — none exists; EU 868 is legally 1%. At minimum an
|
||||
airtime budget/warning.
|
||||
|
||||
---
|
||||
|
||||
## §F — ISO / image build (🔴 one secret leak; otherwise 🟠 hardening)
|
||||
|
||||
`image-recipe/_archived/build-auto-installer-iso.sh` (3604 lines) is the real
|
||||
builder; OTA is the normal update path but the ISO is what produces installable
|
||||
media (latest artifact only one minor behind).
|
||||
|
||||
- [ ] ⛔🔴 **Anthropic API key — INTENTIONAL for alpha/beta, hard GO-LIVE gate.**
|
||||
`build-auto-installer-iso.sh:2645` bakes a live `sk-ant-…` key into `claude-api-proxy.service`
|
||||
so alpha/beta testers get frictionless AI (deliberate — per user 2026-07-02). **Do NOT
|
||||
remove for alpha/beta.** Before public GA it MUST be removed + rotated + injected at runtime
|
||||
(a second copy also exists in a worktree). Track it here so it can't be forgotten at launch.
|
||||
- [ ] 🔴 **Per-device secrets on first boot.** The self-signed TLS **private key is generated
|
||||
at build time** (`:426`) → every device ships the same key; SSH host keys likewise not
|
||||
regenerated. Generate TLS + SSH host keys on first boot.
|
||||
- [ ] 🟠 **Kill default credentials.** `archipelago`/`archipelago` (SSH+root), web `password123`,
|
||||
and SSH `PasswordAuthentication yes` (`:411`) all ship. Lock root, force credential
|
||||
creation in onboarding, disable SSH password auth (or force-change on first login).
|
||||
- [ ] 🟠 **Sign + checksum the ISO.** Pipeline ends at `xorriso` with no `SHA256SUMS`, no
|
||||
GPG/minisign, no Secure Boot (`BOOTX64.EFI` is unsigned though `grub-efi-amd64-signed` is
|
||||
installed). Emit + sign checksums; wire signed Secure Boot.
|
||||
- [ ] 🟠 **Registries over HTTPS in the image too** — `146.59.87.168:3000` / `git.tx1138.com`
|
||||
are baked `insecure=true`/`tls_verify:false` (`:216`, `:2308`). (Ties to §A.)
|
||||
- [ ] 🟡 **Add `unattended-upgrades` + a default-deny nftables firewall** (allow 22/80/443 +
|
||||
mesh/WG). Neither exists today; OS packages drift until reflash and there is no host
|
||||
firewall.
|
||||
- [ ] 🟡 **Pin the build for reproducibility.** FIPS daemon is built from unpinned upstream
|
||||
`main`, Tailscale from its live apt repo, and `scripts/image-versions.sh` uses many
|
||||
`:latest`/`stable` tags (+ `bitcoin-ui:1.7.84-alpha`, 15 behind). Pin to commits/versions;
|
||||
snapshot apt. Wire ISO version to `Cargo.toml` so it can't drift.
|
||||
- [ ] 🟢 **Harden LUKS + roadmap A/B partitioning.** The LUKS data key sits in plaintext on the
|
||||
unencrypted root (`:2137`); add TPM2/passphrase binding. Longer-term: A/B (or
|
||||
factory-reset) partitions for safe OTA rollback, and a real install-time TUI
|
||||
(`docs/archive/INSTALL-SCREENS-DESIGN.md` exists but the installer is headless "press Enter").
|
||||
|
||||
---
|
||||
|
||||
## §G — Refactor & code health (🟢 not release-blocking; do after the tag or opportunistically)
|
||||
|
||||
- [ ] 🟢 **Manifest-drive per-app special-casing.** App names are branched on across 5-7 Rust
|
||||
files (`config.rs` 36 match arms, `runtime.rs` 17, `install.rs:275-287` dispatch,
|
||||
`prod_orchestrator.rs:54-83` baseline/restart-sensitive lists). Move `baseline`,
|
||||
`restart_sensitive`, `stack_members`, `multi_container` into the manifest schema; collapse
|
||||
the five near-identical `install_*_stack()` wrappers into one generic call. **Biggest
|
||||
maintainability win.** (Grew again 2026-07-09: `stack_member_app_ids` in
|
||||
`package/dependencies.rs` — the quadlet stack-resurrection fallback — is a fifth
|
||||
per-app map that must fold into the same manifest field.)
|
||||
- [ ] 🟢 **Route all podman/systemctl through `podman_client`.** 113 raw `Command::new("podman")`
|
||||
+ 32 `systemctl` calls bypass the existing 952-LOC wrapper → untestable + the blocking-call
|
||||
risk (§C). Consolidating also unlocks unit tests for the thinly-tested `package/` handlers
|
||||
(`stacks.rs` 1 test, `config.rs` 2, `runtime.rs` 3, `install.rs` 7).
|
||||
- [ ] 🟢 **Split the god-modules.** `prod_orchestrator.rs` (5,263 LOC) → `orchestrator/{reconcile,
|
||||
host_ports,ownership,hooks}.rs`; `Mesh.vue` (2,485 LOC / 241 KB chunk) → sub-components.
|
||||
Both are well-tested, so safe.
|
||||
- [ ] 🟢 **Delete dead code.** ~4,100 LOC of orphan StartOS crates (`js-engine`, `models`,
|
||||
`helpers`, `container-init`) not in the workspace or linked; the committed AppleDouble
|
||||
`._*.rs` files; the committed `.venv/`/`build/`/`__pycache__` under the duplicate
|
||||
`reticulum-daemon/` tree; promote `MeshRadioDevice` enum → trait.
|
||||
- [ ] 🟢 **Resolve the Quadlet flag & dep hygiene.** Decide `use_quadlet_backends`' fate
|
||||
(flip default + delete the legacy `create_container` branch, or freeze as experimental —
|
||||
don't ship both half-maintained). Consolidate the mixed hyper 0.14/1.x ecosystem; bump
|
||||
stale majors (reqwest, base64, thiserror, tokio-tungstenite).
|
||||
|
||||
---
|
||||
|
||||
## §H — Testing gaps that gate confidence (🟠)
|
||||
|
||||
- [ ] 🟠 **Add the OTA upgrade soak** (same as §B item 2) — the highest-value missing test.
|
||||
- [ ] 🟡 **Add a host-reboot survival tier** — every app is `○` (untested) for reboot in
|
||||
`TESTING.md:138`; the gate can't reboot the node it runs on. Run SSH-`reboot`-then-reprobe
|
||||
out-of-band per node.
|
||||
- [ ] 🟡 **Make the release gate run the full Rust suite** (or hard-require a green CI sha).
|
||||
`tests/release/run.sh:101` runs only a 6-module slice because the full 1000-test suite
|
||||
hangs PTYs on the dev box → 994 tests unverified at release time if CI is stale.
|
||||
- [x] 🟡 **Add `--max-time` to `node_rpc()`.** DONE 2026-07-08 (`380f4f19`): login + rpc get
|
||||
`--connect-timeout 10 --max-time 120` (override `MULTINODE_RPC_TIMEOUT`). Verified live:
|
||||
.116 login/rpc OK; an unroutable node fails in 10s instead of hanging.
|
||||
- [x] 🟢 **De-hardcode creds in tests.** DONE 2026-07-08 (`380f4f19`): multinode suites no
|
||||
longer commit node passwords — `*_PW` env required, auto-loaded from git-ignored
|
||||
`tests/multinode/.env` (`.env.example` documents the shape). Still open from this
|
||||
bullet: snapshot/restore node baseline between destructive iterations
|
||||
(teardown currently only clears `/tmp` session files).
|
||||
|
||||
---
|
||||
|
||||
## §I — Carried-over open items (from `UNIFIED-TASK-TRACKER.md`, still valid)
|
||||
|
||||
- [~] 🟠 **Multinode gate pass** — 5× destructive gate was launched on node `.5`; bring the
|
||||
rest of the fleet to precondition, then run the existing (undocumented-but-present)
|
||||
`tests/multinode/{smoke,meshtastic}.sh` cross-node suites.
|
||||
- [~] 🟠 **Federation `remove-node` tombstone regression.** Code fix DONE 2026-07-02:
|
||||
`remove_node` now tombstones BEFORE trimming the node list and propagates the write
|
||||
error (idempotent, so retries are clean); `add_node`'s untombstone likewise propagates
|
||||
before mutating. **Still open: `tests/multinode/smoke.sh` re-verify on real nodes.**
|
||||
- [ ] 🟠 **Phase-3 Quadlet default-flip** — validated + opt-in on .228/.198; flip
|
||||
`config.rs:256` once the .5 gate reports clean.
|
||||
- [ ] 🟠 **Developer CLI suite** (`archy app validate/render/install/test`) — gates external
|
||||
app publishing (`APP-PACKAGING-MIGRATION-PLAN.md` step 5).
|
||||
- [ ] 🟡 **Version bump + tag** — DECIDED (user, 2026-07-08): the release ships as
|
||||
**`1.8.0-alpha`**. Remaining work is the mechanical bump + `create-release.sh` run
|
||||
when the gate criteria are met.
|
||||
- [ ] 🟢 **Bitcoin multi-version fleet OTA** — DECIDED (user, 2026-07-08): timing doesn't
|
||||
matter; fold the branch into the next fleet OTA (`docs/bitcoin-version-bulletproof-rollout.md`).
|
||||
- [x] ~~⛔🟢 **3ccc stock-Meshtastic RF validation**~~ — DROPPED per user 2026-07-08; the
|
||||
code fix stays in, no live-radio validation will be scheduled.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order of attack
|
||||
|
||||
1. **The critical path:** §A signing ceremony → then turn on manifest/catalog/image
|
||||
signature enforcement (§A) + OTA HTTPS/signature + deeper health check (§B).
|
||||
2. **Cheap high-ROI stability:** §C swallowed-writes + blocking-calls; §D nostr-bridge
|
||||
+ share-to-mesh origin checks; §H OTA soak + reboot tier.
|
||||
3. **Image hardening:** rest of §F (per-device secrets, default creds, ISO signing,
|
||||
firewall/unattended-upgrades, pinning).
|
||||
4. **Polish, post-tag:** §G refactors, §E mesh persistence/dedup, §D bundle shrink.
|
||||
5. **Decisions you own (⛔):** version name, signing mnemonic, bitcoin OTA timing, 3ccc test.
|
||||
6. **Before public GA only (NOT alpha/beta):** remove + rotate the Anthropic key (§F) —
|
||||
intentionally left in for frictionless AI during alpha/beta.
|
||||
|
||||
*Last updated: 2026-07-08 PM (hardening session 3: §C secrets verified on .228,
|
||||
§B release-path guards `e77ccff0`, §H test creds/timeouts `380f4f19`, mempool
|
||||
umbrella lifecycle fix `161a6e4d` from the first quadlet-mode gate run; new
|
||||
finding: manifests hardcoding secrets in plain `environment:`). Update this
|
||||
line + tick boxes with commit shas as items land.*
|
||||
@ -32,7 +32,7 @@ As of the current `1.8-alpha` workstream:
|
||||
|
||||
- `apps/*/manifest.yml` is the source of truth for runtime app definitions.
|
||||
- The Rust manifest parser validates app identity, image-vs-build source selection, safe environment/secrets, safe ports, safe bind/named/tmpfs volumes, generated files under declared bind mounts, devices, and security/network policy values.
|
||||
- Manifest-owned generated files exist through `app.files` and have been used for app config material (e.g. strfry, netbird config regeneration).
|
||||
- Manifest-owned generated files exist through `app.files` and have been used for app config material such as Meshtastic config regeneration.
|
||||
- Local image builds are represented with `container.build`; pulled images are represented with `container.image`.
|
||||
- Data ownership repair is represented with `container.data_uid`.
|
||||
- Derived host facts and secret-file-backed environment variables are represented with `container.derived_env` and `container.secret_env`.
|
||||
@ -41,7 +41,7 @@ As of the current `1.8-alpha` workstream:
|
||||
- Runtime package listings now derive LAN launch URLs from manifest-owned `interfaces.main` declarations or HTTP app ports before falling back to legacy compatibility aliases.
|
||||
- Release drift checking is implemented by `scripts/check-app-catalog-drift.py --release --strict`.
|
||||
- The canonical catalog and the UI public catalog are expected to remain byte-for-byte synced after generation.
|
||||
- Runtime validation has already moved many simple and moderate apps into the manifest/orchestrator path, including Filebrowser, Vaultwarden, Portainer, Uptime Kuma, Grafana, Gitea, Nextcloud, SearXNG, Nostr Relay, PhotoPrism, Jellyfin, and several Bitcoin-adjacent apps.
|
||||
- Runtime validation has already moved many simple and moderate apps into the manifest/orchestrator path, including Filebrowser, Vaultwarden, Portainer, Uptime Kuma, Grafana, Gitea, Nextcloud, SearXNG, Nostr Relay, PhotoPrism, Jellyfin, Meshtastic, and several Bitcoin-adjacent apps.
|
||||
|
||||
The remaining migration work is mostly orchestration quality: post-reboot adoption, progress reporting, stale scanner-state handling, update policy, multi-container stack ownership, proxy route generation, and cleanup of obsolete legacy installers/fallbacks.
|
||||
|
||||
@ -327,14 +327,6 @@ Once a complex stack is clean, the app system is credible.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
**Status (2026-07-08):** Phases 1–3 ✅ DONE (per-member manifests won over a
|
||||
compose subset; all five real multi-container stacks — btcpay, mempool,
|
||||
immich, netbird, indeedhub — install via `install_stack_via_orchestrator`).
|
||||
Phase 5 mostly done (orchestrator-first with legacy fallback + per-app
|
||||
adoption/repair). Phase 4 (routing via `proxy/routes.yml`) NOT started —
|
||||
routing is still host-nginx driven. Phase 6 (cleanup + developer CLI) NOT
|
||||
started; the CLI gates external app publishing.
|
||||
|
||||
### Phase 1: Package Contract
|
||||
|
||||
- Use `apps/<app-id>/manifest.yml` as the package contract.
|
||||
@ -348,7 +340,7 @@ started; the CLI gates external app publishing.
|
||||
- Continue hardening package install for one-container apps.
|
||||
- Compile manifests to rootless Podman/Quadlet runtime behavior.
|
||||
- Support ports, env, generated files, devices, volumes, resources, health checks, data UID repair, image pull/build availability checks, and launch metadata.
|
||||
- Keep Filebrowser, Vaultwarden, Portainer, Uptime Kuma, Grafana, SearXNG, Jellyfin, PhotoPrism, and similar apps as regression proofs.
|
||||
- Keep Filebrowser, Vaultwarden, Portainer, Uptime Kuma, Grafana, SearXNG, Jellyfin, PhotoPrism, Meshtastic, and similar apps as regression proofs.
|
||||
|
||||
### Phase 3: Multi-Container Runtime
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@ real nodes. Until then, this plan is the priority.
|
||||
|
||||
- **~40 apps are manifest-based and Quadlet-migrated** (survive
|
||||
`archipelago.service` restart + reboot). Exhaustive per-app table:
|
||||
`docs/archive/app-registry-status-2026-06-21.md`.
|
||||
`docs/app-registry-status-2026-06-21.md`.
|
||||
- **Legacy holdout: immich** — the one app with **no manifest** and a hardcoded
|
||||
Rust stack installer (in-cgroup, not Quadlet). 3 containers, healthy, live data.
|
||||
The migration proof case.
|
||||
@ -77,7 +77,7 @@ real nodes. Until then, this plan is the priority.
|
||||
| E | **Production test gate** — 5× lifecycle on **.228**, per-app L1/L2 matrix; multinode is split out → `multinode-testing-plan.md` | `tests/lifecycle/TESTING.md`, `bulletproof-containers.md` | **✅ .228 5×-GREEN (110/110 ×5, 0 not-ok, 2026-06-23)** — but this is DESTRUCTIVE-tier / ~8 core apps only; see §6c for the coverage gaps |
|
||||
| F | **Lifecycle perfection — cascade + progress + ALL apps** — extend the gate to uninstall/reinstall (cascade), real install/uninstall progress UI, and EVERY installed app (not just the 8 core). The "insanely-perfect OS/container environment" bar. | §6c (below), `tests/lifecycle/TESTING.md` | **IN PROGRESS (2026-06-26)** — root bug FIXED: uninstall could hang → ghost/stuck-bar/reinstall-block (`71cc9ac4`, unbounded systemctl/podman in `quadlet::disable_remove`); `cascade-uninstall.bats` **7/7 green on .228** w/ binary `ae349a75`. Remaining: wire CASCADE into the canonical gate run, progress-UI truthfulness, all-apps matrix, guardian/IBD state. |
|
||||
|
||||
**Orchestrator architecture** (foundation for A/B): `archive/rust-orchestrator-migration.md`
|
||||
**Orchestrator architecture** (foundation for A/B): `rust-orchestrator-migration.md`
|
||||
(ProdContainerOrchestrator, BootReconciler 30s level-triggered reconcile, adoption
|
||||
scan, Quadlet rendering) and `bulletproof-containers.md` (the six container failure
|
||||
modes FM1–FM6 + the desired-state-first reconciler that fixes them).
|
||||
@ -159,7 +159,7 @@ the next release. **Authoritative detail + exact remaining steps + test matrix
|
||||
filebrowser). It explicitly **SKIPS uninstall/reinstall** (the CASCADE tier is gated behind
|
||||
`ARCHY_ALLOW_CASCADE_DESTRUCTIVE`, which `run-gate.sh` never sets) and has **zero coverage**
|
||||
for the other ~30 apps (grafana, jellyfin, vaultwarden, penpot, nextcloud, photoprism,
|
||||
uptime-kuma, homeassistant, … — see `archive/app-registry-status-2026-06-21.md`). So uninstall,
|
||||
uptime-kuma, homeassistant, … — see `app-registry-status-2026-06-21.md`). So uninstall,
|
||||
reinstall, install-progress UI, and most apps were never under test.
|
||||
|
||||
**Real bugs found in manual multinode testing on .198 (2026-06-23) — the motivating evidence:**
|
||||
@ -313,7 +313,7 @@ phases 2–6 (`dual-ecash-design.md`).
|
||||
|
||||
**Branch/worktree:** currently on `bitcoin-version-bulletproof`, not `main`. Worktree is dirty.
|
||||
Do **not** discard mesh changes: they include E2E/transport indicator plumbing and the Meshtastic
|
||||
receive-path fixes below. Separate recovery note: `docs/archive/SESSION-1.8.0-OTA-PROGRESS.md`.
|
||||
receive-path fixes below. Separate recovery note: `docs/SESSION-1.8.0-OTA-PROGRESS.md`.
|
||||
|
||||
**What was done this session:**
|
||||
1. ✅ **Local Rust release gate fixed and green.** `cargo test -p archipelago --bin archipelago` is
|
||||
@ -902,13 +902,13 @@ This master plan is the hub. Authoritative standalone docs (linked above), kept:
|
||||
- **Design:** `architecture.md`, `app-developer-guide.md`,
|
||||
`APP-PACKAGING-MIGRATION-PLAN.md`, `registry-manifest-design.md`,
|
||||
`marketplace-protocol.md`, `dht-distribution-design.md`,
|
||||
`multi-node-architecture.md`, `archive/rust-orchestrator-migration.md`,
|
||||
`bulletproof-containers.md`, `archive/three-mode-ui-design.md`, `dual-ecash-design.md`,
|
||||
`multi-node-architecture.md`, `rust-orchestrator-migration.md`,
|
||||
`bulletproof-containers.md`, `three-mode-ui-design.md`, `dual-ecash-design.md`,
|
||||
`meshroller-integration-design.md`, `phase4-streaming-ecash-plan.md`, `adr/*`.
|
||||
- **Reference:** `app-manifest-spec.md`, `api-reference.md`, `developer-guide.md`,
|
||||
`operations-runbook.md`, `troubleshooting.md`, `user-walkthrough.md`,
|
||||
`bitcoin-rpc-relay.md`, `archive/security-code-audit-2026-03.md`, `GAMEPAD-NAV.md`,
|
||||
`SEED-VERIFICATION.md`, `hotfix-process.md`, `archive/app-registry-status-2026-06-21.md`.
|
||||
`bitcoin-rpc-relay.md`, `security-code-audit-2026-03.md`, `GAMEPAD-NAV.md`,
|
||||
`SEED-VERIFICATION.md`, `hotfix-process.md`, `app-registry-status-2026-06-21.md`.
|
||||
|
||||
All dated handoffs/resumes/transcripts/superseded trackers were consolidated here
|
||||
and removed (recoverable via git) on 2026-06-21.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user