Compare commits

..

No commits in common. "main" and "demo-build" have entirely different histories.

59 changed files with 725 additions and 3347 deletions

View File

@ -1,16 +1,16 @@
## Summary
<!-- What changed and why? -->
<!-- Brief description of what this PR does -->
## Verification
## Changes
<!-- Commands run, devices tested, screenshots, or reason testing was not run. -->
-
## Checklist
- [ ] Rust formatting/clippy/tests pass when backend code changed.
- [ ] Frontend type-check/build/tests pass when frontend code changed.
- [ ] App manifests validate when app packaging changed.
- [ ] Generated catalogs are updated when manifest-owned catalog fields changed.
- [ ] Docs are updated for user-facing or developer-facing behavior changes.
- [ ] No secrets, generated build outputs, local screenshots, or private host details are included.
- [ ] TypeScript type-check passes (`npm run type-check`)
- [ ] Frontend builds (`npm run build`)
- [ ] Tests pass (`npm test`)
- [ ] Rust clippy clean (if backend changes)
- [ ] No new compiler warnings
- [ ] Tested on live server

View File

@ -8,11 +8,11 @@ on:
env:
RUST_VERSION: stable
NODE_VERSION: 20
NODE_VERSION: 18
jobs:
rust:
name: Rust
name: Rust (fmt + clippy + test)
runs-on: ubuntu-latest
defaults:
run:
@ -28,17 +28,17 @@ jobs:
toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy
- name: Format
- name: Check formatting
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Test
- name: Tests
run: cargo test --all-features
frontend:
name: Frontend
name: Frontend (type-check + lint)
runs-on: ubuntu-latest
defaults:
run:
@ -52,31 +52,14 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache: 'npm'
cache-dependency-path: neode-ui/package-lock.json
- name: Install
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run type-check
- name: Test
run: npm test
- name: Build
run: npm run build
manifests:
name: App Manifests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate manifests
run: |
for manifest in apps/*/manifest.yml; do
./scripts/validate-app-manifest.sh --repo-audit "$manifest"
done

35
.gitignore vendored
View File

@ -1,9 +1,10 @@
# SSH keys and sandbox copies
# SSH keys (sandbox copies)
.ssh/
# Rust build output
target/
**/target/
Cargo.lock
# Node.js
node_modules/
@ -11,6 +12,7 @@ node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
pnpm-debug.log*
# Build outputs
@ -26,46 +28,49 @@ build/
*.swo
*~
.DS_Store
._*
Thumbs.db
# Environment and local overrides
.env
.env.local
.env.*.local
.env.production
core/.env.production
scripts/deploy-config.sh
# Logs
logs/
*.log
# OS
.DS_Store
Thumbs.db
# Testing
coverage/
.nyc_output/
# Image / release artifacts
# Temporary files
*.tmp
*.temp
# Build artifacts
*.iso
*.img
*.dmg
*.app
*.apk
*.keystore
*.s9pk
*.tar.gz
# Release artifacts live in release attachments, not Git history.
# Release artifacts live in Gitea Release attachments, not Git history.
releases/**
!releases/
!releases/manifest.json
# macOS build output
build/macos/
# Image recipe output
image-recipe/output/
image-recipe/*.iso
image-recipe/*.img
# Loop tool artifacts
# Loop tool artifacts (created in every subdirectory)
*/loop/
loop/loop/
loop/loop.log.bak
@ -73,17 +78,19 @@ loop/loop.log.bak
# Separate repos nested in tree
web/
# Resilience harness reports contain session cookies.
._*
# Resilience harness reports (generated, contains session cookies)
scripts/resilience/reports/
# Codex / pnpm / python caches / editor backups
.codex
.codex-target-*/
.codex-tmp/
.claude/
.pnpm-store/
**/__pycache__/
*.bak
.claude/scheduled_tasks.lock
# Local evidence screenshots; intentional UI screenshots should live under an
# app/docs asset path with a descriptive filename.

BIN
Android/app/debug.keystore Normal file

Binary file not shown.

View File

@ -197,12 +197,6 @@ private fun injectSafeAreaVars(view: WebView) {
* the status-bar height: the padded strip shows the page's OWN background
* (padding is inside the element), so the bar keeps the page colour while
* content starts below it the pre-edge-to-edge look, without the black bar.
*
* Body padding only moves normal-flow content. fixed/sticky elements anchored
* at the viewport top (IndeeHub's floating header) stayed glued under the
* status bar, so we also push each of those down by the inset once, marked
* via data attribute and keep a throttled MutationObserver running so
* headers an SPA mounts after load get the same treatment.
* Idempotent; runs on start (early) and finish (after the app rewrites head). */
private fun injectTopInset(view: WebView) {
val insets = view.rootWindowInsets ?: return
@ -212,7 +206,6 @@ private fun injectTopInset(view: WebView) {
view.evaluateJavascript(
"""
(function() {
var SAT = $sat;
var s = document.getElementById('archy-top-inset');
if (!s) {
s = document.createElement('style');
@ -220,40 +213,7 @@ private fun injectTopInset(view: WebView) {
(document.head || document.documentElement).appendChild(s);
}
s.textContent =
'body{padding-top:' + SAT + 'px!important;box-sizing:border-box!important;}';
function push(el) {
if (el.dataset.archyInset) return;
var cs = getComputedStyle(el);
if (cs.position !== 'fixed' && cs.position !== 'sticky') return;
var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars
if (isNaN(top) || top >= SAT) return;
el.style.setProperty('top', (top + SAT) + 'px', 'important');
el.dataset.archyInset = '1';
}
function sweep() {
if (!document.body) return;
// Fixed/sticky bars live shallow in the tree (portals mount on
// body); depth cap keeps the computed-style pass off big lists.
var els = document.body.querySelectorAll(
'body > *, body > * > *, body > * > * > *, body > * > * > * > *');
for (var i = 0; i < els.length; i++) push(els[i]);
}
sweep();
if (!window.__archyInsetObserver) {
var queued = false, last = 0;
window.__archyInsetObserver = new MutationObserver(function() {
if (queued) return;
queued = true;
var wait = Math.max(0, 250 - (Date.now() - last));
setTimeout(function() {
queued = false;
last = Date.now();
sweep();
}, wait);
});
window.__archyInsetObserver.observe(document.documentElement,
{ childList: true, subtree: true });
}
'body{padding-top:${sat}px!important;box-sizing:border-box!important;}';
})();
""".trimIndent(),
null,

View File

@ -1,13 +1,5 @@
# Changelog
## v1.7.117-alpha (2026-07-27)
- FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.
- App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.
- Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps.
- Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.
- Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.
## v1.7.116-alpha (2026-07-27)
- Nodes no longer get stuck on "server starting up" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down.

View File

@ -1,19 +0,0 @@
# Code of Conduct
## Our standard
Be direct, respectful, and focused on the work. Healthy disagreement is welcome;
harassment, personal attacks, and discriminatory language are not.
## Scope
This code of conduct applies to project repositories, issue trackers, pull
requests, documentation, chat, and community spaces connected to Archipelago.
## Enforcement
Maintainers may edit, hide, or remove comments and may restrict participation
for behavior that makes collaboration unsafe or unproductive.
Report conduct concerns privately through the repository owner account or the
private contact channel listed on the project homepage.

View File

@ -1,100 +1,161 @@
# Contributing to Archipelago
This project is preparing for public developer contribution. The highest-value
contributions are focused fixes, tests, app manifests, documentation
improvements, and clear bug reports with reproducible evidence.
Thank you for your interest in contributing to Archipelago! This document covers the process for contributing code, reporting bugs, and submitting apps.
## Development setup
## Code of Conduct
### Frontend
Be respectful. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
## 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`)
4. Create a feature branch: `git checkout -b feature/your-feature`
## Development Setup
### Frontend (Vue.js)
```bash
cd neode-ui
npm install
npm start
npm run type-check
npm test
npm start # Dev server on :8100
npm run type-check # TypeScript validation
npm run build # Production build
npm test # Run tests
```
### Backend
### Backend (Rust)
Build on a Linux server (Debian 13), **not** macOS:
```bash
cd core
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo clippy --all-targets --all-features
cargo fmt --all
cargo test --all-features
```
Linux is required for host integration work involving Podman, systemd,
networking, or image builds. Frontend development works locally with the mock
backend.
## App manifests
App packages live under `apps/<app-id>/manifest.yml` and use the schema
documented in [docs/app-manifest-spec.md](docs/app-manifest-spec.md). Validate
before submitting:
### Deploy to dev server
```bash
./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml
python3 scripts/generate-app-catalog.py
python3 scripts/check-app-catalog-drift.py --release --strict
./scripts/deploy-to-target.sh --live
```
App submissions must:
## Code Style
- pin container image versions;
- avoid hardcoded secrets;
- use `security.no_new_privileges: true`;
- use `security.readonly_root: true` unless the manifest explains why writable
root is required;
- request only necessary Linux capabilities;
- store durable data under `/var/lib/archipelago/<app-id>/`;
- define truthful health checks and launch interfaces for user-facing UIs.
### Frontend (TypeScript + Vue)
## Code style
- `<script setup lang="ts">` — always Composition API
- TypeScript strict mode — no `any`, use `unknown` or proper types
- Global CSS classes in `src/style.css` — never inline Tailwind in components
- Pinia for state management — focused single-purpose stores
- Use `@/api/rpc-client.ts` for RPC calls
- Rust: prefer `?` over `unwrap()`/`expect()` in production paths.
- Rust: use `tracing` for structured logs.
- TypeScript: avoid `any`; use explicit types or `unknown`.
- Vue: prefer `<script setup lang="ts">`.
- Keep changes scoped; do not mix drive-by refactors with behavioral changes.
- Remove dead code rather than commenting it out.
- Add tests for new behavior and regression tests for bug fixes.
### Backend (Rust)
## Pull requests
- No `unwrap()` or `expect()` in production code — use `?` operator
- `thiserror` for library errors, `anyhow` for application errors
- `tracing` for structured logging — never `println!`
- Run `cargo clippy` and `cargo fmt` before commits
1. Open one focused PR per behavior or documentation change.
2. Explain what changed, why it changed, and how it was verified.
3. Include screenshots for UI changes.
4. Link relevant issues or docs.
5. Keep generated catalog changes in sync with manifest changes.
### General
Suggested commit format:
- Functions under 50 lines, single responsibility
- Comment WHY not WHAT
- Remove dead code — never comment it out
- No `TODO`/`FIXME` in commits
```text
feat: add backup scheduling
fix: reject unsafe manifest volume
docs: clarify app deployment flow
test: cover catalog drift check
## Commit Format
```
type: description
```
## Reporting bugs
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
Include:
Examples:
- `feat: add backup scheduling to settings page`
- `fix: handle WiFi connection timeout gracefully`
- `test: add unit tests for RPC client retry logic`
- exact version or commit;
- host platform and architecture;
- steps to reproduce;
- expected and actual behavior;
- logs from the relevant component;
- screenshots for UI issues.
## Pull Request Process
## Security
1. Ensure your branch is up to date with `main`
2. All checks must pass: TypeScript, build, tests, clippy
3. Include a clear description of what changed and why
4. Link any related issues
5. Request review from a maintainer
Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md).
### PR Checklist
- [ ] TypeScript type-check passes (`npm run type-check`)
- [ ] Frontend builds (`npm run build`)
- [ ] Tests pass (`npm test`)
- [ ] Rust clippy clean (`cargo clippy --all-targets --all-features`)
- [ ] No new compiler warnings
- [ ] Follows code style guidelines above
## Testing Requirements
- New features need tests
- Bug fixes need a regression test
- Frontend: Vitest + Vue Test Utils
- Backend: `#[test]` and `#[tokio::test]`
- Target: maintain or improve existing coverage
## Reporting Bugs
Use the **Bug Report** issue template. Include:
1. Steps to reproduce
2. Expected behavior
3. Actual behavior
4. System info (hardware, OS version, Archipelago version)
5. Screenshots if applicable
6. Relevant logs (`journalctl -u archipelago`)
## Feature Requests
Use the **Feature Request** issue template. Include:
1. Problem description
2. Proposed solution
3. Alternatives considered
4. Impact on existing users
## App Submissions
To submit an app for the Archipelago marketplace:
1. Create a manifest following `docs/app-manifest-spec.md`
2. Ensure the container image is published to a public registry
3. Test on Archipelago hardware (x86_64 and ARM64 if possible)
4. Open a PR adding the app to the curated list
5. Include: app description, icon, resource requirements, dependencies
### App Requirements
- Container must run as non-root (UID > 1000)
- `readonly_root: true` unless explicitly justified
- Drop all capabilities except those required
- `no-new-privileges: true`
- Pin specific image versions (no `latest` tag)
- No hardcoded secrets
## Security Disclosure
**Do NOT open public issues for security vulnerabilities.**
Email security concerns to the maintainers directly. Include:
1. Description of the vulnerability
2. Steps to reproduce
3. Potential impact
4. Suggested fix (if any)
We will acknowledge receipt within 48 hours and provide a timeline for a fix.
## License
By contributing, you agree that your contribution is licensed under the
project's MIT License.
By contributing, you agree that your contributions will be licensed under the same license as the project.

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Dorian and the Archipelago Project contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

73
NOTICE
View File

@ -1,73 +0,0 @@
# Archipelago — Third-Party Notices
Archipelago is licensed under the MIT License (see LICENSE).
This file lists third-party components included in this repository and its
release artifacts, with their licenses and required attributions.
## Embedded / vendored components
- **FIPS mesh networking** — https://github.com/jmcorgan/fips
Copyright (c) 2026 Johnathan Corgan. MIT License.
Used as the embedded mesh VPN in the OS (`fips` daemon, pinned v0.4.1) and
compiled into the Android companion app (`Android/rust/archy-fips-core`).
- **QR Code Generator for JavaScript** — http://www.d-project.com/
Copyright (c) 2009 Kazuhiko Arase. MIT License.
Vendored at `docker/lnd-ui/qrcode.js` and `docker/electrs-ui/qrcode.js`
(original headers preserved).
- **nostr-rs-relay** — https://github.com/scsibug/nostr-rs-relay — MIT License.
Binary extracted into the OS image at `/opt/archipelago/bin/`.
- **Reticulum (RNS) and LXMF** — https://github.com/markqvist/Reticulum
Copyright Mark Qvist. Distributed under the Reticulum License (an MIT-style
license with field-of-use restrictions: no use in systems designed to harm
human beings, and no use in AI/ML training datasets). The optional
`archy-reticulum-daemon` binary bundles RNS 1.3.5 and LXMF 1.0.1. The
Reticulum License is NOT an OSI-approved open-source license; it applies
only to that optional component, not to Archipelago itself.
## Fonts
- **Montserrat** — SIL Open Font License 1.1
(`neode-ui/public/assets/fonts/Montserrat/OFL.txt`).
- **Open Sans** — Apache License 2.0
(`neode-ui/public/assets/fonts/Open_Sans/LICENSE.txt`).
## Artwork and icons
- **Mesh device artwork** (`neode-ui/public/assets/img/mesh-devices/`):
device illustrations from the Meshtastic project — https://meshtastic.org
© Meshtastic contributors, GPL-3.0. Meshtastic® is a registered trademark
of Meshtastic LLC. See the ATTRIBUTION.md in that directory.
- Some UI icons are derived from **game-icons.net** (CC BY 3.0 — see
ATTRIBUTION.md in `neode-ui/public/assets/icon/`) and **pixelarticons**
(MIT, https://github.com/halfmage/pixelarticons).
- Third-party application logos under `neode-ui/public/assets/img/app-icons/`
and `service-icons/` are trademarks of their respective owners, used solely
to identify the corresponding applications. No endorsement is implied.
## Original media
All demo content (music, photos, posters in `demo/`), UI sound effects,
background images, and intro video in `neode-ui/public/assets/` are original
works created and owned by the Archipelago project author, released with the
project. The welcome voice line (`welcome-noderunner.mp3`) was generated with
ElevenLabs TTS under a commercial-use plan.
## Redistributed software (ISO and container registry)
The Archipelago OS image is based on Debian and redistributes Debian packages
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
required for hardware support); per-package license texts are preserved at
`/usr/share/doc/*/copyright` in the installed system, and corresponding source
is available via Debian (https://snapshot.debian.org) as referenced in each
release's notes. Container images offered through the app catalog and mirror
registry remain under their upstream licenses (including GPL/AGPL software
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in
the app catalog. The modified mempool-frontend image is built from
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
Full per-crate and per-package license inventories for release binaries are
generated at build time (see THIRD-PARTY-LICENSES files in release artifacts).

229
README.md
View File

@ -1,11 +1,8 @@
# Archipelago
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
> Self-Sovereign Bitcoin Node OS
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
self-hosted apps, mesh communication, decentralized identity, and federation.
Apps are packaged as declarative `manifest.yml` files and run as rootless
Podman containers managed by the Rust backend.
**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.
[![Debian 13](https://img.shields.io/badge/Debian-13%20Trixie-a80030)](https://www.debian.org/)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
@ -13,101 +10,193 @@ Podman containers managed by the Rust backend.
[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/)
[![Version](https://img.shields.io/badge/version-1.8.0--alpha-blue)]()
## What is here
## Philosophy
- `core/` - Rust workspace: backend API, container runtime, security, OpenWrt
helpers, and performance/resource management.
- `neode-ui/` - Vue 3 + TypeScript frontend.
- `apps/` - app manifests and custom app container sources.
- `docker/` - supporting container build contexts for UI companion surfaces.
- `image-recipe/` - bootable image/ISO build inputs.
- `Android/` - Android companion app.
- `scripts/` - development, release, deployment, and validation tooling.
- `docs/` - architecture, app packaging, operations, API, and roadmap docs.
Archipelago is being built as a **developer-ready app platform**, not a fixed appliance:
## Platform model
- **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.
Archipelago is built as a developer-ready app platform, not a fixed appliance:
## Features
- Apps are declared in `apps/<app-id>/manifest.yml`.
- The Rust parser in `core/container/src/manifest.rs` is the canonical schema.
- The orchestrator compiles manifests to rootless Podman/Quadlet runtime state.
- App data lives under `/var/lib/archipelago/<app-id>/`.
- Secrets are generated or read from `/var/lib/archipelago/secrets/` and
injected through Podman secrets rather than static environment values.
- Release and app catalogs are signed and verified against a pinned trust
anchor.
### 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
- **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
Start with:
### 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.
- [Architecture](docs/architecture.md)
- [Developer Guide](docs/developer-guide.md)
- [App Developer Guide](docs/app-developer-guide.md)
- [App Manifest Spec](docs/app-manifest-spec.md)
- [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md)
- [Operations Runbook](docs/operations-runbook.md)
- [Troubleshooting](docs/troubleshooting.md)
### 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
## Quick start
### 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
### Frontend
### 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)
### 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)
### 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).
## Quick Start
### Install from ISO
1. Build or download the ISO for your architecture (x86_64 or ARM64) — see [`image-recipe/`](image-recipe/)
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)
### Supported Hardware
| Platform | Examples | Minimum |
|----------|----------|---------|
| **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.
## Development
### Prerequisites
- macOS or Linux for frontend development
- Linux dev server (Debian 13) for backend builds — **never build Rust on macOS for Linux**
- Node.js 20+, Rust stable toolchain
### Frontend Development
```bash
cd neode-ui
npm install
npm start
npm start # Dev server on http://localhost:8100 (mock backend on :5959)
npm run type-check # TypeScript validation
npm run build # Production build → web/dist/neode-ui/
```
The dev UI runs at `http://localhost:8100` with a mock backend on `:5959`.
### Backend
### Backend Development
```bash
cd core
cd core # Rust workspace root (no Cargo.toml at repo root)
cargo build
cargo test --all-features
cargo test
```
Linux is the supported backend runtime and release-build target. macOS is fine
for frontend work and many Rust compile/test loops, but host integration tests
that touch Podman, systemd, networking, or image build paths require Linux.
### App manifests
### Deploy to a Test Node
```bash
./scripts/validate-app-manifest.sh apps/filebrowser/manifest.yml
python3 scripts/generate-app-catalog.py
python3 scripts/check-app-catalog-drift.py --release --strict
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
```
`scripts/generate-app-catalog.py` requires Python with PyYAML installed.
### Release (tarball-only)
## Documentation map
Releases ship as a backend binary and a frontend tarball referenced by
`releases/manifest.json`, published to the self-hosted Gitea release server.
```bash
./scripts/create-release.sh 1.2.3
git push origin main --tags
```
## Architecture
```
Debian 13 (Trixie)
├── Rootless Podman — every app a systemd Quadlet unit under user.slice
├── 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)
└── System Tor (hidden services, SOCKS5 proxy)
```
~117,000 lines of Rust | ~69,000 lines of TypeScript/Vue | 51 packaged apps | Android companion app
## Documentation
| Doc | Purpose |
|-----|---------|
| [Architecture](docs/architecture.md) | System layers, crates, data paths, security model |
| [Developer Guide](docs/developer-guide.md) | Local setup, code workflow, testing |
| [API Reference](docs/api-reference.md) | JSON-RPC API overview |
| [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps |
| [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules |
| [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model |
| [Apps README](apps/README.md) | Packaged app catalog overview |
| [Image Recipe](image-recipe/README.md) | Bootable image build flow |
| [Operations Runbook](docs/operations-runbook.md) | Production operations and recovery |
| [Open Source Readiness](docs/OPEN_SOURCE_READINESS.md) | Public-release cleanup checklist |
| [Roadmap](docs/ROADMAP.md) | Shipped, in-progress, and planned work |
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Launch hardening task list |
| [Archive](docs/archive/) | Historical plans, audits, and handoffs |
| [Architecture](docs/architecture.md) | System design, crate map, data paths |
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
| [API Reference](docs/api-reference.md) | 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 |
## Contributing
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For
security issues, follow [SECURITY.md](SECURITY.md) and do not open a public
issue.
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)
4. Submit a pull request
## License
Archipelago is licensed under the [MIT License](LICENSE). Third-party notices
are listed in [NOTICE](NOTICE) and generated license inventories in component
release artifacts.
[MIT License](LICENSE)
## 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/)

View File

@ -1,38 +0,0 @@
# Security Policy
## Reporting vulnerabilities
Please do not open a public issue for a security vulnerability.
Until a dedicated security intake address is published, report privately to the
project maintainer through the repository owner account or the private contact
channel listed on the project homepage.
Include:
- affected commit, version, or release;
- affected component;
- reproduction steps;
- expected impact;
- logs, proof of concept, or packet captures when relevant;
- whether the issue is already public.
We aim to acknowledge credible reports within 48 hours and coordinate fixes
before public disclosure.
## Scope
Security-sensitive areas include:
- authentication, session handling, CSRF, and rate limiting;
- release and app-catalog signature verification;
- container manifest validation and runtime compilation;
- Podman/Quadlet isolation, capabilities, volumes, and secret injection;
- backup encryption and key derivation;
- federation, Tor, Nostr, mesh, DID, and credential flows;
- Android companion pairing and device-token handling.
## Supported versions
Archipelago is currently pre-1.0 alpha software. Security fixes target the
current `main` branch and the latest published alpha release.

View File

@ -76,17 +76,7 @@ podman run -p 18084:8080 \
## Integration Checklist
Adding a new app requires updates in multiple places:
- add `apps/<app-id>/manifest.yml`;
- add a Dockerfile and source directory only when the app is built locally;
- choose non-conflicting ports from [PORTS.md](./PORTS.md);
- declare `interfaces.main` for user-facing web UIs;
- declare generated secrets instead of hardcoding credentials;
- run `./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml`;
- regenerate catalogs with `python3 scripts/generate-app-catalog.py`;
- verify drift with `python3 scripts/check-app-catalog-drift.py --release --strict`;
- test install, launch, stop, start, restart, uninstall, and reinstall.
Adding a new app requires updates in multiple places. See the full checklist in [CLAUDE.md](../CLAUDE.md) under "App Integration Checklist".
## Port Assignments

33
core/.env.production Normal file
View File

@ -0,0 +1,33 @@
# Archipelago Production Configuration
# This file is bundled with the macOS app
# Server Configuration
ARCHIPELAGO_HOST=127.0.0.1
ARCHIPELAGO_PORT=8100
ARCHIPELAGO_BACKEND_PORT=3030
# Data Directories (relative to ~/Library/Application Support/Archipelago)
ARCHIPELAGO_DATA_DIR=data
ARCHIPELAGO_LOG_DIR=logs
# Frontend Configuration
ARCHIPELAGO_FRONTEND_DIR=frontend
# Docker UI Configuration
ARCHIPELAGO_DOCKER_UI_DIR=docker-ui
# Security
ARCHIPELAGO_SESSION_SECRET=CHANGE_ME_ON_FIRST_RUN
# Logging
RUST_LOG=info
# Production Mode
NODE_ENV=production
ARCHIPELAGO_MODE=production
# Docker Configuration
DOCKER_HOST=unix:///var/run/docker.sock
# Disable External API Calls in Production
ARCHIPELAGO_DISABLE_EXTERNAL_APIS=true

View File

@ -1,656 +0,0 @@
# Third-Party Rust Crate Licenses — Archipelago core
Generated from `cargo metadata` (all features) on 2026-07-23. 649 external crates.
Full license texts ship with release artifacts (cargo-about; see docs/LICENSE-COMPLIANCE-AUDIT.md).
| Crate | Version | License | Source |
|---|---|---|---|
| adler2 | 2.0.1 | 0BSD OR MIT OR Apache-2.0 | https://github.com/oyvindln/adler2 |
| aead | 0.5.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| aes | 0.8.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-ciphers |
| aes-gcm | 0.10.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/AEADs |
| ahash | 0.7.8 | MIT OR Apache-2.0 | https://github.com/tkaitchuck/ahash |
| aho-corasick | 1.1.4 | Unlicense OR MIT | https://github.com/BurntSushi/aho-corasick |
| allocator-api2 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/zakarumych/allocator-api2 |
| android_system_properties | 0.1.5 | MIT/Apache-2.0 | https://github.com/nical/android_system_properties |
| anyhow | 1.0.100 | MIT OR Apache-2.0 | https://github.com/dtolnay/anyhow |
| arc-swap | 1.9.1 | MIT OR Apache-2.0 | https://github.com/vorner/arc-swap |
| argon2 | 0.5.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/argon2 |
| arrayref | 0.3.9 | BSD-2-Clause | https://github.com/droundy/arrayref |
| arrayvec | 0.7.6 | MIT OR Apache-2.0 | https://github.com/bluss/arrayvec |
| asn1-rs | 0.7.2 | MIT OR Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
| asn1-rs-derive | 0.6.0 | MIT OR Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
| asn1-rs-impl | 0.2.0 | MIT/Apache-2.0 | https://github.com/rusticata/asn1-rs.git |
| async-trait | 0.1.89 | MIT OR Apache-2.0 | https://github.com/dtolnay/async-trait |
| async-utility | 0.3.1 | MIT | https://github.com/yukibtc/async-utility.git |
| async-wsocket | 0.13.1 | MIT | https://github.com/yukibtc/async-wsocket.git |
| async_io_stream | 0.3.3 | Unlicense | https://github.com/najamelan/async_io_stream |
| atomic-destructor | 0.3.0 | MIT | https://github.com/yukibtc/atomic-destructor.git |
| atomic-polyfill | 1.0.3 | MIT OR Apache-2.0 | https://github.com/embassy-rs/atomic-polyfill |
| atomic-waker | 1.1.2 | Apache-2.0 OR MIT | https://github.com/smol-rs/atomic-waker |
| attohttpc | 0.30.1 | MPL-2.0 | https://github.com/sbstp/attohttpc |
| autocfg | 1.5.0 | Apache-2.0 OR MIT | https://github.com/cuviper/autocfg |
| backon | 1.6.0 | Apache-2.0 | https://github.com/Xuanwo/backon |
| bao-tree | 0.16.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/bao-tree |
| base16ct | 1.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| base32 | 0.5.1 | MIT OR Apache-2.0 | https://github.com/andreasots/base32 |
| base58ck | 0.1.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
| base64 | 0.21.7 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 |
| base64 | 0.22.1 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 |
| base64ct | 1.8.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| bcrypt | 0.15.1 | MIT | https://github.com/Keats/rust-bcrypt |
| bech32 | 0.11.1 | MIT | https://github.com/rust-bitcoin/rust-bech32 |
| binary-merge | 0.1.2 | MIT OR Apache-2.0 | https://github.com/rklaehn/binary-merge |
| bip39 | 2.1.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bip39/ |
| bit-vec | 0.9.1 | Apache-2.0 OR MIT | https://github.com/contain-rs/bit-vec |
| bitcoin | 0.32.5 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
| bitcoin-internals | 0.2.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
| bitcoin-internals | 0.3.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
| bitcoin-io | 0.1.4 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
| bitcoin-units | 0.1.2 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ |
| bitcoin_hashes | 0.13.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
| bitcoin_hashes | 0.14.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin |
| bitflags | 1.3.2 | MIT/Apache-2.0 | https://github.com/bitflags/bitflags |
| bitflags | 2.13.0 | MIT OR Apache-2.0 | https://github.com/bitflags/bitflags |
| blake2 | 0.10.6 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
| blake3 | 1.8.5 | CC0-1.0 OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception | https://github.com/BLAKE3-team/BLAKE3 |
| block-buffer | 0.10.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| block-buffer | 0.12.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| block-padding | 0.3.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| block2 | 0.6.2 | MIT | https://github.com/madsmtm/objc2 |
| blowfish | 0.9.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-ciphers |
| bs58 | 0.5.1 | MIT/Apache-2.0 | https://github.com/Nullus157/bs58-rs |
| bumpalo | 3.19.1 | MIT OR Apache-2.0 | https://github.com/fitzgen/bumpalo |
| bytemuck | 1.25.0 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/bytemuck |
| byteorder | 1.5.0 | Unlicense OR MIT | https://github.com/BurntSushi/byteorder |
| byteorder-lite | 0.1.0 | Unlicense OR MIT | https://github.com/image-rs/byteorder-lite |
| bytes | 1.11.0 | MIT | https://github.com/tokio-rs/bytes |
| cbc | 0.1.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-modes |
| cc | 1.2.54 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs |
| cesu8 | 1.1.0 | Apache-2.0/MIT | https://github.com/emk/cesu8-rs |
| cfg-if | 1.0.4 | MIT OR Apache-2.0 | https://github.com/rust-lang/cfg-if |
| cfg_aliases | 0.2.1 | MIT | https://github.com/katharostech/cfg_aliases |
| chacha20 | 0.10.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers |
| chacha20 | 0.9.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/stream-ciphers |
| chacha20poly1305 | 0.10.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/AEADs/tree/master/chacha20poly1305 |
| chrono | 0.4.43 | MIT OR Apache-2.0 | https://github.com/chronotope/chrono |
| ciborium | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
| ciborium-io | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
| ciborium-ll | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium |
| cipher | 0.4.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| cmov | 0.5.4 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
| cobs | 0.3.0 | MIT OR Apache-2.0 | https://github.com/jamesmunns/cobs.rs |
| combine | 4.6.7 | MIT | https://github.com/Marwes/combine |
| const-oid | 0.10.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| const-oid | 0.9.6 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/const-oid |
| constant_time_eq | 0.3.1 | CC0-1.0 OR MIT-0 OR Apache-2.0 | https://github.com/cesarb/constant_time_eq |
| constant_time_eq | 0.4.2 | CC0-1.0 OR MIT-0 OR Apache-2.0 | https://github.com/cesarb/constant_time_eq |
| convert_case | 0.10.0 | MIT | https://github.com/rutrum/convert-case |
| cordyceps | 0.3.4 | MIT | https://github.com/hawkw/mycelium |
| core-foundation | 0.10.1 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
| core-foundation | 0.9.4 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
| core-foundation-sys | 0.8.7 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs |
| cpufeatures | 0.2.17 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| cpufeatures | 0.3.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| crc | 3.4.0 | MIT OR Apache-2.0 | https://github.com/mrhooray/crc-rs.git |
| crc-catalog | 2.4.0 | MIT OR Apache-2.0 | https://github.com/akhilles/crc-catalog.git |
| crc32fast | 1.5.0 | MIT OR Apache-2.0 | https://github.com/srijs/rust-crc32fast |
| critical-section | 1.2.0 | MIT OR Apache-2.0 | https://github.com/rust-embedded/critical-section |
| crossbeam-channel | 0.5.15 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
| crossbeam-epoch | 0.9.18 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
| crossbeam-utils | 0.8.21 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam |
| crunchy | 0.2.4 | MIT | https://github.com/eira-fransham/crunchy |
| crypto-common | 0.1.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| crypto-common | 0.2.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| ctr | 0.9.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-modes |
| ctutils | 0.4.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
| curve25519-dalek | 4.1.3 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek |
| curve25519-dalek | 5.0.0-rc.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek |
| curve25519-dalek-derive | 0.1.1 | MIT/Apache-2.0 | https://github.com/dalek-cryptography/curve25519-dalek |
| darling | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
| darling_core | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
| darling_macro | 0.20.11 | MIT | https://github.com/TedDriggs/darling |
| data-encoding | 2.11.0 | MIT | https://github.com/ia0/data-encoding |
| data-encoding-macro | 0.1.20 | MIT | https://github.com/ia0/data-encoding |
| data-encoding-macro-internal | 0.1.18 | MIT | https://github.com/ia0/data-encoding |
| der | 0.7.10 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/der |
| der | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| der-parser | 10.0.0 | MIT OR Apache-2.0 | https://github.com/rusticata/der-parser.git |
| deranged | 0.5.8 | MIT OR Apache-2.0 | https://github.com/jhpratt/deranged |
| derive_builder | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
| derive_builder_core | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
| derive_builder_macro | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder |
| derive_more | 2.1.1 | MIT | https://github.com/JelteF/derive_more |
| derive_more-impl | 2.1.1 | MIT | https://github.com/JelteF/derive_more |
| diatomic-waker | 0.2.3 | MIT OR Apache-2.0 | https://github.com/asynchronics/diatomic-waker |
| digest | 0.10.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| digest | 0.11.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| dispatch2 | 0.3.1 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
| displaydoc | 0.2.5 | MIT OR Apache-2.0 | https://github.com/yaahc/displaydoc |
| dlopen2 | 0.8.2 | MIT | https://github.com/OpenByteDev/dlopen2 |
| ed25519 | 2.2.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/signatures/tree/master/ed25519 |
| ed25519 | 3.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/signatures |
| ed25519-dalek | 2.2.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek |
| ed25519-dalek | 3.0.0-rc.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek |
| either | 1.15.0 | MIT OR Apache-2.0 | https://github.com/rayon-rs/either |
| embedded-io | 0.4.0 | MIT OR Apache-2.0 | https://github.com/embassy-rs/embedded-io |
| embedded-io | 0.6.1 | MIT OR Apache-2.0 | https://github.com/rust-embedded/embedded-hal |
| encoding_rs | 0.8.35 | (Apache-2.0 OR MIT) AND BSD-3-Clause | https://github.com/hsivonen/encoding_rs |
| enum-assoc | 1.3.0 | MIT OR Apache-2.0 | https://github.com/Eolu/enum-assoc |
| env_logger | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-cli/env_logger |
| equivalent | 1.0.2 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/equivalent |
| errno | 0.3.14 | MIT OR Apache-2.0 | https://github.com/lambda-fairy/rust-errno |
| fastbloom | 0.17.0 | MIT OR Apache-2.0 | https://github.com/tomtomwombat/fastbloom/ |
| fastrand | 2.3.0 | Apache-2.0 OR MIT | https://github.com/smol-rs/fastrand |
| fiat-crypto | 0.2.9 | MIT OR Apache-2.0 OR BSD-1-Clause | https://github.com/mit-plv/fiat-crypto |
| fiat-crypto | 0.3.0 | MIT OR Apache-2.0 OR BSD-1-Clause | https://github.com/mit-plv/fiat-crypto |
| filetime | 0.2.27 | MIT/Apache-2.0 | https://github.com/alexcrichton/filetime |
| find-msvc-tools | 0.1.8 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs |
| flate2 | 1.1.9 | MIT OR Apache-2.0 | https://github.com/rust-lang/flate2-rs |
| flume | 0.11.1 | Apache-2.0/MIT | https://github.com/zesterer/flume |
| fnv | 1.0.7 | Apache-2.0 / MIT | https://github.com/servo/rust-fnv |
| foldhash | 0.1.5 | Zlib | https://github.com/orlp/foldhash |
| foldhash | 0.2.0 | Zlib | https://github.com/orlp/foldhash |
| form_urlencoded | 1.2.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url |
| futures | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-buffered | 0.2.13 | MIT | https://github.com/conradludgate/futures-buffered |
| futures-channel | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-core | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-executor | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-io | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-lite | 2.6.1 | Apache-2.0 OR MIT | https://github.com/smol-rs/futures-lite |
| futures-macro | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-sink | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-task | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| futures-util | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs |
| genawaiter | 0.99.1 | MIT | https://github.com/whatisaphone/genawaiter |
| genawaiter-macro | 0.99.1 | MIT/Apache-2.0 | https://github.com/whatisaphone/genawaiter |
| genawaiter-proc-macro | 0.99.1 | MIT/Apache-2.0 | https://github.com/whatisaphone/genawaiter |
| generator | 0.8.9 | MIT/Apache-2.0 | https://github.com/Xudong-Huang/generator-rs.git |
| generic-array | 0.14.7 | MIT | https://github.com/fizyk20/generic-array.git |
| getrandom | 0.2.17 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
| getrandom | 0.3.4 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
| getrandom | 0.4.2 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom |
| ghash | 0.5.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
| gloo-timers | 0.3.0 | MIT OR Apache-2.0 | https://github.com/rustwasm/gloo/tree/master/crates/timers |
| h2 | 0.3.27 | MIT | https://github.com/hyperium/h2 |
| h2 | 0.4.13 | MIT | https://github.com/hyperium/h2 |
| half | 2.7.1 | MIT OR Apache-2.0 | https://github.com/VoidStarKat/half-rs |
| hash32 | 0.2.1 | MIT OR Apache-2.0 | https://github.com/japaric/hash32 |
| hashbrown | 0.12.3 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
| hashbrown | 0.15.5 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
| hashbrown | 0.16.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
| hashbrown | 0.17.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown |
| heapless | 0.7.17 | MIT OR Apache-2.0 | https://github.com/japaric/heapless |
| heck | 0.5.0 | MIT OR Apache-2.0 | https://github.com/withoutboats/heck |
| hermit-abi | 0.5.2 | MIT OR Apache-2.0 | https://github.com/hermit-os/hermit-rs |
| hex | 0.4.3 | MIT OR Apache-2.0 | https://github.com/KokaKiwi/rust-hex |
| hex-conservative | 0.1.2 | CC0-1.0 | https://github.com/rust-bitcoin/hex-conservative |
| hex-conservative | 0.2.2 | CC0-1.0 | https://github.com/rust-bitcoin/hex-conservative |
| hex_lit | 0.1.1 | MITNFA | https://github.com/Kixunil/hex_lit |
| hickory-net | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
| hickory-proto | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
| hickory-resolver | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns |
| hkdf | 0.12.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/KDFs/ |
| hmac | 0.12.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/MACs |
| http | 0.2.12 | MIT OR Apache-2.0 | https://github.com/hyperium/http |
| http | 1.4.0 | MIT OR Apache-2.0 | https://github.com/hyperium/http |
| http-body | 0.4.6 | MIT | https://github.com/hyperium/http-body |
| http-body | 1.0.1 | MIT | https://github.com/hyperium/http-body |
| http-body-util | 0.1.3 | MIT | https://github.com/hyperium/http-body |
| httparse | 1.10.1 | MIT OR Apache-2.0 | https://github.com/seanmonstar/httparse |
| httpdate | 1.0.3 | MIT OR Apache-2.0 | https://github.com/pyfisch/httpdate |
| humantime | 2.3.0 | MIT OR Apache-2.0 | https://github.com/chronotope/humantime |
| hybrid-array | 0.4.12 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hybrid-array |
| hyper | 0.14.32 | MIT | https://github.com/hyperium/hyper |
| hyper | 1.8.1 | MIT | https://github.com/hyperium/hyper |
| hyper-rustls | 0.24.2 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls |
| hyper-rustls | 0.27.9 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls |
| hyper-util | 0.1.19 | MIT | https://github.com/hyperium/hyper-util |
| hyper-ws-listener | 0.3.0 | MIT | |
| iana-time-zone | 0.1.64 | MIT OR Apache-2.0 | https://github.com/strawlab/iana-time-zone |
| iana-time-zone-haiku | 0.1.2 | MIT OR Apache-2.0 | https://github.com/strawlab/iana-time-zone |
| icu_collections | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| icu_locale_core | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| icu_normalizer | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| icu_normalizer_data | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| icu_properties | 2.1.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| icu_properties_data | 2.1.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| icu_provider | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| id-arena | 2.3.0 | MIT/Apache-2.0 | https://github.com/fitzgen/id-arena |
| ident_case | 1.0.1 | MIT/Apache-2.0 | https://github.com/TedDriggs/ident_case |
| identity-hash | 0.1.0 | Apache-2.0 OR MIT | https://github.com/offsetting/identity-hash |
| idna | 1.1.0 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ |
| idna_adapter | 1.2.1 | Apache-2.0 OR MIT | https://github.com/hsivonen/idna_adapter |
| if-addrs | 0.15.0 | MIT OR BSD-3-Clause | https://github.com/messense/if-addrs |
| igd-next | 0.17.1 | MIT | https://github.com/dariusc93/rust-igd |
| image | 0.25.9 | MIT OR Apache-2.0 | https://github.com/image-rs/image |
| indexmap | 2.13.0 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/indexmap |
| inout | 0.1.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| inplace-vec-builder | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rklaehn/inplace-vec-builder |
| instant | 0.1.13 | BSD-3-Clause | https://github.com/sebcrozet/instant |
| ipconfig | 0.3.4 | MIT/Apache-2.0 | https://github.com/liranringel/ipconfig |
| ipnet | 2.12.0 | MIT OR Apache-2.0 | https://github.com/krisprice/ipnet |
| iri-string | 0.7.12 | MIT OR Apache-2.0 | https://github.com/lo48576/iri-string |
| iroh | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
| iroh-base | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
| iroh-blobs | 0.103.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-blobs |
| iroh-dns | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
| iroh-io | 0.6.2 | Apache-2.0 OR MIT | https://github.com/n0-computer/iroh |
| iroh-metrics | 1.0.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-metrics |
| iroh-metrics-derive | 1.0.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-metrics |
| iroh-relay | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
| iroh-tickets | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-tickets |
| iroh-util | 0.6.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-util |
| irpc | 0.17.0 | Apache-2.0/MIT | https://github.com/n0-computer/irpc |
| irpc-derive | 0.17.0 | Apache-2.0/MIT | https://github.com/n0-computer/irpc |
| is-terminal | 0.4.17 | MIT | https://github.com/sunfishcode/is-terminal |
| itoa | 1.0.17 | MIT OR Apache-2.0 | https://github.com/dtolnay/itoa |
| jni | 0.21.1 | MIT/Apache-2.0 | https://github.com/jni-rs/jni-rs |
| jni | 0.22.4 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-rs |
| jni-macros | 0.22.4 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-rs |
| jni-sys | 0.3.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
| jni-sys | 0.4.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
| jni-sys-macros | 0.4.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys |
| js-sys | 0.3.85 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/js-sys |
| lazy_static | 1.5.0 | MIT OR Apache-2.0 | https://github.com/rust-lang-nursery/lazy-static.rs |
| leb128fmt | 0.1.0 | MIT OR Apache-2.0 | https://github.com/bluk/leb128fmt |
| libc | 0.2.180 | MIT OR Apache-2.0 | https://github.com/rust-lang/libc |
| libm | 0.2.16 | MIT | https://github.com/rust-lang/compiler-builtins |
| libredox | 0.1.14 | MIT | https://gitlab.redox-os.org/redox-os/libredox.git |
| libssh2-sys | 0.3.1 | MIT OR Apache-2.0 | https://github.com/alexcrichton/ssh2-rs |
| libz-sys | 1.1.29 | MIT OR Apache-2.0 | https://github.com/rust-lang/libz-sys |
| linux-raw-sys | 0.11.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/sunfishcode/linux-raw-sys |
| litemap | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| lock_api | 0.4.14 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
| log | 0.4.29 | MIT OR Apache-2.0 | https://github.com/rust-lang/log |
| loom | 0.7.2 | MIT | https://github.com/tokio-rs/loom |
| lru | 0.12.5 | MIT | https://github.com/jeromefroe/lru-rs.git |
| lru | 0.16.3 | MIT | https://github.com/jeromefroe/lru-rs.git |
| lru | 0.18.0 | MIT | https://github.com/jeromefroe/lru-rs.git |
| lru | 0.7.8 | MIT | https://github.com/jeromefroe/lru-rs.git |
| lru-slab | 0.1.2 | MIT OR Apache-2.0 OR Zlib | https://github.com/Ralith/lru-slab |
| mac-addr | 0.3.0 | MIT | https://github.com/shellrow/mac-addr |
| mainline | 2.0.1 | MIT | https://github.com/nuhvi/mainline |
| matchers | 0.2.0 | MIT | https://github.com/hawkw/matchers |
| mdns-sd | 0.18.2 | Apache-2.0 OR MIT | https://github.com/keepsimple1/mdns-sd |
| memchr | 2.7.6 | Unlicense OR MIT | https://github.com/BurntSushi/memchr |
| mime | 0.3.17 | MIT OR Apache-2.0 | https://github.com/hyperium/mime |
| minimal-lexical | 0.2.1 | MIT/Apache-2.0 | https://github.com/Alexhuszagh/minimal-lexical |
| miniz_oxide | 0.8.9 | MIT OR Zlib OR Apache-2.0 | https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide |
| mio | 1.1.1 | MIT | https://github.com/tokio-rs/mio |
| moka | 0.12.15 | (MIT OR Apache-2.0) AND Apache-2.0 | https://github.com/moka-rs/moka |
| moxcms | 0.7.11 | BSD-3-Clause OR Apache-2.0 | https://github.com/awxkee/moxcms.git |
| n0-error | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-error |
| n0-error-macros | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-error |
| n0-future | 0.3.2 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-future |
| n0-watcher | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-watcher |
| ndk-context | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rust-windowing/android-ndk-rs |
| negentropy | 0.5.0 | MIT | https://github.com/rust-nostr/negentropy.git |
| nested_enum_utils | 0.2.3 | MIT OR Apache-2.0 | https://github.com/n0-computer/nested-enum-utils |
| netdev | 0.44.0 | MIT | https://github.com/shellrow/netdev |
| netlink-packet-core | 0.8.1 | MIT | https://github.com/rust-netlink/netlink-packet-core |
| netlink-packet-route | 0.29.0 | MIT | https://github.com/rust-netlink/netlink-packet-route |
| netlink-packet-route | 0.31.0 | MIT | https://github.com/rust-netlink/netlink-packet-route |
| netlink-proto | 0.12.0 | MIT | https://github.com/rust-netlink/netlink-proto |
| netlink-sys | 0.8.8 | MIT | https://github.com/rust-netlink/netlink-sys |
| netwatch | 0.19.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/net-tools |
| nom | 7.1.3 | MIT | https://github.com/Geal/nom |
| noq | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
| noq-proto | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
| noq-udp | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq |
| nostr | 0.44.2 | MIT | https://github.com/rust-nostr/nostr.git |
| nostr-database | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
| nostr-gossip | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
| nostr-relay-pool | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git |
| nostr-sdk | 0.44.1 | MIT | https://github.com/rust-nostr/nostr.git |
| nu-ansi-term | 0.50.3 | MIT | https://github.com/nushell/nu-ansi-term |
| num-bigint | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rust-num/num-bigint |
| num-conv | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jhpratt/num-conv |
| num-integer | 0.1.46 | MIT OR Apache-2.0 | https://github.com/rust-num/num-integer |
| num-traits | 0.2.19 | MIT OR Apache-2.0 | https://github.com/rust-num/num-traits |
| num_enum | 0.7.6 | BSD-3-Clause OR MIT OR Apache-2.0 | https://github.com/illicitonion/num_enum |
| num_enum_derive | 0.7.6 | BSD-3-Clause OR MIT OR Apache-2.0 | https://github.com/illicitonion/num_enum |
| num_threads | 0.1.7 | MIT OR Apache-2.0 | https://github.com/jhpratt/num_threads |
| objc2 | 0.6.4 | MIT | https://github.com/madsmtm/objc2 |
| objc2-core-foundation | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
| objc2-core-wlan | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
| objc2-encode | 4.1.0 | MIT | https://github.com/madsmtm/objc2 |
| objc2-foundation | 0.3.2 | MIT | https://github.com/madsmtm/objc2 |
| objc2-security | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
| objc2-security-foundation | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
| objc2-system-configuration | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 |
| oid-registry | 0.8.1 | MIT OR Apache-2.0 | https://github.com/rusticata/oid-registry.git |
| once_cell | 1.21.3 | MIT OR Apache-2.0 | https://github.com/matklad/once_cell |
| opaque-debug | 0.3.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils |
| openssl-probe | 0.2.1 | MIT OR Apache-2.0 | https://github.com/rustls/openssl-probe |
| openssl-sys | 0.9.117 | MIT | https://github.com/rust-openssl/rust-openssl |
| papaya | 0.2.4 | MIT | https://github.com/ibraheemdev/papaya |
| parking | 2.2.1 | Apache-2.0 OR MIT | https://github.com/smol-rs/parking |
| parking_lot | 0.11.2 | Apache-2.0/MIT | https://github.com/Amanieu/parking_lot |
| parking_lot | 0.12.5 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
| parking_lot_core | 0.8.6 | Apache-2.0/MIT | https://github.com/Amanieu/parking_lot |
| parking_lot_core | 0.9.12 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot |
| password-hash | 0.5.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits/tree/master/password-hash |
| paste | 1.0.15 | MIT OR Apache-2.0 | https://github.com/dtolnay/paste |
| pbkdf2 | 0.12.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/pbkdf2 |
| pem | 3.0.6 | MIT | https://github.com/jcreekmore/pem-rs.git |
| pem-rfc7468 | 1.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| percent-encoding | 2.3.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ |
| pharos | 0.5.3 | Unlicense | https://github.com/najamelan/pharos |
| pin-project | 1.1.13 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project |
| pin-project-internal | 1.1.13 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project |
| pin-project-lite | 0.2.16 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project-lite |
| pin-utils | 0.1.0 | MIT OR Apache-2.0 | https://github.com/rust-lang-nursery/pin-utils |
| pkcs8 | 0.10.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/pkcs8 |
| pkcs8 | 0.11.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| pkg-config | 0.3.33 | MIT OR Apache-2.0 | https://github.com/rust-lang/pkg-config-rs |
| plain | 0.2.3 | MIT/Apache-2.0 | https://github.com/randomites/plain |
| plist | 1.9.0 | MIT | https://github.com/ebarnard/rust-plist/ |
| poly1305 | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
| polyval | 0.6.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes |
| portable-atomic | 1.13.1 | Apache-2.0 OR MIT | https://github.com/taiki-e/portable-atomic |
| portmapper | 0.19.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/net-tools |
| positioned-io | 0.3.5 | MIT | https://github.com/vasi/positioned-io |
| postcard | 1.1.3 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard |
| postcard-derive | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard |
| potential_utf | 0.1.4 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| powerfmt | 0.2.0 | MIT OR Apache-2.0 | https://github.com/jhpratt/powerfmt |
| ppv-lite86 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/cryptocorrosion/cryptocorrosion |
| prefix-trie | 0.8.4 | MIT OR Apache-2.0 | https://github.com/tiborschneider/prefix-trie |
| prettyplease | 0.2.37 | MIT OR Apache-2.0 | https://github.com/dtolnay/prettyplease |
| proc-macro-crate | 3.5.0 | MIT OR Apache-2.0 | https://github.com/bkchr/proc-macro-crate |
| proc-macro-error | 0.4.12 | MIT OR Apache-2.0 | https://gitlab.com/CreepySkeleton/proc-macro-error |
| proc-macro-error-attr | 0.4.12 | MIT OR Apache-2.0 | https://gitlab.com/CreepySkeleton/proc-macro-error |
| proc-macro-hack | 0.5.20+deprecated | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro-hack |
| proc-macro2 | 1.0.106 | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro2 |
| pxfm | 0.1.28 | BSD-3-Clause OR Apache-2.0 | https://github.com/awxkee/pxfm |
| qrcode | 0.14.1 | MIT OR Apache-2.0 | https://github.com/kennytm/qrcode-rust |
| quick-xml | 0.39.4 | MIT | https://github.com/tafia/quick-xml |
| quote | 1.0.44 | MIT OR Apache-2.0 | https://github.com/dtolnay/quote |
| r-efi | 5.3.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | https://github.com/r-efi/r-efi |
| r-efi | 6.0.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | https://github.com/r-efi/r-efi |
| rand | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand | 0.8.5 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand | 0.9.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand_chacha | 0.3.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand_chacha | 0.9.0 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand_core | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand_core |
| rand_core | 0.6.4 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand_core | 0.9.5 | MIT OR Apache-2.0 | https://github.com/rust-random/rand |
| rand_pcg | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rngs |
| range-collections | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rklaehn/range-collections |
| rcgen | 0.14.8 | MIT OR Apache-2.0 | https://github.com/rustls/rcgen |
| redb | 4.1.0 | MIT OR Apache-2.0 | https://github.com/cberner/redb |
| redox_syscall | 0.2.16 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
| redox_syscall | 0.5.18 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
| redox_syscall | 0.7.3 | MIT | https://gitlab.redox-os.org/redox-os/syscall |
| reed-solomon-erasure | 6.0.0 | MIT | https://github.com/darrenldl/reed-solomon-erasure |
| ref-cast | 1.0.25 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast |
| ref-cast-impl | 1.0.25 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast |
| reflink-copy | 0.1.29 | MIT/Apache-2.0 | https://github.com/cargo-bins/reflink-copy |
| regex | 1.12.2 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
| regex-automata | 0.4.13 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
| regex-syntax | 0.8.8 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex |
| reqwest | 0.11.27 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest |
| reqwest | 0.13.4 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest |
| resolv-conf | 0.7.6 | MIT OR Apache-2.0 | https://github.com/hickory-dns/resolv-conf |
| ring | 0.17.14 | Apache-2.0 AND ISC | https://github.com/briansmith/ring |
| rustc-hash | 2.1.2 | Apache-2.0 OR MIT | https://github.com/rust-lang/rustc-hash |
| rustc_version | 0.4.1 | MIT OR Apache-2.0 | https://github.com/djc/rustc-version-rs |
| rusticata-macros | 4.1.0 | MIT/Apache-2.0 | https://github.com/rusticata/rusticata-macros.git |
| rustix | 1.1.3 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/rustix |
| rustls | 0.21.12 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls |
| rustls | 0.23.36 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls |
| rustls-native-certs | 0.8.4 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls-native-certs |
| rustls-pemfile | 1.0.4 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/pemfile |
| rustls-pki-types | 1.14.0 | MIT OR Apache-2.0 | https://github.com/rustls/pki-types |
| rustls-platform-verifier | 0.7.0 | MIT OR Apache-2.0 | https://github.com/rustls/rustls-platform-verifier |
| rustls-platform-verifier-android | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rustls/rustls-platform-verifier |
| rustls-webpki | 0.101.7 | ISC | https://github.com/rustls/webpki |
| rustls-webpki | 0.103.9 | ISC | https://github.com/rustls/webpki |
| rustversion | 1.0.22 | MIT OR Apache-2.0 | https://github.com/dtolnay/rustversion |
| ryu | 1.0.22 | Apache-2.0 OR BSL-1.0 | https://github.com/dtolnay/ryu |
| salsa20 | 0.10.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers |
| same-file | 1.0.6 | Unlicense/MIT | https://github.com/BurntSushi/same-file |
| schannel | 0.1.29 | MIT | https://github.com/steffengy/schannel-rs |
| scoped-tls | 1.0.1 | MIT/Apache-2.0 | https://github.com/alexcrichton/scoped-tls |
| scopeguard | 1.2.0 | MIT OR Apache-2.0 | https://github.com/bluss/scopeguard |
| scrypt | 0.11.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/scrypt |
| sct | 0.7.1 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/sct.rs |
| sd-notify | 0.4.5 | MIT OR Apache-2.0 | https://github.com/lnicola/sd-notify |
| secp256k1 | 0.29.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-secp256k1/ |
| secp256k1-sys | 0.10.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-secp256k1/ |
| security-framework | 3.7.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework |
| security-framework-sys | 2.17.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework |
| seize | 0.5.1 | MIT | https://github.com/ibraheemdev/seize |
| self_cell | 1.2.2 | Apache-2.0 OR GPL-2.0-only | https://github.com/Voultapher/self_cell |
| semver | 1.0.27 | MIT OR Apache-2.0 | https://github.com/dtolnay/semver |
| send_wrapper | 0.6.0 | MIT/Apache-2.0 | https://github.com/thk1/send_wrapper |
| serde | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
| serde_bencode | 0.2.4 | MIT | https://github.com/toby/serde-bencode |
| serde_bytes | 0.11.19 | MIT OR Apache-2.0 | https://github.com/serde-rs/bytes |
| serde_core | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
| serde_derive | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde |
| serde_json | 1.0.149 | MIT OR Apache-2.0 | https://github.com/serde-rs/json |
| serde_spanned | 0.6.9 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| serde_urlencoded | 0.7.1 | MIT/Apache-2.0 | https://github.com/nox/serde_urlencoded |
| serde_yaml | 0.9.34+deprecated | MIT OR Apache-2.0 | https://github.com/dtolnay/serde-yaml |
| serdect | 0.4.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| serial2 | 0.2.34 | BSD-2-Clause OR Apache-2.0 | https://github.com/de-vri-es/serial2-rs |
| serial2-tokio | 0.1.21 | BSD-2-Clause OR Apache-2.0 | https://github.com/de-vri-es/serial2-tokio-rs |
| sha-1 | 0.10.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
| sha1 | 0.10.6 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
| sha1_smol | 1.0.1 | BSD-3-Clause | https://github.com/mitsuhiko/sha1-smol |
| sha2 | 0.10.9 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
| sha2 | 0.11.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes |
| sharded-slab | 0.1.7 | MIT | https://github.com/hawkw/sharded-slab |
| shlex | 1.3.0 | MIT OR Apache-2.0 | https://github.com/comex/rust-shlex |
| signal-hook-registry | 1.4.8 | MIT OR Apache-2.0 | https://github.com/vorner/signal-hook |
| signature | 2.2.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/traits/tree/master/signature |
| signature | 3.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/traits |
| simd-adler32 | 0.3.8 | MIT | https://github.com/mcountryman/simd-adler32 |
| simd_cesu8 | 1.1.1 | Apache-2.0 OR MIT | https://github.com/seancroach/simd_cesu8 |
| simdutf8 | 0.1.5 | MIT OR Apache-2.0 | https://github.com/rusticstuff/simdutf8 |
| simple-dns | 0.11.3 | MIT | https://github.com/balliegojr/simple-dns |
| siphasher | 1.0.3 | MIT/Apache-2.0 | https://github.com/jedisct1/rust-siphash |
| slab | 0.4.11 | MIT | https://github.com/tokio-rs/slab |
| smallvec | 1.15.1 | MIT OR Apache-2.0 | https://github.com/servo/rust-smallvec |
| socket-pktinfo | 0.3.2 | MIT | https://github.com/pixsper/socket-pktinfo |
| socket2 | 0.5.10 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 |
| socket2 | 0.6.2 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 |
| sorted-index-buffer | 0.2.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh |
| spez | 0.1.2 | BSD-2-Clause | https://github.com/m-ou-se/spez |
| spin | 0.10.0 | MIT | https://github.com/mvdnes/spin-rs.git |
| spin | 0.9.8 | MIT | https://github.com/mvdnes/spin-rs.git |
| spki | 0.7.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/spki |
| spki | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats |
| ssh2 | 0.9.5 | MIT OR Apache-2.0 | https://github.com/alexcrichton/ssh2-rs |
| stable_deref_trait | 1.2.1 | MIT OR Apache-2.0 | https://github.com/storyyeller/stable_deref_trait |
| strsim | 0.11.1 | MIT | https://github.com/rapidfuzz/strsim-rs |
| strum | 0.28.0 | MIT | https://github.com/Peternator7/strum |
| strum_macros | 0.28.0 | MIT | https://github.com/Peternator7/strum |
| subtle | 2.6.1 | BSD-3-Clause | https://github.com/dalek-cryptography/subtle |
| syn | 1.0.109 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn |
| syn | 2.0.114 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn |
| syn-mid | 0.5.4 | Apache-2.0 OR MIT | https://github.com/taiki-e/syn-mid |
| sync_wrapper | 0.1.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper |
| sync_wrapper | 1.0.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper |
| synstructure | 0.13.2 | MIT | https://github.com/mystor/synstructure |
| system-configuration | 0.5.1 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
| system-configuration | 0.6.1 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
| system-configuration | 0.7.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
| system-configuration-sys | 0.5.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
| system-configuration-sys | 0.6.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs |
| tagptr | 0.2.0 | MIT/Apache-2.0 | https://github.com/oliver-giersch/tagptr.git |
| tar | 0.4.44 | MIT OR Apache-2.0 | https://github.com/alexcrichton/tar-rs |
| tempfile | 3.24.0 | MIT OR Apache-2.0 | https://github.com/Stebalien/tempfile |
| termcolor | 1.4.1 | Unlicense OR MIT | https://github.com/BurntSushi/termcolor |
| thiserror | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
| thiserror | 2.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
| thiserror-impl | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
| thiserror-impl | 2.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror |
| thread_local | 1.1.9 | MIT OR Apache-2.0 | https://github.com/Amanieu/thread_local-rs |
| time | 0.3.49 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
| time-core | 0.1.9 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
| time-macros | 0.2.29 | MIT OR Apache-2.0 | https://github.com/time-rs/time |
| tinystr | 0.8.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| tinyvec | 1.10.0 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/tinyvec |
| tinyvec_macros | 0.1.1 | MIT OR Apache-2.0 OR Zlib | https://github.com/Soveu/tinyvec_macros |
| tokio | 1.49.0 | MIT | https://github.com/tokio-rs/tokio |
| tokio-macros | 2.6.0 | MIT | https://github.com/tokio-rs/tokio |
| tokio-rustls | 0.24.1 | MIT/Apache-2.0 | https://github.com/rustls/tokio-rustls |
| tokio-rustls | 0.26.4 | MIT OR Apache-2.0 | https://github.com/rustls/tokio-rustls |
| tokio-socks | 0.5.2 | MIT | https://github.com/sticnarf/tokio-socks |
| tokio-stream | 0.1.18 | MIT | https://github.com/tokio-rs/tokio |
| tokio-test | 0.4.5 | MIT | https://github.com/tokio-rs/tokio |
| tokio-tungstenite | 0.20.1 | MIT | https://github.com/snapview/tokio-tungstenite |
| tokio-tungstenite | 0.26.2 | MIT | https://github.com/snapview/tokio-tungstenite |
| tokio-util | 0.7.18 | MIT | https://github.com/tokio-rs/tokio |
| tokio-websockets | 0.13.2 | MIT | https://github.com/Gelbpunkt/tokio-websockets/ |
| toml | 0.8.23 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| toml_datetime | 0.6.11 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| toml_datetime | 1.1.1+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| toml_edit | 0.22.27 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| toml_edit | 0.25.12+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| toml_parser | 1.1.2+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| toml_write | 0.1.2 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml |
| totp-rs | 5.7.0 | MIT | https://github.com/constantoine/totp-rs |
| tower | 0.5.3 | MIT | https://github.com/tower-rs/tower |
| tower-http | 0.6.8 | MIT | https://github.com/tower-rs/tower-http |
| tower-layer | 0.3.3 | MIT | https://github.com/tower-rs/tower |
| tower-service | 0.3.3 | MIT | https://github.com/tower-rs/tower |
| tracing | 0.1.44 | MIT | https://github.com/tokio-rs/tracing |
| tracing-attributes | 0.1.31 | MIT | https://github.com/tokio-rs/tracing |
| tracing-core | 0.1.36 | MIT | https://github.com/tokio-rs/tracing |
| tracing-log | 0.2.0 | MIT | https://github.com/tokio-rs/tracing |
| tracing-subscriber | 0.3.22 | MIT | https://github.com/tokio-rs/tracing |
| try-lock | 0.2.5 | MIT | https://github.com/seanmonstar/try-lock |
| tungstenite | 0.20.1 | MIT OR Apache-2.0 | https://github.com/snapview/tungstenite-rs |
| tungstenite | 0.26.2 | MIT OR Apache-2.0 | https://github.com/snapview/tungstenite-rs |
| typenum | 1.20.1 | MIT OR Apache-2.0 | https://github.com/paholg/typenum |
| unicode-ident | 1.0.22 | (MIT OR Apache-2.0) AND Unicode-3.0 | https://github.com/dtolnay/unicode-ident |
| unicode-normalization | 0.1.22 | MIT/Apache-2.0 | https://github.com/unicode-rs/unicode-normalization |
| unicode-segmentation | 1.13.3 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-segmentation |
| unicode-xid | 0.2.6 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-xid |
| universal-hash | 0.5.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits |
| unsafe-libyaml | 0.2.11 | MIT | https://github.com/dtolnay/unsafe-libyaml |
| untrusted | 0.9.0 | ISC | https://github.com/briansmith/untrusted |
| url | 2.5.8 | MIT OR Apache-2.0 | https://github.com/servo/rust-url |
| urlencoding | 2.1.3 | MIT | https://github.com/kornelski/rust_urlencoding |
| utf-8 | 0.7.6 | MIT OR Apache-2.0 | https://github.com/SimonSapin/rust-utf8 |
| utf8_iter | 1.0.4 | Apache-2.0 OR MIT | https://github.com/hsivonen/utf8_iter |
| uuid | 1.19.0 | Apache-2.0 OR MIT | https://github.com/uuid-rs/uuid |
| valuable | 0.1.1 | MIT | https://github.com/tokio-rs/valuable |
| vcpkg | 0.2.15 | MIT/Apache-2.0 | https://github.com/mcgoo/vcpkg-rs |
| vergen | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
| vergen-gitcl | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
| vergen-lib | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen |
| version_check | 0.9.5 | MIT/Apache-2.0 | https://github.com/SergioBenitez/version_check |
| walkdir | 2.5.0 | Unlicense/MIT | https://github.com/BurntSushi/walkdir |
| want | 0.3.1 | MIT | https://github.com/seanmonstar/want |
| wasi | 0.11.1+wasi-snapshot-preview1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi |
| wasip2 | 1.0.2+wasi-0.2.9 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi-rs |
| wasip3 | 0.4.0+wasi-0.3.0-rc-2026-01-06 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi-rs |
| wasm-bindgen | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen |
| wasm-bindgen-futures | 0.4.58 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/futures |
| wasm-bindgen-macro | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro |
| wasm-bindgen-macro-support | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro-support |
| wasm-bindgen-shared | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/shared |
| wasm-encoder | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-encoder |
| wasm-metadata | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-metadata |
| wasm-streams | 0.4.2 | MIT OR Apache-2.0 | https://github.com/MattiasBuelens/wasm-streams/ |
| wasm-streams | 0.5.0 | MIT OR Apache-2.0 | https://github.com/MattiasBuelens/wasm-streams/ |
| wasmparser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasmparser |
| web-sys | 0.3.85 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/web-sys |
| web-time | 1.1.0 | MIT OR Apache-2.0 | https://github.com/daxpedda/web-time |
| webpki-root-certs | 1.0.7 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
| webpki-roots | 0.25.4 | MPL-2.0 | https://github.com/rustls/webpki-roots |
| webpki-roots | 0.26.11 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
| webpki-roots | 1.0.6 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots |
| widestring | 1.2.1 | MIT OR Apache-2.0 | https://github.com/VoidStarKat/widestring-rs |
| winapi | 0.3.9 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
| winapi-i686-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
| winapi-util | 0.1.11 | Unlicense OR MIT | https://github.com/BurntSushi/winapi-util |
| winapi-x86_64-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs |
| windows | 0.62.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-collections | 0.3.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-core | 0.62.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-future | 0.3.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-implement | 0.60.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-interface | 0.59.3 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-link | 0.2.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-numerics | 0.3.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-registry | 0.6.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-result | 0.4.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-strings | 0.5.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-sys | 0.45.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-sys | 0.48.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-sys | 0.52.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-sys | 0.60.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-sys | 0.61.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-targets | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-targets | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-targets | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-targets | 0.53.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows-threading | 0.2.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_gnullvm | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_aarch64_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_gnu | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_gnu | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_gnu | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_gnu | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_i686_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnu | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnu | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnu | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnu | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnullvm | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| windows_x86_64_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs |
| winnow | 0.7.14 | MIT | https://github.com/winnow-rs/winnow |
| winnow | 1.0.3 | MIT | https://github.com/winnow-rs/winnow |
| winreg | 0.50.0 | MIT | https://github.com/gentoo90/winreg-rs |
| wit-bindgen | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
| wit-bindgen-core | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
| wit-bindgen-rust | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
| wit-bindgen-rust-macro | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen |
| wit-component | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-component |
| wit-parser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-parser |
| wmi | 0.18.4 | MIT OR Apache-2.0 | https://github.com/ohadravid/wmi-rs |
| writeable | 0.6.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| ws_stream_wasm | 0.7.5 | Unlicense | https://github.com/najamelan/ws_stream_wasm |
| x509-parser | 0.18.1 | MIT OR Apache-2.0 | https://github.com/rusticata/x509-parser.git |
| xattr | 1.6.1 | MIT OR Apache-2.0 | https://github.com/Stebalien/xattr |
| xml-rs | 0.8.28 | MIT | https://github.com/kornelski/xml-rs |
| xmltree | 0.10.3 | MIT | https://github.com/eminence/xmltree-rs |
| yasna | 0.6.0 | MIT OR Apache-2.0 | https://github.com/qnighy/yasna.rs |
| yoke | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| yoke-derive | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| zbase32 | 0.1.2 | LGPL-3.0+ | https://gitlab.com/pgerber/zbase32-rust |
| zerocopy | 0.8.33 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy |
| zerocopy-derive | 0.8.33 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy |
| zerofrom | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| zerofrom-derive | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| zeroize | 1.9.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
| zeroize_derive | 1.5.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils |
| zerotrie | 0.2.3 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| zerovec | 0.11.5 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| zerovec-derive | 0.11.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x |
| zmij | 1.0.16 | MIT | https://github.com/dtolnay/zmij |

View File

@ -465,7 +465,6 @@ impl RpcHandler {
signing_key.as_ref().map(|i| i.signing_key()),
Some(&peer.pubkey),
data.server_info.name.as_deref(),
Some(&self.config.data_dir),
)
.await
}

View File

@ -279,7 +279,6 @@ impl RpcHandler {
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(120))
.fips_timeout(std::time::Duration::from_secs(8))
.send_get()
.await
.context("Failed to connect to peer")?;
@ -365,11 +364,6 @@ impl RpcHandler {
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
// The Cloud page's hottest call: without a fast-fail cap a
// cold FIPS path burned ~16.6s before Tor even started,
// against the UI's 30s deadline — users saw errors, not
// fallback.
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await
.context("Failed to connect to peer")?;
@ -1143,7 +1137,6 @@ impl RpcHandler {
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await
.context("Failed to connect to peer for preview")?;

View File

@ -865,9 +865,7 @@ impl RpcHandler {
"/rpc/v1",
)
.service(crate::settings::transport::PeerService::Peers)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(&self.config.data_dir);
.timeout(std::time::Duration::from_secs(30));
match req.send_json(&body).await {
Ok((resp, transport)) if resp.status().is_success() => {

View File

@ -13,14 +13,7 @@ use anyhow::Result;
impl RpcHandler {
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
let status = fips::FipsStatus::query(&self.config.data_dir).await;
let mut v = serde_json::to_value(status)?;
// Dial outcome counters (process-lifetime): how often peer dials
// used FIPS vs fell back to Tor, broken down by reason. This is
// the observability that makes "FIPS uptime" measurable.
if let Some(obj) = v.as_object_mut() {
obj.insert("dial_stats".to_string(), fips::telemetry::snapshot());
}
Ok(v)
Ok(serde_json::to_value(status)?)
}
/// Everything the companion app needs to join this node's mesh, embedded
@ -90,8 +83,7 @@ impl RpcHandler {
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
fips::config::install(&identity_dir).await?;
let unit = fips::service::activation_unit().await;
fips::service::activate(unit).await?;
fips::service::activate(fips::SERVICE_UNIT).await?;
let status = fips::FipsStatus::query(&self.config.data_dir).await;
Ok(serde_json::to_value(status)?)
}

View File

@ -820,8 +820,6 @@ impl RpcHandler {
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
.service(crate::settings::transport::PeerService::MeshFileSharing)
.timeout(std::time::Duration::from_secs(120))
.fips_timeout(std::time::Duration::from_secs(8))
.record_transport(&self.config.data_dir)
.send_get()
.await
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;

View File

@ -137,7 +137,6 @@ impl RpcHandler {
None,
None,
None,
Some(&self.config.data_dir),
)
.await?;
@ -226,7 +225,6 @@ impl RpcHandler {
signing_key.as_ref().map(|i| i.signing_key()),
Some(&req.from_pubkey),
data.server_info.name.as_deref(),
Some(&self.config.data_dir),
)
.await
{

View File

@ -133,7 +133,6 @@ impl RpcHandler {
Some(node_id.signing_key()),
recipient_pubkey.as_deref(),
node_name.as_deref(),
Some(&self.config.data_dir),
)
.await?;
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))

View File

@ -56,12 +56,12 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
Ok(true)
}
/// Best-effort: install fips.yaml + start the available FIPS systemd unit after
/// 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 a few
/// seconds the first time on slow hardware. Any failure is logged but does not
/// break onboarding; the user can still hit fips.install manually from the
/// dashboard as an escape hatch.
/// 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
/// a few seconds the first time on slow hardware. Any failure is logged but
/// does not break onboarding; the user can still hit fips.install manually
/// from the dashboard as an escape hatch.
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
tokio::spawn(async move {
let identity_dir = data_dir.join("identity");
@ -78,12 +78,11 @@ fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
tracing::warn!("post-onboarding fips config install failed: {}", e);
return;
}
let unit = crate::fips::service::activation_unit().await;
if let Err(e) = crate::fips::service::activate(unit).await {
tracing::warn!("post-onboarding FIPS activate failed via {}: {}", unit, e);
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
tracing::warn!("post-onboarding archipelago-fips activate failed: {}", e);
return;
}
tracing::info!("FIPS auto-activated post-onboarding via {}", unit);
tracing::info!("archipelago-fips auto-activated post-onboarding");
});
}
@ -139,8 +138,8 @@ impl RpcHandler {
// Initialize identity index at 0.
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
// fips_key is now on disk — auto-activate FIPS so the user doesn't
// have to hit a manual Start button. Detached task;
// fips_key is now on disk — auto-activate archipelago-fips so the
// user doesn't have to hit an "Activate" button. Detached task;
// the onboarding RPC returns immediately.
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());

View File

@ -498,9 +498,7 @@ pub(super) async fn notify_federation_peers_address_change(
"/rpc/v1",
)
.service(crate::settings::transport::PeerService::Peers)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir);
.timeout(std::time::Duration::from_secs(30));
match req.send_json(&payload).await {
Ok((_, transport)) => {
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")

View File

@ -290,12 +290,12 @@ pub struct ApplyResult {
/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated
/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port.
const FIPS_UDP_PORT: u16 = crate::fips::PUBLISHED_UDP_PORT;
const FIPS_UDP_PORT: u16 = 8668;
/// Build transient seed-anchor entries that dial LAN-discovered federation peers
/// directly over their FIPS UDP transport. For each peer the registry knows both
/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at
/// `<lan-ip>:<FIPS_UDP_PORT>`. This lets co-located federation nodes form a DIRECT FIPS link
/// `<lan-ip>:8668`. This lets co-located federation nodes form a DIRECT FIPS link
/// instead of depending on the global anchor's spanning tree to route between
/// them (the cause of every dial falling back to Tor when the anchor link flaps).
///
@ -448,39 +448,4 @@ mod tests {
assert_eq!(a.transport, "udp");
assert_eq!(a.label, "");
}
#[test]
fn lan_fips_anchor_port_matches_daemon_bind() {
// Drift guard: direct LAN anchors must dial the UDP port the
// generated fips.yaml actually binds. These were out of sync for
// months (anchors dialed 8668, the daemon bound 2121), making the
// whole direct-peering feature dial a dead port.
let yaml = crate::fips::config::render_config_yaml();
assert!(
yaml.contains(&format!("0.0.0.0:{FIPS_UDP_PORT}")),
"lan_fips_anchors dials :{FIPS_UDP_PORT} but the daemon config binds elsewhere"
);
}
#[test]
fn lan_fips_anchors_builds_direct_entry() {
let peer = crate::transport::PeerRecord {
did: "did:key:zpeer".to_string(),
lan_address: Some("192.168.63.198:5678".to_string()),
fips_npub: Some("npub1peer".to_string()),
..Default::default()
};
let out = lan_fips_anchors(&[peer]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].address, format!("192.168.63.198:{FIPS_UDP_PORT}"));
assert_eq!(out[0].transport, "udp");
// Peers missing either the LAN address or the npub produce nothing.
let no_npub = crate::transport::PeerRecord {
did: "did:key:zother".to_string(),
lan_address: Some("192.168.63.199:5678".to_string()),
..Default::default()
};
assert!(lan_fips_anchors(&[no_npub]).is_empty());
}
}

View File

@ -240,21 +240,11 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
// framework-pt). Ship the allowance as a fips.d drop-in on every
// install/upgrade so no node ever regresses to a UI-less mesh.
sudo_install_dir("/etc/fips/fips.d").await?;
// PEER_PORT (5679) carries ALL federation sync, cloud browse/download,
// mesh envelopes, DWN and invoices. It was missing from this allowlist
// while the comment claimed "web UI + peer API" — so every hardened
// node silently dropped peers' FIPS dials at the firewall and the whole
// fleet fell back to Tor (root-caused live 2026-07-27: 28k drops on
// .198's counter; :5679 answered in 0.35s once the rule was inserted).
let dropin = format!(
"# Written by archipelago on every daemon config install.\n\
# Allows the web UI + peer API through the fips0\n\
# default-deny inbound baseline (fips.nft).\n\
tcp dport 80 accept\n\
tcp dport 8443 accept\n\
tcp dport {peer_port} accept\n",
peer_port = crate::fips::dial::PEER_PORT
);
let dropin = "# Written by archipelago on every daemon config install.\n\
# Allows the web UI + peer API through the fips0\n\
# default-deny inbound baseline (fips.nft).\n\
tcp dport 80 accept\n\
tcp dport 8443 accept\n";
let nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
tokio::fs::write(&nft_stage, dropin)
.await

View File

@ -24,7 +24,6 @@
//! ```
#![allow(dead_code)]
use super::telemetry::{self, FallbackReason};
use anyhow::{Context, Result};
use std::net::{IpAddr, Ipv6Addr};
use std::time::Duration;
@ -318,11 +317,6 @@ pub struct PeerRequest<'a> {
/// large content download needs so its long FIPS transfer isn't truncated.
pub fips_timeout: Option<std::time::Duration>,
pub service: Option<crate::settings::transport::PeerService>,
/// When set, the transport that actually served this request is written
/// to federation storage (`record_peer_transport`, matched by onion) so
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
/// every caller has a data dir in scope.
pub record_data_dir: Option<std::path::PathBuf>,
}
impl<'a> PeerRequest<'a> {
@ -335,31 +329,6 @@ impl<'a> PeerRequest<'a> {
timeout: std::time::Duration::from_secs(30),
fips_timeout: None,
service: None,
record_data_dir: None,
}
}
/// Record the transport that serves this request into federation storage
/// (matched by this request's onion host). Best-effort, off the hot path.
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
self.record_data_dir = Some(data_dir.into());
self
}
fn spawn_record(&self, kind: crate::transport::TransportKind) {
if let Some(dir) = &self.record_data_dir {
let dir = dir.clone();
let onion = self.onion_host.to_string();
let transport = kind.to_string();
tokio::spawn(async move {
let _ = crate::federation::record_peer_transport(
&dir,
None,
Some(&onion),
&transport,
)
.await;
});
}
}
@ -420,22 +389,8 @@ impl<'a> PeerRequest<'a> {
// fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back.
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips));
}
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
FallbackReason::Http404
} else {
FallbackReason::Http5xx
};
telemetry::record_fallback(reason);
tracing::info!(
reason = reason.key(),
status = %resp.status(),
"FIPS POST {} answered but status triggers Tor fallback",
self.path
);
}
None => {
if pref == TransportPref::Fips {
@ -447,7 +402,6 @@ impl<'a> PeerRequest<'a> {
}
}
let resp = self.send_tor_post_json(body).await?;
self.spawn_record(crate::transport::TransportKind::Tor);
Ok((resp, crate::transport::TransportKind::Tor))
}
@ -459,22 +413,8 @@ impl<'a> PeerRequest<'a> {
match self.try_fips_get().await? {
Some(resp) => {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips));
}
let reason = if resp.status() == reqwest::StatusCode::NOT_FOUND {
FallbackReason::Http404
} else {
FallbackReason::Http5xx
};
telemetry::record_fallback(reason);
tracing::info!(
reason = reason.key(),
status = %resp.status(),
"FIPS GET {} answered but status triggers Tor fallback",
self.path
);
}
None => {
if pref == TransportPref::Fips {
@ -486,7 +426,6 @@ impl<'a> PeerRequest<'a> {
}
}
let resp = self.send_tor_get().await?;
self.spawn_record(crate::transport::TransportKind::Tor);
Ok((resp, crate::transport::TransportKind::Tor))
}
@ -495,127 +434,67 @@ impl<'a> PeerRequest<'a> {
body: &B,
) -> Result<Option<reqwest::Response>> {
let Some(npub) = self.fips_npub else {
telemetry::record_fallback(FallbackReason::NoNpub);
return Ok(None);
};
if !is_service_active().await {
telemetry::record_fallback(FallbackReason::ServiceInactive);
return Ok(None);
}
let base = match peer_base_url(npub).await {
Ok(b) => b,
Err(e) => {
telemetry::record_fallback(FallbackReason::DnsFail);
tracing::info!(
reason = FallbackReason::DnsFail.key(),
"FIPS resolve for {} failed: {}, falling back to Tor",
npub,
e
);
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
return Ok(None);
}
};
let url = format!("{}{}", base, self.path);
let budget = self.fips_attempt_timeout();
// With an explicit fast-fail cap, halve the per-attempt client
// timeout so the one-retry path in send_with_retry fits inside the
// budget instead of silently doubling it ("fips_timeout(6s)" used
// to really mean ~12.6s). Without one (long streaming downloads),
// keep the full budget per attempt — the client timeout also
// governs body streaming and must not truncate a real transfer.
let per_attempt = if self.fips_timeout.is_some() {
budget / 2
} else {
budget
};
let c = client_with_timeout(per_attempt);
let c = client_with_timeout(self.fips_attempt_timeout());
let mut rb = c.post(&url).json(body);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
match tokio::time::timeout(budget, send_with_retry(rb)).await {
Ok(Ok(r)) => Ok(Some(r)),
Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
match send_with_retry(rb).await {
Ok(r) => Ok(Some(r)),
Err(e) => {
tracing::debug!(
"FIPS POST {} failed after retry: {}, falling back to Tor",
url,
e
);
Ok(None)
}
Err(_) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS POST {} exceeded attempt budget {:?}, falling back to Tor",
url,
budget
);
Ok(None)
}
}
}
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
let Some(npub) = self.fips_npub else {
telemetry::record_fallback(FallbackReason::NoNpub);
return Ok(None);
};
if !is_service_active().await {
telemetry::record_fallback(FallbackReason::ServiceInactive);
return Ok(None);
}
let base = match peer_base_url(npub).await {
Ok(b) => b,
Err(e) => {
telemetry::record_fallback(FallbackReason::DnsFail);
tracing::info!(
reason = FallbackReason::DnsFail.key(),
"FIPS resolve for {} failed: {}, falling back to Tor",
npub,
e
);
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
return Ok(None);
}
};
let url = format!("{}{}", base, self.path);
let budget = self.fips_attempt_timeout();
// Same budget discipline as the POST path: halve per attempt only
// under an explicit fast-fail cap; hard-cap the retry sequence.
let per_attempt = if self.fips_timeout.is_some() {
budget / 2
} else {
budget
};
let c = client_with_timeout(per_attempt);
let c = client_with_timeout(self.fips_attempt_timeout());
let mut rb = c.get(&url);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
match tokio::time::timeout(budget, send_with_retry(rb)).await {
Ok(Ok(r)) => Ok(Some(r)),
Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
match send_with_retry(rb).await {
Ok(r) => Ok(Some(r)),
Err(e) => {
tracing::debug!(
"FIPS GET {} failed after retry: {}, falling back to Tor",
url,
e
);
Ok(None)
}
Err(_) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
reason = FallbackReason::ConnectFail.key(),
"FIPS GET {} exceeded attempt budget {:?}, falling back to Tor",
url,
budget
);
Ok(None)
}
}
}

View File

@ -31,7 +31,6 @@ pub mod config;
pub mod dial;
pub mod iface;
pub mod service;
pub mod telemetry;
pub mod update;
use serde::{Deserialize, Serialize};
@ -55,8 +54,7 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
return;
}
let unit = service::activation_unit().await;
if let Err(e) = service::activate(unit).await {
if let Err(e) = service::activate(SERVICE_UNIT).await {
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
return;
}

View File

@ -32,16 +32,6 @@ pub async fn unit_state(unit: &str) -> String {
}
}
/// Whether systemd knows about `unit`.
pub async fn unit_exists(unit: &str) -> bool {
Command::new("systemctl")
.args(["cat", unit])
.output()
.await
.map(|out| out.status.success())
.unwrap_or(false)
}
/// Whether the `fips` debian package is installed on the host.
pub async fn package_installed() -> bool {
// dpkg-query -W -f='${Status}' fips → "install ok installed" when present.
@ -141,28 +131,17 @@ done
Ok(())
}
/// Resolve which systemd unit should be started when FIPS is inactive.
/// Newer Archipelago images may ship `archipelago-fips.service`; nodes with
/// the upstream Debian package may only have `fips.service`. Activation must
/// choose a unit systemd can actually load, otherwise the dashboard repeatedly
/// offers an "Activate" action that can never succeed.
pub async fn activation_unit() -> &'static str {
if unit_exists(super::SERVICE_UNIT).await {
return super::SERVICE_UNIT;
}
if unit_exists(super::UPSTREAM_SERVICE_UNIT).await {
return super::UPSTREAM_SERVICE_UNIT;
}
super::SERVICE_UNIT
}
/// Resolve which systemd unit is actually supervising the fips daemon on this
/// host. Restart/Reconnect must operate on whichever one is running, otherwise
/// the UI button is a silent no-op.
/// Resolve which systemd unit is actually supervising the fips daemon
/// on this host. Nodes installed from the archipelago ISO run
/// `archipelago-fips.service`; nodes that were apt-installed (or had
/// fips running before archipelago took over) may only have the
/// upstream `fips.service`. Restart/Reconnect must operate on whichever
/// one is running, otherwise the UI button is a silent no-op.
///
/// Returns the archipelago-managed unit name if it's active,
/// else the upstream unit name if that's active,
/// else a startable activation unit.
/// else the archipelago-managed name as a default (so activate() can
/// bring it up).
pub async fn active_unit() -> &'static str {
if unit_state(super::SERVICE_UNIT).await == "active" {
return super::SERVICE_UNIT;
@ -170,7 +149,7 @@ pub async fn active_unit() -> &'static str {
if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" {
return super::UPSTREAM_SERVICE_UNIT;
}
activation_unit().await
super::SERVICE_UNIT
}
pub async fn mask(unit: &str) -> Result<()> {
@ -269,10 +248,4 @@ mod tests {
// Must not panic regardless of host state.
let _ = package_installed().await;
}
#[tokio::test]
async fn test_unit_exists_is_bool() {
// Must not panic regardless of host state.
let _ = unit_exists("archipelago-bogus-test.service").await;
}
}

View File

@ -1,144 +0,0 @@
//! In-process counters for FIPS dial outcomes.
//!
//! Every peer dial that could have used FIPS either succeeds over FIPS or
//! falls back to Tor for one of six reasons (F1F6). Before these counters
//! existed, fallbacks were `debug!`-only and invisible in production, which
//! made "FIPS uptime" unfalsifiable — several paths were 100% Tor for months
//! (dead ports, firewalled listeners, allowlist 404s) and nothing surfaced
//! it. The counters are process-lifetime (reset on restart) and exposed via
//! `fips.status` as `dial_stats`, so a fleet-wide fallback regression shows
//! up on the dashboard instead of as vague slowness.
use std::sync::atomic::{AtomicU64, Ordering};
/// Why a FIPS-capable dial fell back to Tor.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FallbackReason {
/// F1 — no FIPS npub known for the peer (never meshed, or pre-npub
/// federation record). Expected for non-FIPS peers; high counts here
/// mean npub propagation is broken, not the transport.
NoNpub,
/// F2 — the local FIPS daemon service isn't active.
ServiceInactive,
/// F3 — the local FIPS DNS resolver couldn't resolve the peer's npub
/// (daemon up but peer not in the identity cache / mesh unreachable).
DnsFail,
/// F4 — TCP/HTTP dial to the peer's ULA failed or exceeded the FIPS
/// attempt budget (firewalled :5679, cold hole-punch, peer down).
ConnectFail,
/// F5 — peer answered over FIPS with 404: its listener doesn't serve
/// this path (older build / stricter allowlist).
Http404,
/// F6 — peer answered over FIPS with a 5xx server error.
Http5xx,
}
impl FallbackReason {
pub fn key(self) -> &'static str {
match self {
Self::NoNpub => "no_npub",
Self::ServiceInactive => "service_inactive",
Self::DnsFail => "dns_fail",
Self::ConnectFail => "connect_fail",
Self::Http404 => "http_404",
Self::Http5xx => "http_5xx",
}
}
}
static FIPS_OK: AtomicU64 = AtomicU64::new(0);
static NO_NPUB: AtomicU64 = AtomicU64::new(0);
static SERVICE_INACTIVE: AtomicU64 = AtomicU64::new(0);
static DNS_FAIL: AtomicU64 = AtomicU64::new(0);
static CONNECT_FAIL: AtomicU64 = AtomicU64::new(0);
static HTTP_404: AtomicU64 = AtomicU64::new(0);
static HTTP_5XX: AtomicU64 = AtomicU64::new(0);
fn counter(reason: FallbackReason) -> &'static AtomicU64 {
match reason {
FallbackReason::NoNpub => &NO_NPUB,
FallbackReason::ServiceInactive => &SERVICE_INACTIVE,
FallbackReason::DnsFail => &DNS_FAIL,
FallbackReason::ConnectFail => &CONNECT_FAIL,
FallbackReason::Http404 => &HTTP_404,
FallbackReason::Http5xx => &HTTP_5XX,
}
}
/// A dial completed over FIPS (any HTTP status that wasn't a fallback
/// trigger — the peer was reached on the mesh).
pub fn record_fips_ok() {
FIPS_OK.fetch_add(1, Ordering::Relaxed);
}
/// A FIPS-capable dial fell back to Tor.
pub fn record_fallback(reason: FallbackReason) {
counter(reason).fetch_add(1, Ordering::Relaxed);
}
/// Snapshot for `fips.status` (`dial_stats`). Process-lifetime counts.
pub fn snapshot() -> serde_json::Value {
let f1 = NO_NPUB.load(Ordering::Relaxed);
let f2 = SERVICE_INACTIVE.load(Ordering::Relaxed);
let f3 = DNS_FAIL.load(Ordering::Relaxed);
let f4 = CONNECT_FAIL.load(Ordering::Relaxed);
let f5 = HTTP_404.load(Ordering::Relaxed);
let f6 = HTTP_5XX.load(Ordering::Relaxed);
serde_json::json!({
"fips_ok": FIPS_OK.load(Ordering::Relaxed),
"fallbacks": {
"no_npub": f1,
"service_inactive": f2,
"dns_fail": f3,
"connect_fail": f4,
"http_404": f5,
"http_5xx": f6,
"total": f1 + f2 + f3 + f4 + f5 + f6,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_counts_recorded_events() {
// Counters are global; assert deltas rather than absolutes so this
// test stays correct alongside any other test that dials.
let before = snapshot();
record_fips_ok();
record_fallback(FallbackReason::ConnectFail);
record_fallback(FallbackReason::Http404);
let after = snapshot();
let d = |v: &serde_json::Value, path: &[&str]| -> u64 {
let mut cur = v;
for p in path {
cur = &cur[p];
}
cur.as_u64().unwrap()
};
assert_eq!(d(&after, &["fips_ok"]) - d(&before, &["fips_ok"]), 1);
assert_eq!(
d(&after, &["fallbacks", "connect_fail"]) - d(&before, &["fallbacks", "connect_fail"]),
1
);
assert_eq!(
d(&after, &["fallbacks", "http_404"]) - d(&before, &["fallbacks", "http_404"]),
1
);
assert!(d(&after, &["fallbacks", "total"]) >= 2);
}
#[test]
fn reason_keys_are_stable() {
// These strings are the fips.status API surface — renaming one is a
// breaking change for the UI.
assert_eq!(FallbackReason::NoNpub.key(), "no_npub");
assert_eq!(FallbackReason::ServiceInactive.key(), "service_inactive");
assert_eq!(FallbackReason::DnsFail.key(), "dns_fail");
assert_eq!(FallbackReason::ConnectFail.key(), "connect_fail");
assert_eq!(FallbackReason::Http404.key(), "http_404");
assert_eq!(FallbackReason::Http5xx.key(), "http_5xx");
}
}

View File

@ -134,7 +134,6 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
for onion in &unique_onions {
let fips_npub = crate::federation::fips_npub_for_onion(data_dir, onion).await;
match sync_single_peer(
data_dir,
fips_npub.as_deref(),
&store,
onion,
@ -174,7 +173,6 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
/// Sync with a single peer: pull their messages and push ours.
/// Each HTTP call picks FIPS when a npub is known, otherwise Tor.
async fn sync_single_peer(
data_dir: &Path,
fips_npub: Option<&str>,
store: &crate::network::dwn_store::DwnStore,
onion: &str,
@ -188,8 +186,6 @@ async fn sync_single_peer(
let (health_resp, _) = PeerRequest::new(fips_npub, onion, "/dwn/health")
.service(crate::settings::transport::PeerService::Federation)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir)
.send_get()
.await
.context("Peer DWN unreachable")?;
@ -215,8 +211,6 @@ async fn sync_single_peer(
let (pull_res, _) = PeerRequest::new(fips_npub, onion, "/dwn")
.service(crate::settings::transport::PeerService::Federation)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir)
.send_json(&pull_body)
.await
.context("Failed to query peer DWN")?;
@ -275,8 +269,6 @@ async fn sync_single_peer(
match PeerRequest::new(fips_npub, onion, "/dwn")
.service(crate::settings::transport::PeerService::Federation)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.record_transport(data_dir)
.send_json(&push_body)
.await
{

View File

@ -342,9 +342,6 @@ pub async fn send_to_peer(
signing_key: Option<&ed25519_dalek::SigningKey>,
recipient_pubkey: Option<&str>,
from_name: Option<&str>,
// Federation data dir for last-transport recording; None skips recording
// (callers without a data dir in scope).
record_data_dir: Option<&std::path::Path>,
) -> Result<()> {
validate_onion(onion)?;
@ -373,15 +370,10 @@ pub async fn send_to_peer(
body["from_name"] = serde_json::Value::String(name.to_string());
}
let mut req =
let (resp, transport) =
crate::fips::dial::PeerRequest::new(fips_npub, onion, "/archipelago/node-message")
.service(crate::settings::transport::PeerService::Messaging)
.timeout(std::time::Duration::from_secs(60))
.fips_timeout(std::time::Duration::from_secs(8));
if let Some(dir) = record_data_dir {
req = req.record_transport(dir);
}
let (resp, transport) = req
.send_json(&body)
.await
.map_err(|e| {
@ -418,7 +410,6 @@ pub async fn check_peer_reachable(onion: &str, fips_npub: Option<&str>) -> Resul
// circuit that hasn't answered /health in 12s is "offline" for UI
// purposes; the old 30s made the Connected Nodes probes crawl.
.timeout(std::time::Duration::from_secs(12))
.fips_timeout(std::time::Duration::from_secs(4))
.send_get()
.await
{

View File

@ -433,18 +433,8 @@ impl Server {
),
));
// LAN transport (mDNS discovery). Advertise our FIPS npub in
// the TXT record so co-located peers can form a direct FIPS
// link (see `lan_fips_anchors`).
let local_fips_npub = crate::identity::fips_npub(&data_dir.join("identity"))
.await
.unwrap_or(None);
let mut lan = crate::transport::lan::LanTransport::new(
&did,
&pubkey_hex,
5678,
local_fips_npub,
);
// LAN transport (mDNS discovery)
let mut lan = crate::transport::lan::LanTransport::new(&did, &pubkey_hex, 5678);
match lan.start(registry.clone()) {
Ok(()) => info!("📡 LAN transport (mDNS) started"),
Err(e) => debug!("LAN transport init (non-fatal): {}", e),
@ -763,25 +753,12 @@ impl Server {
// (often flaky) global anchor's spanning tree to route to each
// other. For every peer the registry knows both a LAN address
// AND a FIPS npub for, dial it on its FIPS UDP transport port
// at its LAN IP. This is FIPS's own transport over the
// (8668) at its LAN IP. This is FIPS's own transport over the
// LAN — NOT Tailscale, NOT the HTTP/LAN messaging port. Pure
// FIPS. `fipsctl connect` is idempotent, so re-applying every
// tick just keeps the direct link warm; unknown/remote peers
// (no LAN address) are left to the anchor as before.
if let Some(reg) = fips_peer_registry.as_ref() {
// Hydrate FIPS npubs into the registry from federation
// storage (did-keyed). Peers discovered before the mDNS
// TXT `fips` key existed — or running builds that don't
// advertise it yet — would otherwise never satisfy the
// `fips_npub` requirement in lan_fips_anchors(), leaving
// direct LAN peering a no-op.
if let Ok(nodes) = crate::federation::load_nodes(&data_dir).await {
for n in &nodes {
if let Some(npub) = n.fips_npub.as_deref() {
reg.set_fips_npub(&n.did, npub).await;
}
}
}
let direct = crate::fips::anchors::lan_fips_anchors(&reg.all_peers().await);
if !direct.is_empty() {
let _ = crate::fips::anchors::apply(&direct).await;
@ -914,7 +891,7 @@ impl Server {
// Post-onboarding auto-activation for archipelago-fips. Runs once
// at startup: if fips_key is on disk, install /etc/fips/fips.yaml
// (schema-refreshed) and start the service. This removes the
// need for a user-facing manual Start button — the node comes up
// need for a user-facing "Activate" button — the node comes up
// with FIPS running whenever the seed has been onboarded. Also
// self-heals legacy raw-byte fips.key files (load_fips_keys
// rewrites them as bech32 nsec the first time they're read).
@ -963,16 +940,14 @@ impl Server {
);
}
}
let unit = crate::fips::service::activation_unit().await;
if let Err(e) = crate::fips::service::activate(unit).await {
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
tracing::warn!(
"FIPS activate failed on startup via {}: {} — user can retry via fips.install RPC",
unit,
"archipelago-fips activate failed on startup: {} — user can retry via fips.install RPC",
e
);
return;
}
tracing::info!("FIPS auto-activated on startup via {}", unit);
tracing::info!("archipelago-fips auto-activated on startup");
});
}
@ -1025,8 +1000,10 @@ impl Server {
_ => None,
};
let v6_task = if let Some(port) = v4_any_port {
let v6_addr =
SocketAddr::new(std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), port);
let v6_addr = SocketAddr::new(
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
port,
);
match bind_v6_only(v6_addr) {
Ok(listener) => {
info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr);
@ -1040,10 +1017,7 @@ impl Server {
)))
}
Err(e) => {
warn!(
"IPv6 web listener bind {} failed: {} — UI stays v4-only",
v6_addr, e
);
warn!("IPv6 web listener bind {} failed: {} — UI stays v4-only", v6_addr, e);
None
}
}
@ -1057,9 +1031,9 @@ impl Server {
// IPv4 only for most apps, so a phone dialing [ULA]:8123 got
// nothing even with the firewall open (HA/FileBrowser/Gitea/
// Portainer/Pine all v4-only on 2026-07-26; a few bind [::]
// themselves). Bridge each catalog launch port on the fips0 ULA
// only. Binding wildcard [::]:port reserves the same host ports
// Podman needs and can restart-loop apps that publish those ports.
// themselves). Bridge each catalog launch port to its v4 loopback
// listener with a V6ONLY relay — self-selecting: where the app
// already answers on v6 the bind fails and the app wins.
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
let peer_task = tokio::spawn(peer_late_bind_loop(
@ -1113,14 +1087,10 @@ fn bind_v6_only(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
tokio::net::TcpListener::from_std(socket.into())
}
fn fips_app_relay_addr(ip: std::net::Ipv6Addr, port: u16) -> SocketAddr {
SocketAddr::new(std::net::IpAddr::V6(ip), port)
}
/// IPv6→IPv4 relay for catalog app launch ports (see the spawn site for
/// why). Rescans every 60s so ports of freshly installed apps get bridged
/// without a daemon restart. Each relay binds to the fips0 ULA only and
/// forwards raw TCP to the same port on IPv4 loopback.
/// without a daemon restart. Each relay is a V6ONLY listener forwarding
/// raw TCP to the same port on IPv4 loopback.
async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
use std::collections::HashSet;
let mut bridged: HashSet<u16> = HashSet::new();
@ -1129,7 +1099,6 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
loop {
tokio::select! {
_ = interval.tick() => {
let Some(fips_ip) = crate::fips::iface::fips0_ula() else { continue };
for &port in crate::fips::app_ports::APP_LAUNCH_PORTS {
if bridged.contains(&port) {
continue;
@ -1154,12 +1123,15 @@ async fn app_port_v6_relay_loop(mut shutdown_rx: tokio::sync::watch::Receiver<bo
if !v4_up {
continue;
}
let addr = fips_app_relay_addr(fips_ip, port);
// EADDRINUSE = fipsd or another process already answers
// on this mesh address/port, so stay out of the way.
let addr = SocketAddr::new(
std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED),
port,
);
// EADDRINUSE = the app itself already answers on v6 —
// exactly when we must stay out of the way.
let Ok(listener) = bind_v6_only(addr) else { continue };
bridged.insert(port);
debug!("v6 relay bridging [{fips_ip}]:{port} -> 127.0.0.1:{port}");
debug!("v6 relay bridging [::]:{port} -> 127.0.0.1:{port}");
let mut rx = shutdown_rx.clone();
tokio::spawn(async move {
loop {
@ -1264,12 +1236,6 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
)
// Prefix-matched content endpoints (peer file browse + fetch)
|| path.starts_with("/content/")
// Mesh file sharing — blob fetch by CID, signature-gated in the
// handler. Absent from this list it 404'd over FIPS and the feature
// was 100% Tor by construction.
|| path.starts_with("/blob/")
// DWN sync — /dwn/health is step 1 of every sync; same story.
|| path.starts_with("/dwn/")
}
async fn accept_loop(
@ -1973,25 +1939,10 @@ mod merge_tests {
);
assert!(is_peer_allowed_path("/rpc/v1"));
assert!(is_peer_allowed_path("/health"));
// Mesh blob fetch + DWN sync — both were missing from the allowlist,
// which made them deterministically 404 over FIPS and therefore
// 100% Tor by construction.
assert!(is_peer_allowed_path("/blob/abc123"), "blob fetch by CID");
assert!(is_peer_allowed_path("/dwn/health"), "DWN sync step 1");
// Not on the allow-list → rejected (no broad surface over the mesh).
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/"));
assert!(!is_peer_allowed_path("/rpc/v2"));
assert!(!is_peer_allowed_path("/blobber"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/dwnx"), "must not prefix-leak");
}
#[test]
fn app_relay_binds_to_fips_ula_not_wildcard() {
let ula = "fd12:3456:789a::1".parse().unwrap();
let addr = fips_app_relay_addr(ula, 8083);
assert_eq!(addr.ip(), std::net::IpAddr::V6(ula));
assert_eq!(addr.port(), 8083);
}
#[test]

View File

@ -24,27 +24,17 @@ pub struct LanTransport {
our_did: String,
our_pubkey_hex: String,
our_port: u16,
/// This node's FIPS npub, advertised in the mDNS TXT record so
/// co-located peers can form a direct FIPS link (`lan_fips_anchors`)
/// without waiting for federation storage to sync.
our_fips_npub: Option<String>,
daemon: Option<ServiceDaemon>,
available: AtomicBool,
}
impl LanTransport {
/// Create a new LAN transport. Does not start discovery yet.
pub fn new(
our_did: &str,
our_pubkey_hex: &str,
port: u16,
our_fips_npub: Option<String>,
) -> Self {
pub fn new(our_did: &str, our_pubkey_hex: &str, port: u16) -> Self {
Self {
our_did: our_did.to_string(),
our_pubkey_hex: our_pubkey_hex.to_string(),
our_port: port,
our_fips_npub,
daemon: None,
available: AtomicBool::new(false),
}
@ -57,14 +47,11 @@ impl LanTransport {
// Advertise our service
let hostname = format!("archy-{}.local.", &self.our_pubkey_hex[..8]);
let mut properties = vec![
let properties = vec![
("did".to_string(), self.our_did.clone()),
("pubkey".to_string(), self.our_pubkey_hex.clone()),
("version".to_string(), "0.1.0".to_string()),
];
if let Some(npub) = &self.our_fips_npub {
properties.push(("fips".to_string(), npub.clone()));
}
let service_info = ServiceInfo::new(
SERVICE_TYPE,
@ -106,11 +93,6 @@ impl LanTransport {
.map(|v| v.val_str().to_string());
let addresses = info.get_addresses();
let fips_npub = info
.get_properties()
.get("fips")
.map(|v| v.val_str().to_string());
if let (Some(did), Some(pubkey)) = (did, pubkey) {
if let Some(scoped_ip) = addresses.iter().next() {
let ip: std::net::IpAddr = match scoped_ip.to_string().parse() {
@ -124,9 +106,6 @@ impl LanTransport {
.await;
registry_clone.set_lan_address(&did, socket_addr).await;
registry_clone.set_name(&did, info.get_fullname()).await;
if let Some(npub) = fips_npub.as_deref() {
registry_clone.set_fips_npub(&did, npub).await;
}
}
}
}

View File

@ -106,7 +106,7 @@ pub enum PeerSource {
}
/// Unified peer record with per-transport capabilities.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerRecord {
pub did: String,
pub pubkey_hex: String,

View File

@ -1,9 +0,0 @@
# Demo Content Provenance
All media in `demo/content/` and `demo/peer-media/` — music tracks, photos,
book covers, posters, and documents — are original works created and owned by
the Archipelago project author (Dorian), included here as demo content and
released with the project under the repository MIT license.
None of this content is sourced from third-party stock libraries or
commercial catalogs.

View File

@ -1,300 +0,0 @@
# FIPS near-100% uptime + optimistic UI state — implementation plan
**Date:** 2026-07-27. **Status:** researched + root-caused live on the fleet; ready to
implement for the next release. Two workstreams: (A) make node↔node FIPS transport
succeed whenever a FIPS path physically exists, (B) stop the UI reloading everything
on every navigation (optimistic/cached cards, stale-while-revalidate) while keeping
data fresh.
**Honesty note on "100%":** if a node's network blackholes every anchor (the .116
WiFi case, `docs/HANDOFF-2026-07-20-fips-peer-files.md:117-133`), Tor fallback is
*correct*. The achievable target is: **FIPS wins whenever a FIPS path exists, and
fallback frequency is measured in-product so regressions are visible.** Today several
paths are 0% FIPS *by construction* regardless of network health — that's the bug.
---
## Part A — why Cloud/FIPS "commonly falls back to Tor": ranked root causes
All verified live on 2026-07-27 (.116 local, .198, .228, Framework PT, x250s) plus a
full code audit of `core/archipelago/src/{fips,transport,federation,server.rs}`.
### RC0 — 🔥 The hardening firewall drops the peer-API port on every hardened node (PROVEN)
The fips0 default-deny baseline (`/etc/fips/fips.nft`) is opened by archipelago's
drop-in `80-web-ui.nft` (`fips/config.rs:236-255`) for **80 + 8443 + app ports only**.
The peer-API listener — which carries *all* federation sync, cloud browse/download,
mesh envelopes, DWN, invoices — is **`PEER_PORT = 5679`** (`fips/dial.rs:35`).
**5679 is not in the allowlist.** The drop-in's own comment claims "web UI + peer
API" but the peer API port was never added.
Live proof (2026-07-27):
- .116 nft chain: 5,965 dropped packets; .198: **28,670 dropped packets** — that's
peers' FIPS dials dying at the firewall.
- .198 → .116 `GET :5679/health`: **timeout (6s)** before; **HTTP 200 in 0.35s**
after `nft insert rule inet fips inbound iifname fips0 tcp dport 5679 accept`.
Same result in reverse direction (200 in 0.64s).
- Explains the exact fleet split in `federation/nodes.json`: hardened-baseline nodes
(Framework PT, .198, .228, x250-dev, x250-mad2) = `last_transport: tor`;
non-hardened nodes (Austin Sapien, X250-Beta, X250-PA) answer :5679 (404 from the
path allowlist = listener reachable) = `last_transport: fips`.
- Every dial to a hardened peer pays the 8s FIPS connect timeout
(`dial.rs:114`) ×2 (retry, `dial.rs:128-140`) → then Tor. That's the "Cloud takes
forever / shows Tor" experience.
**Fix (one line + reload):** add `tcp dport 5679 accept` to the drop-in in
`fips/config.rs` (use a constant shared with `dial.rs::PEER_PORT`, not a literal).
The drop-in reinstalls on every daemon config install, so it heals fleet-wide on OTA.
⚠️ Transient manual rules were inserted on .116 and .198 during diagnosis (2026-07-27)
— they vanish on the next `nft -f /etc/fips/fips.nft` reload or reboot; the code fix
makes them permanent.
### RC1 — .228 (Shorty's) runs fips 0.3.0-dev; the 0.4.1 fleet can't reach it
.228's daemon: `0.3.0-dev (rev 34e00b9f6e)`, both anchor links "connected", but its
ULA is 100% unreachable from 0.4.1 nodes (ping loss 100%). FIPS wire format is not
stable across revs (`docs/HANDOFF-2026-07-23-companion-apk-deploy.md:78`). Everything
to/from .228 rides Tor no matter what else we fix.
**Fix:** fleet fips-version audit + upgrade to v0.4.1 everywhere (in-product updater
exists: `fips/update.rs`; .deb path per `reference_vps2_fips_anchor`). Add a version
check to `fips.status` and surface a "peer daemon outdated" warning.
### RC2 — Direct LAN/endpoint peering is dead code + wrong port + stale seed anchors
Without direct links, all peer traffic hairpins through the vps2 anchor spanning
tree (observed: .116→.198 cold RTT 1.53.5s on the same LAN; also the wedged-anchor
latency-rot incident, `HANDOFF-2026-07-23:141-160`).
- **G1 — `lan_fips_anchors()` has never run.** It needs `PeerRecord.fips_npub`, but
`PeerRegistry::set_fips_npub` (`transport/mod.rs:302`) has **zero callers** — mDNS
TXT records only carry `did`/`pubkey`/`version` (`transport/lan.rs:50-54`). So the
"co-located peers form a direct link" feature (`anchors.rs:294-305`,
`server.rs:761-766`) is a fleet-wide no-op.
- **G2 — wrong UDP port.** `anchors.rs:293` dials `8668`, but the generated
fips.yaml binds UDP **2121** (`fips/config.rs:187`, `fips/mod.rs:130`). Even if G1
ran, it would dial a dead port. `.116`'s live `seed-anchors.json` still carries
`.198@192.168.1.198:8668` — **stale IP (LAN renumbered to 192.168.63.x) AND dead
port**; both manual entries are useless today.
- No Tailscale/alternate endpoint fallback when LAN is unreachable (the .116↔.198
fix of 2026-07-20 was hand-applied per-node config, never productized).
**Fix:** (a) `FIPS_UDP_PORT``crate::fips::PUBLISHED_UDP_PORT` + drift-guard test;
(b) hydrate `fips_npub` into the registry from federation storage (did-keyed join) so
`lan_fips_anchors` goes live with no wire change; (c) advertise the npub in the mDNS
TXT + `set_fips_npub` on resolve as the proper fix; (d) teach the LAN-anchor tick to
also try a peer's Tailscale/last-known-good endpoint when LAN fails (reviewed change
— this area got handoffs wrong twice, per memory).
### RC3 — No fast-fail on the hottest call sites; retry silently doubles every budget
- `content.browse-peer`**the Cloud page** — has NO `fips_timeout`
(`api/rpc/content.rs:363-366`): a cold FIPS path burns up to ~16.6s (8s connect +
600ms + 8s retry) before Tor even starts, against a UI deadline of 30s
(`Cloud.vue:720`) — and the frontend then retries ×3. Users see errors, not
fallback. 12 call sites total lack `fips_timeout` (browse/download/preview-peer,
`/blob`, DWN, node_message, rotation notifies).
- `dial.rs:128-140` runs 2 full-budget attempts, so `fips_timeout(6s)` really means
~12.6s everywhere.
**Fix:** wrap `send_with_retry` in a single `tokio::time::timeout(fips_attempt_timeout())`
(call sites `dial.rs:455`, `dial.rs:488`; halve per-attempt client timeout), then add
`.fips_timeout(...)`: `content.rs:366` (6s), `content.rs:281` (8s), `content.rs:1139`
(6s), `typed_messages.rs:822` (8s), `dwn_sync.rs:188/213/272` (6s),
`node_message.rs:376` (8s), `node_message.rs:412` (4s), `tor/mod.rs:501` (6s),
`federation/handlers.rs:869` (6s). **Skip the three 900s streaming downloads**
(`content.rs:552/870/1061`, `proxy.rs:236`) — `dial.rs:311-319` documents why; the
retry-budget wrap covers their connect phase.
### RC4 — Two features are 100% Tor by construction (allowlist 404)
The peer listener path allowlist (`server.rs:1219-1239`) omits `/blob/<cid>` (mesh
file sharing, `typed_messages.rs:813-822`) and `/dwn/health` (step 1 of DWN sync,
`dwn_sync.rs:186`) → deterministic 404 over FIPS (`dial.rs:44-46` treats 404 as
fall-back) → deterministic Tor, after paying the full FIPS cost. Both endpoints are
already cryptographically gated, so they meet the allowlist's stated criterion.
**Fix:** add `|| path.starts_with("/blob/") || path.starts_with("/dwn/")`; extend the
existing test block at `server.rs:1935-1945` (assert `/blob/abc` + `/dwn/health`
allowed, `/blobber` + `/dwnx` denied).
### RC5 — Inbound listener can't heal; anchor flap = 5-minute Tor window; probe overhead
- `peer_late_bind_loop` returns after first successful bind (`server.rs:1203`) and
`accept_loop` `continue`s on errors forever (`server.rs:1249-1258`): a fips0
teardown/re-key leaves the node inbound-dead until process restart → **every peer**
falls back to Tor against it.
- Nothing reacts to anchor-link drops: anchors re-apply only on the 300s tick
(`server.rs:731`); worst-case 5min Tor-only after a flap (the historic "link dead
timeout 30s" flapping made this chronic).
- `is_service_active()` spawns up to 2 `systemctl` per FIPS attempt *and* per peer
per 25s warm tick (`dial.rs:284-294`); `warm_path` skips peers without
`fips_npub` in federation storage (`fips/mod.rs:88-95`); `anchors::apply` is
serial with unbounded subprocess waits (`anchors.rs:234-283`).
**Fix:** rebindable listener; a ~25s connectivity watcher (reuse
`service::peer_connectivity_summary`, `fips/service.rs:178-207`) that re-applies
anchors immediately on a connected→disconnected edge with bounded backoff; 10s TTL
cache for `is_service_active` (mirror `transport/fips.rs:24-107`); warm the union of
federation+registry peers; make `apply()` concurrent with per-connect timeouts.
### RC6 — Zero observability: fallbacks are invisible, so "uptime" is unfalsifiable
Fallbacks log at `debug!` only (`dial.rs:458,491`); no counters; `last_transport` is
written by only 7 of ~20 call sites and **never read** to influence anything
(`storage.rs:120-147`). The parallel `TransportRouter` system can't even see FIPS
(`FipsTransport` is never constructed — `server.rs:422-442` registers Tor/Mesh/LAN
only).
**Fix:** per-reason fallback counters (F1 no-npub / F2 service-inactive / F3
DNS-fail / F4 connect-fail / F5 404 / F6 5xx) surfaced in `fips.status` + `info!`
logs with a `reason` field; call `record_peer_transport` from all peer-dial sites;
UI: per-peer transport badge on Cloud (the response already carries `transport`
`content.rs:392-400` — Cloud.vue currently throws it away at `:716-721`).
---
## Part A — execution phases
### Phase A0 — fleet triage (no release needed; do first, validates everything)
1. Fleet audit: `fipsctl --version` + `nft list table inet fips` + `ss -tlnp | grep 5679`
on every node (roster: `reference_test_deploy_roster`).
2. Transient `nft insert rule inet fips inbound iifname fips0 tcp dport 5679 accept`
on hardened nodes (already done on .116 + .198, 2026-07-27) — instant fleet-wide
FIPS recovery while the code fix rides the OTA.
3. Upgrade .228 (and any other 0.3.x) fips daemon to v0.4.1.
4. Regenerate/clean stale `seed-anchors.json` on .116 (dead 192.168.1.x + :8668 entries).
5. Baseline measurement: for each node pair, `content.browse-peer` time + transport.
### Phase A1 — P0 code (one commit, mechanical, offline-testable)
1. **nft drop-in: open 5679**`fips/config.rs` (share the constant with
`dial.rs::PEER_PORT`). ← RC0
2. **Allowlist `/blob/`, `/dwn/`**`server.rs:1219-1239` + tests. ← RC4
3. **`FIPS_UDP_PORT` = `PUBLISHED_UDP_PORT` (2121)** — `anchors.rs:293` + drift-guard
test against `render_config_yaml()`. ← RC2-G2
4. **Un-deaden `lan_fips_anchors`** — hydrate `fips_npub` from federation storage in
`server.rs:761-766`; then mDNS TXT `fips` key + `set_fips_npub`
(`transport/lan.rs:50-54`, `lan.rs:96-108`, `LanTransport::new` 4th arg via
`crate::identity::fips_npub(&data_dir.join("identity"))`). ← RC2-G1
5. **Retry-budget wrap + `fips_timeout` on 12 call sites** (list in RC3). ← RC3
Verify: `cd core && cargo test -p archipelago` — watch `test_rendered_yaml_exact_snapshot`
(`config.rs:419`) + `test_render_is_deterministic` (`config.rs:476`); item 3 must
not change rendered output.
### Phase A2 — telemetry BEFORE tuning (second commit)
6. Fallback counters by reason + `fips.status` exposure + `info!` reason logs;
`record_peer_transport` from all sites. ← RC6 (gives the baseline that makes A3
measurable and "100%" falsifiable)
### Phase A3 — resilience (third commit, measured against A2 baseline)
7. `is_service_active` 10s TTL cache; warm-path union + `warm_path_unchecked`.
8. Link-state watcher → immediate anchor re-apply on drop (replaces waiting for the
300s tick); concurrent `apply()` with subprocess timeouts.
9. Rebindable peer listener (`server.rs:1203`, `1249-1258`).
10. (Reviewed, separate PR) endpoint-fallback for direct peering: LAN → Tailscale →
last-known-good, npub-keyed. Mesh-routing area — needs careful review per memory.
### Phase A4 — verification gate (on nodes, before tag)
- On .116/.198/framework-pt/.228: `content.browse-peer` to every peer must return
`transport: "fips"` with sub-second latency (LAN pairs) / <3s (WAN), 20/20 calls.
- Kill the fips daemon on one node → calls fall back to Tor gracefully within the
fast-fail budget (<8s), UI shows partial results, no errors.
- Restart daemon → FIPS recovers within one watcher tick (~25s), verified in
`fips.status` counters.
- Flap the anchor link (drop vps2 route) → direct LAN pairs keep FIPS via their
direct link (G1 fix proof).
- Add these as `tests/multinode/` cases per `docs/multinode-testing-plan.md`; also
fix the known `node_rpc()` missing `--max-time` (tracker item).
---
## Part B — optimistic loading + state management (frontend)
Full audit: Pinia exists but pages fetch-on-mount with `loading=true` spinners;
`Dashboard.vue:89` keys the router-view by `route.path`, so **every navigation
unmounts and refetches everything**; no KeepAlive/onActivated anywhere; no dedup,
no abort, no SWR layer. Four hand-rolled cache implementations already exist and
prove the pattern (`useFleetData.ts:198-231` sessionStorage hydrate;
`homeStatus.ts` sticky-ready loadState; `Home.vue:591-621` wallet localStorage
snapshot; `curatedApps.ts:21-77` TTL cache). `SkeletonCard.vue` exists, imported by
zero files.
### B1 — one shared primitive: `useCachedResource` composable + `resources` Pinia store
Semantics (generalize `homeStatus.ts` + `useFleetData.ts`):
- Keyed resource: `{ data, loadState: idle|loading|ready|error|refreshing, fetchedAt, error }`.
- **Hydrate synchronously** from memory (Pinia, survives navigation) → sessionStorage
snapshot (survives reload) → then revalidate in background.
- Sticky-ready: once `ready`, never regress to `loading`
(`loadState = loadState==='ready' ? 'ready' : 'loading'` — the `homeStatus.ts:80` idiom);
keep-last-known-value on error with a stale badge (age from `fetchedAt`).
- TTL per resource; `revalidateOnFocus` + on WS push (debounced, the
`Home.vue:539-542` pattern); explicit `invalidate(key)` for mutations.
- Optimistic mutation helper: apply → RPC → rollback on error (generalize
`TransportPrefsCard.vue:112-127`).
### B2 — rpc-client upgrades (`src/api/rpc-client.ts`)
- `AbortSignal` in `RPCOptions` (today the AbortController at `:87` is timeout-only)
→ abort-on-unmount for fan-outs.
- In-flight dedup keyed `method+JSON(params)` — collapses duplicate concurrent calls.
- Per-call `maxRetries` override; set `maxRetries: 1` for `content.browse-peer` /
`preview-peer` (retry×3 on a 30s timeout is why one slow peer = 90s spinner).
### B3 — Cloud page conversion (worst offender, the marquee win)
- Move `sectionCounts`, `peerNodes`, `myFiles`, `peerFiles`, `paidItems` out of
`Cloud.vue` component state (`:403,:476,:582,:689,:427`) into the cached store —
instant render on revisit, background refresh.
- **Incremental per-peer fan-in**: render each peer's card as its
`content.browse-peer` resolves (today `Promise.allSettled` at `:708-747` blocks on
the slowest peer). Per-peer states: cached/fresh/loading/unreachable.
- **Surface `transport` per peer** (already in the response, discarded at `:716-721`):
FIPS/Tor badge + latency — this is also the fleet-wide FIPS-uptime dashboard the
user asked for, for free.
- Skeleton cards (revive `SkeletonCard.vue`, copy `FileGrid.vue:3-19` shimmer) instead
of spinners for counts/folders/peer grids.
- Stop `CloudFolder.vue:307-319` calling `cloudStore.reset()` on every folder entry —
cache per-path listings, navigate renders cache + revalidates.
- `PeerFiles.vue`: persist catalog + preview cache in the store; cap the
`preview-peer` fan-out (`:832-841`, currently unbounded) with a small concurrency
queue + abort-on-unmount.
### B4 — roll out to remaining offenders (in audit order)
PeerFiles → Web5 wallet/ecash/LND slices → Monitoring → Lightning channels
(`LightningChannelsPanel.vue:650`) → Federation (already has `{showLoader:false}`
just adopt the store) → Server → Credentials/OpenWrtGateway/ContainerApps.
`Apps.vue`/`Marketplace.vue`/`Fleet.vue` are already good; don't touch.
### B5 — freshness via the existing push channel
`/ws/db` firehose + `sync.ts` JSON-patch already exist. Wire `useCachedResource`
revalidation to relevant WS pushes (debounced 800ms), keep the 30s staleness
reconciliation as backstop. No new backend needed for v1; a per-topic subscribe can
come later.
### Part B verification (on nodes)
- Navigate Cloud → Apps → Cloud: peer files render instantly from cache (0 spinner),
refresh indicator while revalidating, updated data lands without layout jump.
- One unreachable peer: its card shows stale/unreachable state; other peers render
immediately (no 30s all-or-nothing).
- Kill backend mid-view: stale data stays visible with age badge; recovery
revalidates automatically.
- Hard reload: sessionStorage hydrate paints before first RPC completes.
---
## Sequencing for the next release
1. **A0 now** (fleet triage + transient nft rules + .228 daemon upgrade + baseline).
2. **A1 + A2** land together (P0 fixes + telemetry) → deploy to .116/.198 →
Phase A4 checks on the pair → framework-pt → full fleet.
3. **B1 + B2 + B3** (composable + rpc-client + Cloud) in parallel with A-testing —
frontend-only, verifiable against .116 dev (`reference_neode_ui_dev_testing`).
4. **A3** after telemetry baseline exists; **B4/B5** ride the same or next OTA.
5. Gate: Phase A4 checklist green + Part B verification on-device + existing
single-node gate stays green → tag/OTA per ship ritual.
## Success criteria
- `content.browse-peer` transport = fips for ≥99% of calls between healthy 0.4.1
nodes over 24h (measured by the new counters), Tor reserved for genuinely
FIPS-unreachable peers (.116-WiFi-class networks).
- Cloud revisit paints in <100ms from cache; fresh data within one revalidate.
- Fallback counters visible in `fips.status` so regressions are caught on the
dashboard, not by users.

View File

@ -1,108 +0,0 @@
# License Compliance Audit — Open-Source Release
Audit date: 2026-07-22. Scope: entire repo (core Rust workspace, neode-ui, apps/*, Android companion, image-recipe ISO, docker/, app-catalog, reticulum-daemon, demo/) plus the external FIPS source and registry-mirrored images.
**Verdict:** the dependency graph is almost entirely permissive (MIT/Apache/BSD) and compatible with a free open-source release. But the repo is not releasable as-is: it has **no license of its own**, one **LGPL Rust dependency**, several **non-redistributable committed assets** (proprietary fonts, unknown-rights media), and **missing attribution machinery**. Everything below is ordered by severity.
---
## STATUS UPDATE — 2026-07-23
**DONE:**
- MIT adopted. Root `LICENSE` + `NOTICE` added; `license = "MIT"` in all 5 workspace crates (archy-fips-core already had it); `"license": "MIT"` (+ `"private": true`) in all 4 package.json files.
- Deleted: `Courier_New/`, `Benton_Sans/`, `Redacted/` fonts; `wireguard.apk`; `atob.s9pk`; obsolete `test-install.sh` (all git-rm'd; also removed from `web/dist`).
- Media provenance resolved: all demo music/photos/posters, UI sfx, backgrounds, and intro video are the author's original work — recorded in `demo/content/README.md` and `NOTICE`.
- Meshtastic device artwork attributed (`mesh-devices/ATTRIBUTION.md` + NOTICE); icon attribution added (`assets/icon/ATTRIBUTION.md`: game-icons.net CC BY 3.0, pixelarticons MIT).
- Reticulum decision: include + disclose (NOTICE states the Reticulum License restrictions and that it applies only to the optional daemon).
- indeedhub: deferred — partnership in place; license the submodule before/at public release.
- License inventories generated: `core/THIRD-PARTY-LICENSES.md` (649 crates) and `neode-ui/THIRD-PARTY-LICENSES.md` (runtime deps + fonts + vendored).
**REMAINING (code changes, awaiting review — see sections below for detail):**
1. Replace `zbase32` (LGPL-3.0+) with `z32` or original impl — §2.
2. Swap `redis:7.4.8` → Valkey in `scripts/image-versions.sh` and deploys — §3.
3. Delete dead StartOS-derived crates `core/{js-engine,container-init,models,helpers}` — §4.
4. Attribution build integration: cargo-about in CI → ship full license texts in ISO; vite/rollup license plugin (or UI licenses page) for the web bundle; Android OSS-licenses screen — §5.
5. Release-checklist items: per-release Debian source pointer (snapshot.debian.org), catalog `license`/`sourceUrl` fields, restrict ISO image bundling to the audited list — §6.
6. Before repo goes public: purge deleted fonts/APKs from git history (`git filter-repo`), and verify game-icons author credit.
---
## 1. BLOCKER — the project has no license
There is no `LICENSE`/`COPYING` file anywhere in the repo. No crate in `core/` declares a `license` field; none of the four `package.json` files do either (and the three `apps/*` packages aren't even `private: true`). Until fixed, the code is "all rights reserved" — publicly visible, but legally not open source and not usable by anyone.
**Do:**
- [ ] Choose a license. **Recommendation: MIT** — the Bitcoin-ecosystem norm (Bitcoin Core, LND are MIT), maximally compatible with everything found in the graph. (Alternatives: Apache-2.0 adds a patent grant; GPLv3 if copyleft is desired — nothing in the deps prevents any of these.)
- [ ] Add `LICENSE` at repo root with the year and copyright holder.
- [ ] Add `license = "MIT"` to all five workspace member `Cargo.toml`s (archipelago, container, openwrt, performance, security) and `Android/rust/archy-fips-core` (declares MIT but ships no license file — add one).
- [ ] Add `"license": "MIT"` to `neode-ui/package.json` and `apps/{morphos-server,router,did-wallet}/package.json`.
## 2. BLOCKER — copyleft dependency that must be replaced
- [ ] **`zbase32 0.1.2` — LGPL-3.0+** — the only hard copyleft blocker in all 649 resolved Rust crates. Direct dep of `archipelago`, used in `core/archipelago/src/network/did_dht.rs` for did:dht z-base-32 encoding. LGPL statically linked into a Rust binary requires shipping relinkable objects/source — impractical. **Replace with the MIT `z32` crate** or a ~30-line original alphabet-substitution implementation.
No GPL, AGPL, SSPL, or unlicensed crates exist anywhere else in the Rust graph. (`r-efi` and `self_cell` list LGPL/GPL only as options in OR-expressions — elect MIT/Apache, no action.)
## 3. BLOCKER — committed files we may not redistribute
Remove from git (and **purge from history** before the repo goes public — they're in past commits):
- [ ] `neode-ui/public/assets/fonts/Courier_New/` — Monotype proprietary font, no license, **unused in CSS**. Delete.
- [ ] `neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf` — commercial Font Bureau typeface, no license, unused. Delete.
- [ ] `neode-ui/public/packages/wireguard.apk` (17 MB) — official WireGuard Android APK containing GPL-2.0 `libwg` components; redistribution triggers GPL source-offer. **Unreferenced since the FIPS migration** — delete.
- [ ] `neode-ui/public/packages/atob.s9pk` (24 MB) — Start9 service package, unknown license, referenced only by a test script. Delete.
- [ ] `demo/content/music/` (18 full tracks, ~150 MB) and `demo/peer-media/` (17 photos/book covers/film posters) — no recorded rights. If they're your own/AI-generated work, document that in a `demo/content/README`; otherwise remove.
- [ ] `neode-ui/public/assets/video/video-intro.mp4`, `Kratter.MP3`, photographic `bg-*.jpg` backgrounds, UI/arcade sound effects in `assets/audio/` — same: document provenance (user-made per project convention) or replace. `welcome-noderunner.mp3` is ElevenLabs TTS — their commercial-use terms allow this on paid plans; note it.
- [ ] **Registry: `redis:7.4.8`** (`scripts/image-versions.sh` `REDIS_IMAGE`) — Redis ≥ 7.4 is RSALv2/SSPLv1, **not open source**; re-hosting it on your registry is redistribution under a restricted license. **Switch to Valkey** (BSD-3, already mirrored) everywhere.
## 4. VERIFY — unknown/third-party provenance
- [ ] **`neode-ui/public/assets/img/mesh-devices/` (36 SVGs)** — almost certainly Meshtastic project device artwork (meshtastic/web is GPL-3.0). Confirm source; either replace with original art or comply with the upstream license + attribution.
- [ ] **`neode-ui/public/assets/icon/`** — `barbarian.svg`, `batteries.svg` match game-icons.net (**CC BY 3.0 — visible attribution required**); pixel-style icons match pixelarticons (MIT). Confirm and add attribution, or replace.
- [ ] `Redacted/redacted.regular.ttf` — upstream is SIL OFL 1.1 but no license file is shipped. Add `OFL.txt` or delete (unused).
- [ ] **indeedhub** — submodule (private gitea) not checked out; no known license, yet `indeedhub{,-api,-ffmpeg}:1.0.0` images are distributed via registry/ISO. `indeedhub-ffmpeg` implies a bundled FFmpeg (LGPL/GPL → source-offer obligations). Must license the project and audit the ffmpeg build before public release.
- [ ] `minmoto/fmcd` v0.8.0 and `ark-bitcoin/bark` (barkd) — binaries redistributed in your images; verify upstream licenses (bark claims Apache-2.0/MIT dual) and include their notices.
- [ ] **Start9/StartOS heritage**`core/{js-engine,container-init,models,helpers}` are StartOS-derived (embassy paths, s9pk handling). start-os is MIT → attribution required if kept. **Better: delete these four crates** — they are not workspace members, cannot compile (broken `../../patch-db` path dep), and carry an unpinned `yajrc = "*"` git dep on a moving branch. Deleting removes both the attribution question and dead code.
- [ ] **Reticulum (RNS 1.3.5 + LXMF)** — verified: custom "Reticulum License" — MIT-style **plus field-of-use restrictions** (no systems designed to harm humans; no AI/ML training-dataset use). Redistribution is permitted, so shipping the PyInstaller `archy-reticulum-daemon` binary is fine **if** the license text is included with it — but the OS cannot claim to be 100 % OSI-open-source while bundling it. Options: include + disclose (recommended, matches "plan for decentralization" honesty), or make the daemon an optional download.
## 5. REQUIRED — attribution / notice machinery (currently absent)
Nearly every permissive license (MIT/BSD/ISC/Apache) requires reproducing copyright + license text **in distributed binaries** — and right now every distribution channel strips them:
- [ ] **Rust binaries** (649 crates, ~85 % MIT/Apache dual): generate `THIRD-PARTY-LICENSES` with `cargo-about` (or `cargo-license`) in CI; ship it in the ISO at e.g. `/usr/share/doc/archipelago/`. Include **ring's three license files** (LICENSE, LICENSE-BoringSSL, LICENSE-other-bits) and note the system OpenSSL (Apache-2.0) linked via `ssh2`.
- [ ] **Web bundle**: Vite/esbuild strips all `@license` comments from `web/dist`. Add `rollup-plugin-license`/`vite-plugin-license` to emit a third-party attribution file, or add an "Open-source licenses" page in the UI. Runtime deps needing notices: vue/vue-router/pinia/vue-i18n (MIT), d3 (ISC), leaflet (BSD-2), dompurify (elect Apache-2.0 of its MPL/Apache dual), fuse.js (Apache-2.0), qrcode/qr-scanner/qrloop/buffer/fast-json-patch (MIT).
- [ ] **Android APK**: `packaging.excludes` strips `META-INF` license texts and there is no licenses screen. Add an OSS-licenses screen or bundled `licenses.txt` covering AndroidX/Compose/OkHttp/ZXing (Apache-2.0), **fips © 2026 Johnathan Corgan (MIT — the core of the VPN feature)**, tokio/tracing (MIT), subtle (BSD-3), tun (WTFPL — permissive, just list it), secp256k1 family (CC0). Generate the Rust side from the committed `Cargo.lock` with cargo-about.
- [ ] **AIUI demo bundle** (`demo/aiui/` — committed minified build): bundles Mermaid, Cytoscape, KaTeX, D3, Lodash, Workbox (all MIT/BSD). Add a `THIRD-PARTY-LICENSES` file next to it (or rebuild with a license plugin).
- [ ] Keep the intact MIT headers in the two vendored `qrcode.js` copies (docker/lnd-ui, docker/electrs-ui) — already compliant, don't minify them.
- [ ] Fonts kept: Montserrat (OFL.txt present ✓), Open Sans (Apache LICENSE.txt present ✓) — keep license files adjacent to the font files in dist.
## 6. REQUIRED — distribution-level obligations (ISO & registry)
The ISO redistributes a full Debian (trixie) system plus ~29 container image tarballs; the private registry re-hosts upstream images. Re-hosting = redistribution, same obligations as bundling.
- [ ] **GPL source offer for the ISO** — kernel, GRUB, busybox/live-boot, coreutils, nftables, cryptsetup, wireguard-tools, SYSLINUX `isohdpfx.bin`, etc. Easiest compliance: keep `/usr/share/doc/*/copyright` (the build already does ✓) **and** publish, per release, either a mirror of the exact Debian source packages (`apt-get source` snapshot / snapshot.debian.org pointer) or a written offer in the docs. Add this to the release checklist.
- [ ] **AGPLv3 images redistributed** (mempool, Grafana, Vaultwarden, SearXNG, PhotoPrism, Nextcloud, Immich, CryptPad, MinIO): AGPL compliance = make corresponding source available. You ship a **modified** mempool-frontend (`docker/mempool-frontend` entrypoint patch) — the patch is in-repo, so compliance is met once the repo is public; state this in docs. For unmodified images, link upstream sources in the app catalog.
- [ ] **GPLv2/GPLv3 images** (MariaDB, Jellyfin, AdGuard Home, strfry): unmodified redistribution → provide license text + upstream source links (a `license` + `sourceUrl` field per `app-catalog/catalog.json` entry solves this catalog-wide).
- [ ] **Non-free firmware** (firmware-realtek/iwlwifi/misc/linux-nonfree, intel/amd microcode): redistributable but proprietary — disclose in docs ("includes non-free firmware for hardware support"), like Debian's own non-free-firmware ISOs do.
- [ ] The ISO build's live-server image capture (`podman save` of whatever matches on the dev server) is a compliance hazard — bundle only from the audited image list.
- [ ] FIPS daemon (jmcorgan/fips v0.4.1, MIT ✓) and nostr-rs-relay binary (MIT ✓): include their license texts in the notices bundle.
## 7. Housekeeping (supports compliance)
- [ ] Add lockfiles + pinned versions in `apps/*` (currently floating `^` ranges, violating the project's own pinning rule) — reproducibility is also what makes license audits stay true.
- [ ] `Android` fips dep is pinned to a personal fork rev (`9qeklajc/fips-native@46494a74`) — mirror or vendor it so outside contributors can build.
- [ ] Move `@types/dompurify` to devDeps; refresh stale `neode-ui/node_modules`.
- [ ] Add a `NOTICE` file at root naming: fips (Johnathan Corgan, MIT), Start9 start-os (if any derived code remains), Kazuhiko Arase qrcode.js, font licenses, icon attributions.
- [ ] Consider CI license gating: `cargo-deny` (Rust) + `license-checker` (npm) with an allowlist, so new copyleft deps are caught at PR time.
---
## Quick reference: what's already clean
- All 649 Rust crates except `zbase32`: permissive or dual-licensed.
- All 833 npm packages in neode-ui: no GPL/AGPL anywhere; only dev-tool LGPL (sharp's libvips, never distributed).
- Android Gradle deps: 100 % Apache-2.0, all pinned, no Play Services/telemetry.
- FIPS mesh: MIT (© 2026 Johnathan Corgan) — keep notice.
- js-engine binds deno_core (MIT) as a crate, nothing vendored — moot if dead crates are deleted.
- reticulum-daemon Python is original code; obligations attach only to the PyInstaller binary (see §4).
- Bitcoin Core/Knots, LND, BTCPay, Electrs, Fedimint, core-lightning, Gitea, Home Assistant, Tailscale, Portainer, Uptime-Kuma, filebrowser, ollama, penpot: MIT/Apache/BSD/Zlib/MPL — link + notice is enough.

View File

@ -27,75 +27,6 @@ is GREEN and must stay green. Re-run after any orchestrator/lifecycle change (Ph
especially). All cargo verification uses `--all-features` to match CI. Stage by explicit
path, never `git add -A` (shared tree).
## Current local pass status
This branch is replayed on top of `origin/main` as `public-prelaunch`.
Completed locally in this pass:
- Redacted the two tracked Anthropic API key literals from
`scripts/setup-aiui-server.sh` and
`image-recipe/_archived/build-auto-installer-iso.sh`.
- Removed `Android/app/debug.keystore` and `core/.env.production` from the
source tree; copies were preserved in
`~/Desktop/archipelago-sensitive-backup-2026-07-27/`.
- Reworked `scripts/audit-secrets.sh` to scan tracked source more aggressively
and to catch non-example env files and credential file patterns.
- Reworked `scripts/validate-app-manifest.sh` so the current `app:` manifest
schema can be audited without a Python `PyYAML` dependency.
- Updated root/community docs, CI, PR template, app developer notes, and
container/deployment docs toward public contributor expectations.
- Fixed native FIPS activation fallback: nodes that have the packaged
`fips.service` but not `archipelago-fips.service` now start the available
unit instead of repeatedly failing activation against a missing unit. This
now covers startup, supervisor self-heal, manual dashboard start/reconnect,
and post-onboarding activation. The UI now labels the action as `Start`
instead of making native FIPS look like an installable app.
- Fixed the FIPS app-port relay design so it binds relays to the node's FIPS
ULA instead of wildcard `[::]`, avoiding collisions with Podman-published app
ports such as FileBrowser `8083` and Botfights `9100`.
- Added `docs/nostr-git-source-hosting.md`, a NIP-34/ngit/GRASP source hosting
plan using a Bitcoin Core-style maintainer model: public review and easy
forks, with canonical merge rights held by a small signed maintainer set.
Verified locally:
- `./scripts/audit-secrets.sh` passes.
- Full `apps/*/manifest.yml` repository audit passes with warnings only.
- `bash -n` passes for the edited shell scripts.
- Targeted FIPS dashboard vitest passes.
- Targeted Rust tests for FIPS service unit detection and FIPS app relay
address selection pass.
Verified on a Linux Archipelago verification node:
- Native FIPS was restored by starting the already-installed packaged
`fips.service`; the daemon became active and joined the FIPS tree.
- Correct local lifecycle API endpoint is HTTP, not HTTPS
(`ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http`).
- Read-only lifecycle run progressed past login and confirmed required
containers, Bitcoin RPC, ElectrumX TCP, and manifest port-drift checks, but
did not complete cleanly: `botfights` and `filebrowser` remained in
`restarting` longer than the matrix window, and the LND `lncli getinfo`
probe hung. Do not run the destructive gate until those live-node issues are
understood.
- After the node updated to `1.7.116-alpha`, `botfights`, `filebrowser`, and
`lnd` were active/running and ports `8083`/`9100` were held by Podman's
`rootlessport` as expected. The packaged `fips.service` remained installed
and enabled but inactive, so the native FIPS service fallback should still
ship before the public launch.
Still required before public publish:
- Rotate/revoke compromised credentials listed in Phase 0.
- Finish Phase 1 password/node/token sanitization beyond the two API keys.
- Publish from fresh history after the sanitized tree is final.
- Run full Rust, frontend, Android, and lifecycle gate verification.
- Resolve the live-node lifecycle blockers above, then rerun the read-only
suite followed by the destructive gate only on an approved verification node.
- Decide the canonical Archipelago maintainer npub and merge-maintainer npub
list before publishing the Nostr Git source-hosting workflow.
---
## Phase 0 — Credential rotation (immediate, independent of the repo)

View File

@ -146,7 +146,7 @@ app:
path: /health
interfaces:
main:
type: ui
type: web
port: 8090
```

View File

@ -74,10 +74,9 @@ archy/
### Prerequisites
- Node.js 20+ and npm for frontend development.
- Rust stable for backend development.
- Linux with Podman, systemd, and Nginx for host integration work.
- Debian 13 is the target runtime for release validation.
- **macOS** (development machine): Node.js 20+, npm
- **Linux server** (`192.168.1.228`): Rust toolchain, Podman, Nginx, Debian 13
- SSH key: `~/.ssh/archipelago-deploy`
### Local Frontend Development
@ -87,18 +86,17 @@ npm install
npm start # Vite dev server on :8100, mock backend on :5959
```
The dev server at `http://localhost:8100` uses a mock backend.
The dev server at `http://localhost:8100` uses a mock backend. Login with `password123`.
### Deploying Changes
Release and host-integration builds should run on Linux. The deploy script rsyncs
source to a configured Linux target and builds there.
**Never build Rust on macOS.** The deploy script rsyncs source to the Linux server and builds there.
```bash
# Deploy to the configured primary target (builds backend + frontend, restarts services)
# Deploy to live server (builds backend + frontend, restarts services)
./scripts/deploy-to-target.sh --live
# Deploy to both configured targets
# Deploy to both servers
./scripts/deploy-to-target.sh --both
```
@ -116,16 +114,14 @@ The deploy script:
# Frontend tests
cd neode-ui && npm test
# Backend tests
cd core && cargo test --all-features
# Backend tests (on dev server via SSH)
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
"cd ~/archy/core && cargo test --all-features"
# Both
./scripts/run-tests.sh
```
`scripts/run-tests.sh` can run backend tests on a Linux target when
`ARCHIPELAGO_SSH_HOST` and `ARCHIPELAGO_SSH_KEY` are set.
## Adding a New RPC Endpoint
### 1. Create the Handler
@ -204,7 +200,7 @@ async myAction(params: { name: string }): Promise<{ ok: boolean; result: string
```bash
./scripts/deploy-to-target.sh --live
curl -X POST http://<node-host>/rpc/v1 \
curl -X POST http://192.168.1.228/rpc/v1 \
-H "Content-Type: application/json" \
-b "archipelago_session=YOUR_SESSION" \
-d '{"method":"mymodule.action","params":{"name":"test"}}'
@ -313,5 +309,5 @@ mod tests {
2. Make changes following the standards above
3. Test locally: `cd neode-ui && npm test`
4. Deploy to dev server: `./scripts/deploy-to-target.sh --live`
5. Verify on your configured development target
5. Verify at `http://192.168.1.228`
6. Commit with conventional format: `feat: add my feature`

View File

@ -1,252 +0,0 @@
# Nostr Git Source Hosting Plan
This plan describes how Archipelago can publish and accept contributions to its
source code through `ngit`, NIP-34, and GRASP while keeping the developer
experience inside Archipelago.
## Goals
- Publish Archipelago source from a sanitized, fresh-history repository.
- Make the in-app registry the primary onboarding path for contributors.
- Let contributors clone, branch, push PR branches, open PRs, and discuss issues
with a Nostr identity from their Archipelago node.
- Follow the Bitcoin Core development model: broad public review and easy forks,
with canonical merge authority held by a small maintainer set.
- Give contributors full read, fork, and proposal rights, but no direct merge
rights on the canonical repository.
- Keep the official maintainer identity and merge authority separate from user
node identities.
## Current Building Blocks
Archipelago already has most of the primitives needed for this:
- App manifests and the app registry already install developer tooling as
rootless Podman apps.
- The `gitea` app provides a conventional fallback Git UI and package registry.
- The app launcher already exposes a consent-gated NIP-07 bridge for launched
apps using `getPublicKey`, `signEvent`, NIP-04, and NIP-44 requests.
- The backend exposes node and identity Nostr signing RPC methods.
- FIPS gives nodes a stable mesh identity and private transport path, but repo
announcements and PRs should remain NIP-34 compatible on normal Nostr relays.
- DWN protocol registration exists and can be used later for local contribution
metadata/cache, but should not be required for the first public workflow.
## Protocol Basis
Use existing Nostr Git conventions rather than inventing an Archipelago-only
protocol:
- NIP-34 repository announcement events identify repositories with kind `30617`.
- NIP-34 repository state events publish branch/tag refs with kind `30618`.
- NIP-34 patches, pull requests, PR updates, issues, and status events use kinds
`1617`, `1618`, `1619`, `1621`, and `1630`-`1633`.
- `ngit` provides the `git-remote-nostr` helper for `nostr://` clone URLs and PR
branches.
- GRASP servers provide Git Smart HTTP storage while Nostr events remain the
authority for repository identity, refs, PRs, issues, and maintainer state.
Primary references:
- https://nips.nostr.com/34
- https://docs.rs/crate/ngit/latest/source/README.md
- https://ngit.dev/grasp/
## Recommended Architecture
### Apps
Create two first-party apps:
- `ngit`: CLI/runtime package containing `ngit` and `git-remote-nostr`.
- `archipelago-source`: web UI for cloning Archipelago source, viewing NIP-34
issues/PRs, opening branches, and submitting PR events.
The `archipelago-source` app should depend on `ngit`. It can also recommend
Gitea for users who want a conventional local web Git UI, but Gitea should not
be the source of truth for public contribution permissions.
### Contributor Onboarding
When the user installs `archipelago-source` from the registry:
1. Show a modal before first launch: "Contribute to Archipelago".
2. Explain that the app will use their Archipelago Nostr identity to clone and
sign contribution events.
3. Display the maintainer repository announcement, clone URL, maintainer npub,
and relay/GRASP endpoints.
4. Ask for consent to:
- fetch repository metadata from configured relays,
- clone source through `nostr://`,
- create local branches,
- sign NIP-34 issue/PR/comment events,
- push PR branches to approved GRASP servers.
5. Store approval per app origin, identity id, repository id, and relay set.
This should build on the existing NIP-07 app-launcher bridge, but use a more
specific permission scope than the generic sign-event approval.
### Identity And Permissions
Use four identity classes:
- `archipelago-maintainer`: an offline or tightly controlled Nostr key that
signs the canonical kind `30617` repo announcement and status/merge events.
- `archipelago-merge-maintainer`: one of the small set of maintainer npubs
allowed to advance canonical refs and publish valid merged/applied status.
- `archipelago-build`: release automation key for signed release artifacts and
CI status events. It must not have merge authority.
- `contributor`: user node or app-specific identity used for PRs, issues, and
comments.
Contributor rights:
- Clone the repository.
- Open issues.
- Push proposal branches using `pr/<npub>/<short-topic>` or `pr/<event-id>`.
- Publish NIP-34 PR/update/comment events.
- Rebase and update their own PR branch.
- Run local validation and attach status evidence.
Contributor restrictions:
- Cannot update `refs/heads/main` or release branches in canonical state.
- Cannot publish maintainer-valid merge/applied status.
- Cannot alter the canonical repository announcement.
- Cannot publish release catalog signatures.
Maintainer rights:
- Publish/update the canonical repo announcement.
- Publish canonical `refs/heads/main` state.
- Mark PRs merged/closed/draft via NIP-34 status events.
- Sign release tags and catalog updates.
Fork rights:
- Any contributor can create their own NIP-34 kind `30617` repository
announcement for a fork.
- Fork announcements should use the NIP-34 `u` tag to point back to the
canonical `archy` repository.
- The source app should make forking a first-class path: "Fork on Nostr", clone
the fork locally, push branches to the contributor's GRASP list, and open PRs
back to canonical Archipelago when they want review.
- Forks can have their own maintainer npubs, relays, policies, and release
cadence, but the app should clearly label them as forks unless signed by the
canonical maintainer set.
The GRASP server policy should enforce this by accepting pushes to maintainer
refs only when backed by signed maintainer state, while allowing contributor PR
refs from their own npubs.
## Repository Layout
Canonical repo announcement:
- repo id: `archy`
- display name: `Archipelago`
- clone URLs:
- `nostr://<maintainer-npub>/<relay-hint>/archy`
- `https://<grasp-host>/<maintainer-npub>/archy.git`
- relays:
- Archipelago-operated relay
- at least two public Nostr relays that support the event load
- GRASP servers:
- Archipelago-operated GRASP instance
- one public GRASP-compatible mirror
Keep the existing HTTP Git remote as a mirror during launch. The docs can
present `nostr://` as the preferred contribution path once the workflow is
proven.
## UI Requirements
The source app should provide:
- A first-run contribution modal with a real Archipelago source graphic, not a
generic text-only dialog.
- Current clone status and local path.
- Branch list, changed files, commit form, and push/open-PR flow.
- PR inbox, issue list, maintainer status, and relay health.
- Explicit identity indicator showing which npub will sign events.
- A merge rights indicator that clearly says contributors can propose changes
but cannot merge them.
- A fork flow that creates a user-owned NIP-34 repo announcement and remote,
then offers "Open PR to Archipelago" from any fork branch.
- Maintainer badges based only on pinned canonical maintainer npubs, not relay
metadata or server-side account names.
- Links to container docs, deployment docs, manifest spec, and open-source
readiness tasks.
## Backend Work
Add an RPC module for source contribution workflow:
- `source.repo-info`: returns canonical announcement, clone URL, relay set,
maintainer npubs, and local clone state.
- `source.ensure-ngit`: verifies the `ngit` app/runtime is installed.
- `source.clone`: clones or updates the local source checkout.
- `source.status`: returns branch, dirty files, ahead/behind, and PR state.
- `source.commit`: creates a local commit from selected files.
- `source.fork`: creates a contributor-owned NIP-34 fork announcement and local
remote.
- `source.open-pr`: pushes a PR branch and publishes a kind `1618` event.
- `source.update-pr`: updates the branch and publishes kind `1619`.
- `source.issue`: publishes a kind `1621` event.
Backend must shell out through a narrow command wrapper, never arbitrary user
commands. The wrapper should set an isolated working tree under
`/var/lib/archipelago/source/archy`, run as the Archipelago service user, and
deny operations outside that path.
## Security Model
- Never expose maintainer private keys to an Archipelago node.
- Prefer app-specific contributor identities over the node's default identity.
- Require per-action consent for first PR push, issue creation, and signing any
event that tags the canonical repository.
- Pin the canonical maintainer npub in the app manifest and backend config.
- Keep the canonical merge-maintainer allow list signed by the
`archipelago-maintainer` key; never infer merge rights from GRASP server
accounts.
- Verify the canonical kind `30617` event signature before displaying clone
instructions.
- Treat GRASP servers as untrusted storage; verify Git refs against signed
Nostr state.
- Do not use destructive git operations from the UI without an explicit modal.
- Store local clones and generated patches outside app container writable roots
unless the user exports them.
## MVP
1. Package `ngit` as a first-party app.
2. Stand up one Archipelago-operated GRASP server and one Nostr relay.
3. Publish sanitized fresh-history `archy` through `ngit init`.
4. Add a simple `archipelago-source` app that clones source and links out to the
preferred Nostr Git browser.
5. Add app-launcher consent scopes for repository-specific NIP-34 signing.
6. Allow issues and PR branch submission from contributor npubs.
7. Add a one-click fork flow that publishes a contributor-owned fork
announcement referencing canonical Archipelago.
8. Keep maintainer merge/status publication manual.
## Later
- Native PR review UI with file diffs and inline comments.
- CI status events signed by the build identity.
- FIPS-first source sync between trusted Archipelago nodes.
- Private prerelease repositories using NIP-42 allow lists and/or protected
events if the ecosystem support is mature enough.
- Multi-maintainer policy with threshold signatures or explicit maintainer-list
rotation events.
## Open Questions
- Which maintainer npub should become canonical for `archy`?
- Should contributor identities be node-default or app-specific by default?
- Which GRASP implementation should be deployed first: `ngit-grasp` or another
NIP-34/GRASP-compatible relay?
- Should the source app include a full web Git UI in v1, or launch Gitea/ngit
browser links for review while keeping signing/submission native?
- What exact license and contribution certificate should contributors accept
before submitting PR events?

View File

@ -2834,7 +2834,7 @@ After=network.target
[Service]
Type=simple
Environment=ANTHROPIC_API_KEY=
Environment=ANTHROPIC_API_KEY=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
ExecStart=/usr/bin/python3 /opt/archipelago/claude-api-proxy.py
Restart=always
RestartSec=5

View File

@ -1,39 +0,0 @@
# Third-Party Licenses — neode-ui (web frontend)
Runtime dependencies bundled into the distributed web UI. Verified 2026-07-23.
Dev-only tooling (Vite, Playwright, TypeScript, etc.) is not distributed and
is not listed here. Note: the production bundler strips license header
comments, so this file (and the fonts' adjacent license files) constitute the
attribution shipped with the bundle; a build-time license-aggregation step is
planned (see docs/LICENSE-COMPLIANCE-AUDIT.md).
| Package | Version | License |
|---|---|---|
| vue | 3.5.x | MIT |
| vue-router | 4.6.x | MIT |
| vue-i18n | 11.3.x | MIT |
| pinia | 3.0.x | MIT |
| d3 | 7.9.x | ISC |
| leaflet | 1.9.x | BSD-2-Clause |
| @vue-leaflet/vue-leaflet | 0.10.x | MIT |
| dompurify | 3.4.x | MPL-2.0 OR Apache-2.0 (used under Apache-2.0) |
| buffer | 6.0.x | MIT |
| fast-json-patch | 3.1.x | MIT |
| fuse.js | 7.1.x | Apache-2.0 |
| qrcode | 1.5.x | MIT |
| qr-scanner | 1.4.x | MIT |
| qrloop | 1.4.x | MIT |
## Fonts
| Font | License | License file |
|---|---|---|
| Montserrat | SIL OFL 1.1 | public/assets/fonts/Montserrat/OFL.txt |
| Open Sans | Apache-2.0 | public/assets/fonts/Open_Sans/LICENSE.txt |
## Vendored
- `public/assets/icon/` — see ATTRIBUTION.md in that directory
(game-icons.net CC BY 3.0; pixelarticons MIT).
- `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0;
see ATTRIBUTION.md in that directory.

View File

@ -1,11 +0,0 @@
# Icon Attribution
- `barbarian.svg`, `batteries.svg` — from **game-icons.net**
(https://game-icons.net), by Lorc, Delapouite & contributors,
licensed under CC BY 3.0 (https://creativecommons.org/licenses/by/3.0/).
- Pixel-style icons (`save.svg`, `paint-bucket.svg`, `fill-half.svg`,
`cloud-moon.svg`, `debug-off.svg`, `cloud-done.svg`) — from
**pixelarticons** by Gerrit Halfmann (https://github.com/halfmage/pixelarticons),
MIT License.
- All other icons are original Archipelago artwork (MIT, see repository
LICENSE).

View File

@ -1,13 +0,0 @@
# Device Artwork Attribution
The device illustrations in this directory are from the
**Meshtastic® project** — https://meshtastic.org
(https://github.com/meshtastic), © Meshtastic contributors,
licensed under GPL-3.0.
Meshtastic® is a registered trademark of Meshtastic LLC, used here solely to
identify compatible hardware. No endorsement by the Meshtastic project is
implied.
Any modifications to these images made for Archipelago are likewise available
under GPL-3.0 as part of this public repository.

View File

@ -4,16 +4,6 @@ export interface RPCOptions {
method: string
params?: Record<string, unknown>
timeout?: number
/** Abort the call (and any pending retries) from the outside pass a
* component-scoped controller's signal so fan-outs stop on unmount. */
signal?: AbortSignal
/** Per-call retry budget (default 3). Use 1 for calls whose caller has its
* own timeout/fallback UX retry×3 on a slow peer is how one unreachable
* node turns a 30s timeout into a 90s spinner. */
maxRetries?: number
/** Collapse concurrent identical calls (same method + params) into one
* request. Opt-in: only safe for reads. */
dedup?: boolean
}
export interface RPCResponse<T> {
@ -84,35 +74,18 @@ function getCsrfToken(): string | null {
class RPCClient {
private static _sessionExpiredRedirecting = false
private baseUrl: string
/** In-flight dedup map for `dedup: true` calls, keyed method+params. */
private inflight = new Map<string, Promise<unknown>>()
constructor(baseUrl: string = '/rpc/v1') {
this.baseUrl = baseUrl
}
async call<T>(options: RPCOptions): Promise<T> {
if (options.dedup) {
const key = `${options.method}:${JSON.stringify(options.params ?? {})}`
const existing = this.inflight.get(key)
if (existing) return existing as Promise<T>
const p = this.callInner<T>(options).finally(() => this.inflight.delete(key))
this.inflight.set(key, p)
return p
}
return this.callInner<T>(options)
}
private async callInner<T>(options: RPCOptions): Promise<T> {
const { method, params = {}, timeout = 15000, signal: external } = options
const maxRetries = Math.max(1, options.maxRetries ?? 3)
const { method, params = {}, timeout = 15000 } = options
const maxRetries = 3
for (let attempt = 0; attempt < maxRetries; attempt++) {
if (external?.aborted) throw new Error('Aborted')
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
const onExternalAbort = () => controller.abort()
external?.addEventListener('abort', onExternalAbort, { once: true })
try {
const headers: Record<string, string> = {
@ -132,7 +105,6 @@ class RPCClient {
})
clearTimeout(timeoutId)
external?.removeEventListener('abort', onExternalAbort)
if (!response.ok) {
// Session expired — debounced redirect to login
@ -195,11 +167,8 @@ class RPCClient {
return data.result as T
} catch (error) {
clearTimeout(timeoutId)
external?.removeEventListener('abort', onExternalAbort)
if (error instanceof Error) {
if (error.name === 'AbortError') {
// Caller-initiated abort is final — never retried.
if (external?.aborted) throw new Error('Aborted')
const timeoutErr = new Error('Request timeout')
if (attempt < maxRetries - 1) {
const delay = 600 * (attempt + 1)

View File

@ -1,107 +0,0 @@
// Stale-while-revalidate resource hook over the shared resources store.
//
// Usage:
// const files = useCachedResource<CloudFile[]>({
// key: 'cloud.my-files',
// fetcher: (signal) => rpcClient.call({ method: 'content.list', signal, dedup: true }),
// ttlMs: 30_000,
// })
// // template: files.data renders instantly on revisit (cache), while
// // files.loadState === 'refreshing' drives a subtle refresh indicator.
//
// Behavior:
// - Synchronous hydrate: memory (survives navigation) → sessionStorage
// snapshot (survives reload) → fetch.
// - Sticky-ready: never regresses ready → loading; refreshes are
// 'refreshing' so content stays on screen.
// - Stale-while-revalidate: on mount, cached data is shown immediately and a
// background refresh runs only if the TTL has lapsed (or never fetched).
// - Keep-last-value on error, with `isStale`/`ageMs` for badges.
// - revalidateOnFocus: refreshes when the tab regains focus and the data is
// stale (debounced by TTL, so focus-flapping is free).
// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when
// the last subscribed component unmounts.
import { computed, getCurrentScope, onScopeDispose } from 'vue'
import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
export interface CachedResourceOptions<T> {
/** Cache key. Include identifying params, e.g. `peer-files:${onion}`. */
key: string
/** Fetch fresh data. Receives an abort signal tied to component lifetime. */
fetcher: (signal: AbortSignal) => Promise<T>
/** Data older than this triggers a background revalidate (default 30s). */
ttlMs?: number
/** Snapshot to sessionStorage so reloads paint instantly (default true).
* Disable for large payloads. */
persist?: boolean
/** Revalidate (if stale) when the window regains focus (default true). */
revalidateOnFocus?: boolean
/** Fetch on first use (default true). Set false for lazy resources. */
immediate?: boolean
}
export interface CachedResource<T> {
entry: ResourceEntry<T>
/** Convenience computed views over the entry. */
data: ReturnType<typeof computed<T | null>>
loadState: ReturnType<typeof computed<ResourceLoadState>>
error: ReturnType<typeof computed<string | null>>
/** True when data exists but is older than the TTL (drive an age badge). */
isStale: ReturnType<typeof computed<boolean>>
ageMs: ReturnType<typeof computed<number | null>>
/** Force a refresh now (deduped with any in-flight one). */
refresh: () => Promise<void>
/** Mark stale + debounce-refresh all mounted users of this key. */
invalidate: () => void
/** Optimistically update cached data; returns rollback for RPC failure. */
optimistic: (update: (current: T | null) => T) => () => void
}
export function useCachedResource<T>(opts: CachedResourceOptions<T>): CachedResource<T> {
const store = useResourcesStore()
const ttlMs = opts.ttlMs ?? 30_000
const persist = opts.persist ?? true
const entry = store.entry<T>(opts.key, persist)
const aborter = new AbortController()
const fetcher = () => opts.fetcher(aborter.signal)
const refresh = () => store.refresh(opts.key, fetcher, { persist })
const stale = () => entry.fetchedAt === null || Date.now() - entry.fetchedAt > ttlMs
const refreshIfStale = () => {
if (stale()) void refresh()
}
// Register as a live revalidator so invalidate(key) reaches us.
const unsubscribe = store.subscribe(opts.key, () => void refresh())
const onFocus = () => refreshIfStale()
if (opts.revalidateOnFocus ?? true) {
window.addEventListener('focus', onFocus)
}
// Tied to the owning effect scope (component setup or manual scope);
// outside any scope (tests, module init) there's nothing to dispose.
if (getCurrentScope()) {
onScopeDispose(() => {
unsubscribe()
window.removeEventListener('focus', onFocus)
aborter.abort()
})
}
if (opts.immediate ?? true) refreshIfStale()
return {
entry,
data: computed(() => entry.data),
loadState: computed(() => entry.loadState),
error: computed(() => entry.error),
isStale: computed(() => entry.data !== null && stale()),
ageMs: computed(() => (entry.fetchedAt === null ? null : Date.now() - entry.fetchedAt)),
refresh,
invalidate: () => store.invalidate(opts.key),
optimistic: (update) => store.optimistic<T>(opts.key, update),
}
}

View File

@ -1,131 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useResourcesStore } from '../resources'
import { useCachedResource } from '@/composables/useCachedResource'
describe('resources store — stale-while-revalidate semantics', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('first fetch goes idle → loading → ready with data', async () => {
const store = useResourcesStore()
const e = store.entry<string>('k1')
expect(e.loadState).toBe('idle')
const p = store.refresh('k1', async () => 'hello')
expect(e.loadState).toBe('loading')
await p
expect(e.loadState).toBe('ready')
expect(e.data).toBe('hello')
expect(e.fetchedAt).not.toBeNull()
})
it('sticky-ready: refresh never regresses ready → loading', async () => {
const store = useResourcesStore()
await store.refresh('k2', async () => 1)
const e = store.entry<number>('k2')
const p = store.refresh('k2', async () => 2)
expect(e.loadState).toBe('refreshing')
await p
expect(e.loadState).toBe('ready')
expect(e.data).toBe(2)
})
it('keeps last-known data on refresh error (ready + error set)', async () => {
const store = useResourcesStore()
await store.refresh('k3', async () => 'good')
const e = store.entry<string>('k3')
await store.refresh('k3', async () => {
throw new Error('boom')
})
expect(e.data).toBe('good')
expect(e.loadState).toBe('ready')
expect(e.error).toBe('boom')
})
it('errors with no prior data land in error state', async () => {
const store = useResourcesStore()
await store.refresh('k4', async () => {
throw new Error('down')
})
const e = store.entry('k4')
expect(e.loadState).toBe('error')
expect(e.data).toBeNull()
})
it('dedups concurrent refreshes for the same key', async () => {
const store = useResourcesStore()
const fetcher = vi.fn(async () => 'once')
const p1 = store.refresh('k5', fetcher)
const p2 = store.refresh('k5', fetcher)
await Promise.all([p1, p2])
expect(fetcher).toHaveBeenCalledTimes(1)
})
it('hydrates a new entry from the sessionStorage snapshot', async () => {
const store = useResourcesStore()
await store.refresh('k6', async () => ({ n: 42 }))
// Fresh pinia = fresh memory cache, same sessionStorage.
setActivePinia(createPinia())
const store2 = useResourcesStore()
const e = store2.entry<{ n: number }>('k6')
expect(e.loadState).toBe('ready')
expect(e.data).toEqual({ n: 42 })
})
it('optimistic update applies immediately and rollback restores', async () => {
const store = useResourcesStore()
await store.refresh('k7', async () => ['a'])
const e = store.entry<string[]>('k7')
const rollback = store.optimistic<string[]>('k7', (cur) => [...(cur ?? []), 'b'])
expect(e.data).toEqual(['a', 'b'])
rollback()
expect(e.data).toEqual(['a'])
})
it('invalidate marks stale and debounce-runs subscribers', async () => {
const store = useResourcesStore()
await store.refresh('k8', async () => 1)
const revalidate = vi.fn()
store.subscribe('k8', revalidate)
store.invalidate('k8')
expect(store.entry('k8').fetchedAt).toBeNull()
expect(revalidate).not.toHaveBeenCalled()
vi.advanceTimersByTime(900)
expect(revalidate).toHaveBeenCalledTimes(1)
})
})
describe('useCachedResource composable', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
})
it('fetches immediately when stale and exposes reactive views', async () => {
const fetcher = vi.fn(async () => 'data')
const r = useCachedResource<string>({ key: 'c1', fetcher, revalidateOnFocus: false })
await r.refresh()
expect(fetcher).toHaveBeenCalled()
expect(r.data.value).toBe('data')
expect(r.loadState.value).toBe('ready')
expect(r.isStale.value).toBe(false)
})
it('does not refetch within TTL (instant render from cache)', async () => {
const fetcher = vi.fn(async () => 'v1')
const r1 = useCachedResource<string>({ key: 'c2', fetcher, ttlMs: 60_000, revalidateOnFocus: false })
await r1.refresh()
// Second component using the same key inside the TTL: no new fetch.
const fetcher2 = vi.fn(async () => 'v2')
const r2 = useCachedResource<string>({ key: 'c2', fetcher: fetcher2, ttlMs: 60_000, revalidateOnFocus: false })
expect(r2.data.value).toBe('v1')
expect(fetcher2).not.toHaveBeenCalled()
})
})

View File

@ -8,11 +8,6 @@ export const useCloudStore = defineStore('cloud', () => {
const loading = ref(false)
const error = ref<string | null>(null)
const authenticated = ref(false)
// Per-path listing cache: re-entering a folder paints the last listing
// immediately (no spinner) while the fresh listing loads behind it.
const pathCache = new Map<string, FileBrowserItem[]>()
// Last-wins guard for overlapping navigations (fast folder hopping).
let navSeq = 0
const breadcrumbs = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean)
@ -41,22 +36,7 @@ export const useCloudStore = defineStore('cloud', () => {
}
async function navigate(path: string): Promise<void> {
const seq = ++navSeq
const apply = (p: string, result: FileBrowserItem[]) => {
pathCache.set(p, result)
if (seq !== navSeq) return // a newer navigation superseded this one
items.value = result
currentPath.value = p
}
// Stale-while-revalidate: show the cached listing for this path
// immediately (no spinner), then refresh it underneath.
const cached = pathCache.get(path)
if (cached) {
items.value = cached
currentPath.value = path
} else {
loading.value = true
}
loading.value = true
error.value = null
try {
if (!authenticated.value) {
@ -67,7 +47,9 @@ export const useCloudStore = defineStore('cloud', () => {
}
}
try {
apply(path, await fileBrowserClient.listDirectory(path))
const result = await fileBrowserClient.listDirectory(path)
items.value = result
currentPath.value = path
} catch {
// Directory may not exist — try to create it, then retry
if (path !== '/') {
@ -75,20 +57,23 @@ export const useCloudStore = defineStore('cloud', () => {
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
const dirName = path.substring(path.lastIndexOf('/') + 1)
await fileBrowserClient.createFolder(parentPath, dirName)
apply(path, await fileBrowserClient.listDirectory(path))
const result = await fileBrowserClient.listDirectory(path)
items.value = result
currentPath.value = path
} catch {
// Fall back to root
apply('/', await fileBrowserClient.listDirectory('/'))
const result = await fileBrowserClient.listDirectory('/')
items.value = result
currentPath.value = '/'
}
} else {
throw new Error('Failed to list root directory')
}
}
} catch (e) {
// Keep showing the cached listing on a failed revalidate.
if (!cached) error.value = e instanceof Error ? e.message : 'Failed to load files'
error.value = e instanceof Error ? e.message : 'Failed to load files'
} finally {
if (seq === navSeq) loading.value = false
loading.value = false
}
}
@ -127,7 +112,6 @@ export const useCloudStore = defineStore('cloud', () => {
items.value = []
loading.value = false
error.value = null
pathCache.clear()
}
return {

View File

@ -1,166 +0,0 @@
// Shared cache for RPC-backed page data (the "stale-while-revalidate" layer).
//
// Pages used to fetch-on-mount with a spinner on every navigation — Dashboard
// keys its router-view by route.path, so each visit unmounted and refetched
// everything. This store is the single place resource state lives instead:
// keyed entries survive navigation (Pinia) and reloads (sessionStorage
// snapshot), and `useCachedResource` renders them instantly while
// revalidating in the background.
//
// Semantics (generalized from homeStatus.ts / useFleetData.ts, the proven
// hand-rolled versions):
// - sticky-ready: once a key is 'ready' it never regresses to 'loading';
// refreshes show as 'refreshing' so the UI keeps the data visible.
// - keep-last-known-value on error: a failed revalidate leaves data in place
// (with `error` set and `fetchedAt` untouched → age badge shows staleness).
// - in-flight dedup per key: concurrent refreshes collapse into one fetch.
import { defineStore } from 'pinia'
import { reactive } from 'vue'
export type ResourceLoadState = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error'
export interface ResourceEntry<T = unknown> {
data: T | null
loadState: ResourceLoadState
/** Epoch ms of the last SUCCESSFUL fetch (drives TTL + stale badges). */
fetchedAt: number | null
error: string | null
}
const SNAPSHOT_PREFIX = 'resource:'
function readSnapshot<T>(key: string): { data: T; fetchedAt: number } | null {
try {
const raw = sessionStorage.getItem(SNAPSHOT_PREFIX + key)
if (!raw) return null
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.fetchedAt === 'number' && 'data' in parsed) return parsed
} catch {
/* corrupt/absent snapshot — fall through to a fresh fetch */
}
return null
}
function writeSnapshot(key: string, data: unknown, fetchedAt: number): void {
try {
sessionStorage.setItem(SNAPSHOT_PREFIX + key, JSON.stringify({ data, fetchedAt }))
} catch {
/* quota exceeded or unserializable — memory cache still works */
}
}
export const useResourcesStore = defineStore('resources', () => {
const entries = reactive(new Map<string, ResourceEntry>())
// Non-reactive bookkeeping: in-flight fetches + active revalidators.
const inflight = new Map<string, Promise<void>>()
const revalidators = new Map<string, Set<() => void>>()
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>()
/** Get (or create) the reactive entry for a key, hydrating from the
* sessionStorage snapshot on first sight so revisits after a reload paint
* before any RPC completes. Pass `persist: false` to skip snapshots. */
function entry<T>(key: string, persist = true): ResourceEntry<T> {
let e = entries.get(key)
if (!e) {
const snap = persist ? readSnapshot<T>(key) : null
e = reactive<ResourceEntry>({
data: snap ? snap.data : null,
loadState: snap ? 'ready' : 'idle',
fetchedAt: snap ? snap.fetchedAt : null,
error: null,
})
entries.set(key, e)
}
return e as ResourceEntry<T>
}
/** Run `fetcher` for `key` with sticky-ready + keep-last-value semantics.
* Concurrent calls for the same key share one in-flight fetch. */
function refresh<T>(
key: string,
fetcher: () => Promise<T>,
opts: { persist?: boolean } = {},
): Promise<void> {
const existing = inflight.get(key)
if (existing) return existing
const e = entry<T>(key, opts.persist ?? true)
e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading'
const p = (async () => {
try {
const data = await fetcher()
e.data = data
e.error = null
e.fetchedAt = Date.now()
e.loadState = 'ready'
if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt)
} catch (err) {
e.error = err instanceof Error ? err.message : String(err)
// Keep last-known data visible; only 'error' when we have nothing.
e.loadState = e.data !== null ? 'ready' : 'error'
} finally {
inflight.delete(key)
}
})()
inflight.set(key, p)
return p
}
/** Mark a key stale and (debounced) re-run every mounted subscriber's
* fetcher. Call after a mutation or on a relevant WS push. */
function invalidate(key: string, opts: { debounceMs?: number } = {}): void {
const e = entries.get(key)
if (e) e.fetchedAt = null
const subs = revalidators.get(key)
if (!subs || subs.size === 0) return
const t = invalidateTimers.get(key)
if (t) clearTimeout(t)
invalidateTimers.set(
key,
setTimeout(() => {
invalidateTimers.delete(key)
for (const fn of subs) fn()
}, opts.debounceMs ?? 800),
)
}
/** Register a live revalidator for a key (used by useCachedResource);
* returns an unsubscribe fn. */
function subscribe(key: string, revalidate: () => void): () => void {
let subs = revalidators.get(key)
if (!subs) {
subs = new Set()
revalidators.set(key, subs)
}
subs.add(revalidate)
return () => {
subs.delete(revalidate)
}
}
/** Optimistically apply `update` to the cached value; returns a rollback.
* Pattern: rollback on RPC failure (generalized TransportPrefsCard). */
function optimistic<T>(key: string, update: (current: T | null) => T): () => void {
const e = entry<T>(key)
const before = e.data
const beforeState = e.loadState
e.data = update(before)
if (e.loadState === 'idle' || e.loadState === 'error') e.loadState = 'ready'
return () => {
e.data = before
e.loadState = beforeState
}
}
/** Drop a key entirely (memory + snapshot). */
function evict(key: string): void {
entries.delete(key)
try {
sessionStorage.removeItem(SNAPSHOT_PREFIX + key)
} catch {
/* noop */
}
}
return { entries, entry, refresh, invalidate, subscribe, optimistic, evict }
})

View File

@ -194,7 +194,7 @@
Open Federation
</RouterLink>
</div>
<div v-else-if="filteredPeerFiles.length === 0 && peerFilesPending === 0" class="glass-card p-8 text-center text-white/40 text-sm">
<div v-else-if="filteredPeerFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
</div>
<div v-else class="space-y-2">
@ -216,13 +216,6 @@
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span>
</button>
</div>
<p v-if="peerFilesPending > 0" class="text-[11px] text-white/35 text-center mt-3 flex items-center justify-center gap-2">
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Still fetching from {{ peerFilesPending }} peer{{ peerFilesPending === 1 ? '' : 's' }}
</p>
<p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3">
{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable showing what answered.
</p>
@ -308,23 +301,12 @@
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
{{ peer.trust_level }}
</span>
<!-- Live transport badge which route actually served the last
browse (FIPS = direct mesh, fast; Tor = fallback, slow). -->
<span
v-if="peerTransport(peer.onion)"
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
:class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300'"
:title="`Last browse served via ${peerTransport(peer.onion)!.transport.toUpperCase()}`"
>
<span class="w-1.5 h-1.5 rounded-full" :class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-400' : 'bg-amber-400'"></span>
{{ peerTransport(peer.onion)!.transport.toUpperCase() }} · {{ (peerTransport(peer.onion)!.latencyMs / 1000).toFixed(1) }}s
</span>
<span v-else class="text-white/30">Peer Node</span>
<span class="text-white/30">Peer Node</span>
</div>
</div>
<div
v-if="(peersLoading || peersRefreshing) && peerNodes.length > 0"
v-if="peersLoading && peerNodes.length > 0"
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
>
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
@ -406,8 +388,6 @@ import { computed, ref, watch, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud'
import { useResourcesStore } from '../stores/resources'
import { useCachedResource } from '../composables/useCachedResource'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
import { rpcClient } from '@/api/rpc-client'
import { getFileCategory } from '../composables/useFileType'
@ -419,19 +399,9 @@ import MediaLightbox from '../components/cloud/MediaLightbox.vue'
const router = useRouter()
const store = useAppStore()
const cloudStore = useCloudStore()
const resources = useResourcesStore()
const audioPlayer = useAudioPlayer()
// Section counts cached: revisits render the last-known counts instantly
// and refresh in the background (sticky-ready never regresses to "Loading").
const countsResource = useCachedResource<Record<string, number>>({
key: 'cloud.section-counts',
fetcher: fetchCounts,
ttlMs: 30_000,
immediate: false, // gated on fileBrowserRunning; kicked from onMounted/watch
})
const sectionCounts = computed(() => countsResource.entry.data ?? {})
const countsLoading = computed(() => countsResource.entry.loadState === 'loading')
const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false)
// Tabs / categories / search state
type TabId = 'folders' | 'mine' | 'peers' | 'paid'
@ -454,18 +424,14 @@ const activeTab = ref<TabId>('folders')
// Paid Files tab
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
const paidResource = useCachedResource<PaidItem[]>({
key: 'cloud.paid-items',
fetcher: async (signal) => {
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list', signal, dedup: true })
return (res.items || []).slice().reverse()
},
immediate: false, // loaded when the Paid tab is opened
})
const paidItems = computed(() => paidResource.entry.data ?? [])
const paidLoading = computed(() => paidResource.entry.loadState === 'loading')
function loadPaidItems() {
return paidResource.refresh()
const paidItems = ref<PaidItem[]>([])
const paidLoading = ref(false)
async function loadPaidItems() {
paidLoading.value = true
try {
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' })
paidItems.value = (res.items || []).slice().reverse()
} catch { paidItems.value = [] } finally { paidLoading.value = false }
}
async function viewPaidItem(it: PaidItem) {
try {
@ -507,21 +473,8 @@ interface PeerNode {
trust_level: string
}
// Federation peers cached so the Folders tab's peer cards paint instantly
// on revisit while the list revalidates behind them.
const peersResource = useCachedResource<PeerNode[]>({
key: 'cloud.peer-nodes',
fetcher: async (signal) => {
const result = await rpcClient.federationListNodes()
void signal
return result?.nodes ?? []
},
ttlMs: 30_000,
immediate: false, // kicked from onMounted (keeps the legacy load order)
})
const peerNodes = computed(() => peersResource.entry.data ?? [])
const peersLoading = computed(() => peersResource.entry.loadState === 'loading')
const peersRefreshing = computed(() => peersResource.entry.loadState === 'refreshing')
const peerNodes = ref<PeerNode[]>([])
const peersLoading = ref(true)
const loadError = ref('')
const APP_ALIASES: Record<string, string[]> = {
@ -626,51 +579,42 @@ function formatSize(bytes: number): string {
}
// My Files (flat list of every own file across the sections)
// Cached: revisiting the tab renders the last walk instantly and re-walks in
// the background only when stale.
const myFilesResource = useCachedResource<FileBrowserItem[]>({
key: 'cloud.my-files',
fetcher: fetchMyFiles,
ttlMs: 60_000,
immediate: false, // loaded when the My Files tab (or search) needs it
})
const myFiles = computed(() => myFilesResource.entry.data ?? [])
const myFilesLoading = computed(() => myFilesResource.entry.loadState === 'loading')
const myFiles = ref<FileBrowserItem[]>([])
const myFilesLoading = ref(false)
const myFilesLoaded = ref(false)
/** Depth-limited walk of the section folders; flat file list, capped. */
async function fetchMyFiles(): Promise<FileBrowserItem[]> {
if (!fileBrowserRunning.value) return []
const ok = await cloudStore.init()
if (!ok) return []
const out: FileBrowserItem[] = []
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
if (sectionId === 'files') continue // '/' would double-visit the sections
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
while (queue.length > 0 && out.length < 500) {
const { path, depth } = queue.shift()!
let items: FileBrowserItem[]
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
for (const item of items) {
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
if (item.isDir) {
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
} else {
out.push({ ...item, path: itemPath })
async function loadMyFiles(force = false) {
if (myFilesLoading.value || (myFilesLoaded.value && !force)) return
if (!fileBrowserRunning.value) { myFilesLoaded.value = true; return }
myFilesLoading.value = true
try {
const ok = await cloudStore.init()
if (!ok) return
const out: FileBrowserItem[] = []
for (const [sectionId, root] of Object.entries(SECTION_PATHS)) {
if (sectionId === 'files') continue // '/' would double-visit the sections
const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }]
while (queue.length > 0 && out.length < 500) {
const { path, depth } = queue.shift()!
let items: FileBrowserItem[]
try { items = await fileBrowserClient.listDirectory(path) } catch { continue }
for (const item of items) {
const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}`
if (item.isDir) {
if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 })
} else {
out.push({ ...item, path: itemPath })
}
}
}
}
out.sort((a, b) => a.name.localeCompare(b.name))
myFiles.value = out
myFilesLoaded.value = true
} finally {
myFilesLoading.value = false
}
out.sort((a, b) => a.name.localeCompare(b.name))
return out
}
/** Load if never fetched or stale; `force` always re-walks. */
function loadMyFiles(force = false): Promise<void> {
if (force) return myFilesResource.refresh()
if (myFilesResource.entry.data === null || myFilesResource.isStale.value) {
return myFilesResource.refresh()
}
return Promise.resolve()
}
const filteredMyFiles = computed(() =>
@ -724,8 +668,7 @@ function handlePreview(path: string, context: FileBrowserItem[]) {
async function handleDelete(path: string) {
try {
await cloudStore.deleteItem(path)
// Delete confirmed update the cache in place (no rollback needed).
myFilesResource.optimistic((cur) => (cur ?? []).filter(f => f.path !== path))
myFiles.value = myFiles.value.filter(f => f.path !== path)
searchResults.value = searchResults.value.filter(r => r.item?.path !== path)
} catch (e) {
loadError.value = e instanceof Error ? e.message : 'Delete failed'
@ -743,6 +686,11 @@ interface PeerFileEntry {
peerOnion: string
}
const peerFiles = ref<PeerFileEntry[]>([])
const peerFilesLoading = ref(false)
const peerFilesLoaded = ref(false)
const peerFilesErrors = ref(0)
interface CatalogItem {
id: string
filename: string
@ -756,96 +704,46 @@ function priceOf(access: CatalogItem['access']): number {
return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0
}
// Per-peer browse results live as individual cached entries so (a) each
// peer's card/rows render the moment THAT peer answers no more blocking on
// the slowest peer via Promise.allSettled and (b) revisits paint from
// cache. The response's `transport` (fips/tor) + measured latency ride
// along, giving every peer a live transport badge.
interface PeerBrowse {
items: CatalogItem[]
transport: string | null
latencyMs: number
}
const peerBrowseKey = (onion: string) => `cloud.peer-browse:${onion}`
function browsePeer(peer: PeerNode): Promise<void> {
return resources.refresh<PeerBrowse>(peerBrowseKey(peer.onion), async () => {
const t0 = Date.now()
const res = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
method: 'content.browse-peer',
params: { onion: peer.onion },
timeout: 30000,
// One slow/unreachable peer must cost its timeout ONCE, not ×3
// the retry loop is why one dead peer meant a 90s spinner.
maxRetries: 1,
dedup: true,
})
return { items: res?.items ?? [], transport: res?.transport ?? null, latencyMs: Date.now() - t0 }
})
}
function peerBrowseEntry(onion: string) {
return resources.entry<PeerBrowse>(peerBrowseKey(onion))
}
/** Transport badge data for a peer (null until its first browse resolves). */
function peerTransport(onion: string): { transport: string; latencyMs: number } | null {
const e = peerBrowseEntry(onion)
if (!e.data?.transport) return null
return { transport: e.data.transport, latencyMs: e.data.latencyMs }
}
/** Aggregated peer files, incrementally updated as each peer resolves. */
const peerFiles = computed<PeerFileEntry[]>(() => {
const merged: PeerFileEntry[] = []
for (const peer of peerNodes.value) {
const e = peerBrowseEntry(peer.onion)
if (!e.data) continue
const peerName = peer.name || peerDisplayName(peer.did)
for (const item of e.data.items) {
merged.push({
key: `${peer.onion}:${item.id}`,
filename: item.filename,
sizeBytes: item.size_bytes,
priceSats: priceOf(item.access),
category: categoryOf(item.mime_type || item.filename),
peerName,
peerOnion: peer.onion,
})
}
}
merged.sort((a, b) => a.filename.localeCompare(b.filename))
return merged
})
/** Peers still on their first in-flight browse (nothing cached yet). */
const peerFilesPending = computed(() =>
peerNodes.value.filter(p => {
const s = peerBrowseEntry(p.onion).loadState
return s === 'loading' || s === 'idle'
}).length,
)
/** Peers whose browse failed with no cached data to show. */
const peerFilesErrors = computed(() =>
peerNodes.value.filter(p => peerBrowseEntry(p.onion).loadState === 'error').length,
)
/** All-or-nothing spinner ONLY when nothing has ever been cached. */
const peerFilesLoading = computed(() =>
peerNodes.value.length > 0 && peerFiles.value.length === 0 && peerFilesPending.value > 0 && peerFilesErrors.value < peerNodes.value.length,
)
/** Fan out content.browse-peer; each peer renders as it resolves. */
/** Fan out content.browse-peer over every federation node; tolerate stragglers. */
async function loadPeerFiles(force = false) {
if (peerNodes.value.length === 0) await loadPeers()
const targets = peerNodes.value.filter(p => {
if (force) return true
const e = peerBrowseEntry(p.onion)
const stale = e.fetchedAt === null || Date.now() - e.fetchedAt > 30_000
return e.loadState === 'idle' || e.loadState === 'error' ? true : stale
})
// Fire-and-collect: the computed aggregation updates per resolution.
await Promise.allSettled(targets.map(p => browsePeer(p)))
if (peerFilesLoading.value || (peerFilesLoaded.value && !force)) return
peerFilesLoading.value = true
peerFilesErrors.value = 0
try {
if (peerNodes.value.length === 0) await loadPeers()
const results = await Promise.allSettled(
peerNodes.value.map(async (peer) => {
const res = await rpcClient.call<{ items?: CatalogItem[] }>({
method: 'content.browse-peer',
params: { onion: peer.onion },
timeout: 30000,
})
return { peer, items: res?.items ?? [] }
}),
)
const merged: PeerFileEntry[] = []
for (const r of results) {
if (r.status !== 'fulfilled') { peerFilesErrors.value++; continue }
const { peer, items } = r.value
const peerName = peer.name || peerDisplayName(peer.did)
for (const item of items) {
merged.push({
key: `${peer.onion}:${item.id}`,
filename: item.filename,
sizeBytes: item.size_bytes,
priceSats: priceOf(item.access),
category: categoryOf(item.mime_type || item.filename),
peerName,
peerOnion: peer.onion,
})
}
}
merged.sort((a, b) => a.filename.localeCompare(b.filename))
peerFiles.value = merged
peerFilesLoaded.value = true
} finally {
peerFilesLoading.value = false
}
}
const filteredPeerFiles = computed(() =>
@ -923,50 +821,47 @@ const searchMineItems = computed(() =>
)
// Existing counts / peers loading
async function fetchCounts(): Promise<Record<string, number>> {
if (!fileBrowserRunning.value) return {}
const ok = await fileBrowserClient.login()
if (!ok) throw new Error('File Browser login failed')
const counts: Record<string, number> = {}
for (const section of contentSections) {
const path = SECTION_PATHS[section.id]
if (!path) continue
try {
counts[section.id] = (await fileBrowserClient.listDirectory(path)).length
} catch {
counts[section.id] = 0
async function loadCounts() {
if (!fileBrowserRunning.value) return
countsLoading.value = true
try {
const ok = await fileBrowserClient.login()
if (!ok) return
for (const section of contentSections) {
const path = SECTION_PATHS[section.id]
if (!path) continue
try {
const items = await fileBrowserClient.listDirectory(path)
sectionCounts.value[section.id] = items.length
} catch {
sectionCounts.value[section.id] = 0
}
}
}
return counts
}
function loadCounts() {
if (countsResource.entry.data === null || countsResource.isStale.value) {
void countsResource.refresh()
} catch (e) {
loadError.value = e instanceof Error ? e.message : 'Failed to load file counts'
if (import.meta.env.DEV) console.warn('FileBrowser count loading failed', e)
} finally {
countsLoading.value = false
}
}
onMounted(async () => {
onMounted(() => {
loadCounts()
await loadPeers()
// Warm the per-peer browse cache in the background: peer cards get their
// FIPS/Tor badge and the Peer Files tab is instant. Staleness-gated, so
// quick revisits don't refetch.
void loadPeerFiles()
})
// File Browser can finish its startup scan after we mount pick counts up
// the moment it becomes available instead of showing a permanent blank.
watch(fileBrowserRunning, (running) => {
if (running) loadCounts()
loadPeers()
})
async function loadPeers() {
await peersResource.refresh()
// Surface refresh failures in the banner the cached peer list stays
// visible either way (keep-last-known-value).
const e = peersResource.entry
if (e.error) loadError.value = e.error
const hadPeers = peerNodes.value.length > 0
peersLoading.value = true
try {
const result = await rpcClient.federationListNodes()
peerNodes.value = result?.nodes ?? []
} catch (e) {
if (!hadPeers) peerNodes.value = []
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
} finally {
peersLoading.value = false
}
}
function peerDisplayName(did: string): string {

View File

@ -306,12 +306,12 @@ const backLabel = computed(() => {
return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder'
})
// Initialize native file browser when entering a native-UI section.
// No reset() here: navigate() serves the per-path cache instantly and
// revalidates underneath resetting wiped the listing and forced a
// spinner on every folder entry.
// Initialize native file browser when entering a native-UI section
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
if (native && sec) {
if (cloudStore.currentPath !== path) {
cloudStore.reset()
}
const ok = await cloudStore.init()
if (ok) {
await cloudStore.navigate(path)

View File

@ -113,7 +113,7 @@
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
<div v-if="status.key_present && !status.service_active" class="flex gap-2 mt-auto pt-3 shrink-0">
<button class="flex-1 min-h-[44px] px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" :disabled="installing" @click="installAndActivate">{{ installing ? 'Starting' : 'Start' }}</button>
<button class="flex-1 min-h-[44px] px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" :disabled="installing" @click="installAndActivate">{{ installing ? 'Installing' : 'Activate' }}</button>
</div>
</div>
</template>
@ -211,10 +211,10 @@ async function installAndActivate() {
installing.value = true
try {
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
flash('FIPS started')
flash('FIPS installed and activated')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Start failed: ${msg}`, true)
flash(`Install failed: ${msg}`, true)
} finally {
installing.value = false
}

View File

@ -362,20 +362,6 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.117-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.117-alpha</span>
<span class="text-xs text-white/40">July 27, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>FIPS startup is more reliable on nodes that have the packaged fips.service instead of Archipelago's archipelago-fips.service. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started.</p>
<p>App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports.</p>
<p>Companion WebView safe-area handling now also moves fixed and sticky top bars below the phone status bar, including headers mounted after page load by single-page apps.</p>
<p>Public-source preparation now includes a Nostr Git hosting plan using ngit, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core.</p>
<p>Node OS release notes for v1.7.117 are still open; add any installer, service, kernel, firewall, or package changes here before cutting the release.</p>
</div>
</div>
<!-- v1.7.116-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">

View File

@ -25,7 +25,7 @@ PATTERNS=(
)
# Allowed files (config templates, docs, test fixtures)
ALLOW_PATTERNS="test|e2e|mock|demo|example|Example|template|CLAUDE.md|deploy-config|\.md$|node_modules|dist|target|default\)|grep.*rpc|audit-secrets|startsWith|should start with"
ALLOW_PATTERNS="test|mock|example|template|CLAUDE.md|deploy-config|\.md$|node_modules|dist|target|default\)|grep.*rpc|audit-secrets"
main() {
log "=== Secrets Audit ==="
@ -34,7 +34,7 @@ main() {
# 1. Check for .env files in version control
log "1. Checking for .env files in git..."
local env_files
env_files=$(cd "$REPO_ROOT" && git ls-files | grep -E '(^|/)\.env($|[.])|(^|/)[^/]*\.env($|[.])' | grep -vE '(^|/)\.env\.example$|(^|/)[^/]*\.env\.example$' || echo "")
env_files=$(cd "$REPO_ROOT" && git ls-files '*.env' '.env*' 2>/dev/null || echo "")
if [ -z "$env_files" ]; then
pass "No .env files tracked in git"
else
@ -69,7 +69,7 @@ main() {
if [ -n "$matches" ]; then
# Filter out false positives (empty strings, variable declarations, etc.)
local real_matches
real_matches=$(echo "$matches" | grep -vE '""|\x27\x27|None|null|undefined|TODO|placeholder|example|Option<|\$\{[A-Z0-9_]+:-\}|\$[A-Z0-9_]+|TestPassword|password123|entertoexit' || echo "")
real_matches=$(echo "$matches" | grep -vE '""|\x27\x27|None|null|undefined|TODO|placeholder|example|Option<' || echo "")
if [ -n "$real_matches" ]; then
echo " WARNING: Pattern '$pattern' found:"
echo "$real_matches" | head -5 | sed 's/^/ /'
@ -96,7 +96,7 @@ main() {
# 5. Check for credential files in repo
log "5. Checking for credential files..."
local cred_files
cred_files=$(cd "$REPO_ROOT" && git ls-files | grep -Ei '(\.pem$|\.key$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_ed25519|macaroon)' | grep -vE '\.(rs|ts)$' || echo "")
cred_files=$(cd "$REPO_ROOT" && git ls-files '*.pem' '*.key' '*macaroon*' 2>/dev/null | grep -v '\.rs$' | grep -v '\.ts$' || echo "")
if [ -z "$cred_files" ]; then
pass "No credential files tracked in git"
else

View File

@ -1,15 +1,12 @@
#!/usr/bin/env bash
#
# Run Archipelago tests.
#
# By default this runs frontend tests and local backend Rust tests. Set
# ARCHIPELAGO_SSH_HOST and ARCHIPELAGO_SSH_KEY to run backend tests on a Linux
# target instead.
# Run all Archipelago tests: frontend (local) + backend (dev server via SSH).
# Exit 0 only if both pass.
#
set -euo pipefail
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-}"
SSH_HOST="${ARCHIPELAGO_SSH_HOST:-}"
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
SSH_HOST="${ARCHIPELAGO_SSH_HOST:-archipelago@192.168.1.228}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
@ -32,29 +29,34 @@ fi
echo ""
# --- Backend Tests ---
if [[ -n "$SSH_HOST" && -n "$SSH_KEY" ]]; then
echo "--- Backend Tests (Linux target: $SSH_HOST) ---"
echo "Syncing source to target..."
rsync -az --exclude 'target' --exclude 'node_modules' --exclude '.git' \
-e "ssh -i $SSH_KEY" \
"$PROJECT_DIR/core/" "$SSH_HOST:~/archy/core/" 2>&1
# --- Backend Tests (on dev server) ---
echo "--- Backend Tests (dev server) ---"
if ssh -i "$SSH_KEY" "$SSH_HOST" \
"source ~/.cargo/env && cd ~/archy/core && cargo test --all-features 2>&1"; then
echo "✅ Backend tests PASSED"
BACKEND_OK=1
else
echo "❌ Backend tests FAILED"
fi
# Sync source to server
echo "Syncing source to dev server..."
rsync -az --exclude 'target' --exclude 'node_modules' --exclude '.git' \
-e "ssh -i $SSH_KEY" \
"$PROJECT_DIR/core/" "$SSH_HOST:~/archy/core/" 2>&1
# Run tests on server
if ssh -i "$SSH_KEY" "$SSH_HOST" \
"source ~/.cargo/env && cd ~/archy/core && cargo test -p archipelago 2>&1"; then
echo "✅ Backend unit tests PASSED"
BACKEND_OK=1
else
echo "--- Backend Tests (local) ---"
if (cd "$PROJECT_DIR/core" && cargo test --all-features 2>&1); then
echo "✅ Backend tests PASSED"
BACKEND_OK=1
else
echo "❌ Backend tests FAILED"
fi
echo "❌ Backend unit tests FAILED"
fi
echo ""
# --- Integration Tests ---
echo "--- Integration Tests (dev server) ---"
if ssh -i "$SSH_KEY" "$SSH_HOST" \
"source ~/.cargo/env && cd ~/archy/core && cargo test --test rpc_integration 2>&1"; then
echo "✅ Integration tests PASSED"
else
echo "❌ Integration tests FAILED"
BACKEND_OK=0
fi
echo ""

View File

@ -24,9 +24,8 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY"
# Anthropic API key used by the AIUI Claude chat proxy. Keep this in the
# caller's environment or scripts/deploy-config.sh; never commit live keys.
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
# Anthropic API key — used by all servers for AIUI Claude chat
ANTHROPIC_API_KEY="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"
TARGET_HOST="$1"
if [ -z "$TARGET_HOST" ]; then
@ -35,12 +34,6 @@ if [ -z "$TARGET_HOST" ]; then
exit 1
fi
if [ -z "$ANTHROPIC_API_KEY" ]; then
echo "ERROR: ANTHROPIC_API_KEY must be set in the environment."
echo "Example: ANTHROPIC_API_KEY=<key> $0 $TARGET_HOST"
exit 1
fi
AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"
if [ ! -f "$AIUI_DIST/index.html" ]; then
echo "ERROR: AIUI build not found at $AIUI_DIST"

View File

@ -1,26 +1,25 @@
#!/usr/bin/env bash
#
# validate-app-manifest.sh - validate an Archipelago app manifest.
# validate-app-manifest.sh — Validate a community-submitted app manifest
#
# Usage:
# ./scripts/validate-app-manifest.sh [--repo-audit] apps/my-app/manifest.yml
# Usage: ./scripts/validate-app-manifest.sh <manifest.yml>
#
# This intentionally mirrors the public app contract documented in
# docs/app-manifest-spec.md: manifests have a top-level `app:` block and are
# ultimately validated by the Rust parser in core/container/src/manifest.rs.
# This script is the contributor-friendly preflight; the Rust parser remains
# canonical.
# Checks:
# 1. Valid YAML syntax
# 2. Required fields present (id, title, version, image, description)
# 3. Image from trusted registry (docker.io, ghcr.io, quay.io)
# 4. No :latest tag (must pin specific version)
# 5. Resource limits specified (memory, cpu)
# 6. Security: no privileged mode, no host networking
# 7. No hardcoded secrets/passwords in environment
# 8. Port conflicts with existing apps
#
# Exit 0 = valid, Exit 1 = issues found
set -euo pipefail
REPO_AUDIT=0
if [[ "${1:-}" == "--repo-audit" ]]; then
REPO_AUDIT=1
shift
fi
if [[ $# -ne 1 ]]; then
echo "Usage: $0 [--repo-audit] <manifest.yml>"
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <manifest.yml>"
exit 1
fi
@ -31,235 +30,138 @@ WARN=0
check() {
local desc="$1" result="$2"
case "$result" in
pass)
PASS=$((PASS + 1))
echo " PASS: $desc"
;;
warn)
WARN=$((WARN + 1))
echo " WARN: $desc"
;;
*)
FAIL=$((FAIL + 1))
echo " FAIL: $desc"
;;
esac
}
yaml_eval() {
ruby -ryaml -e '
path, expr = ARGV
data = YAML.load_file(path)
app = data.is_a?(Hash) ? data["app"] : nil
abort "missing top-level app block" unless app.is_a?(Hash)
value = eval(expr)
case value
when Array
puts value.join("\n")
when Hash
puts value.to_a.map { |k, v| "#{k}=#{v}" }.join("\n")
when NilClass
puts ""
else
puts value
end
' "$MANIFEST" "$1"
if [[ "$result" == "pass" ]]; then
PASS=$((PASS + 1))
echo " PASS: $desc"
elif [[ "$result" == "warn" ]]; then
WARN=$((WARN + 1))
echo " WARN: $desc"
else
FAIL=$((FAIL + 1))
echo " FAIL: $desc"
fi
}
echo "Validating: $MANIFEST"
echo ""
# 1. File exists and is readable
if [[ ! -f "$MANIFEST" ]]; then
echo " FAIL: File not found: $MANIFEST"
exit 1
fi
check "File exists" "pass"
if ! ruby -ryaml -e 'data = YAML.load_file(ARGV[0]); exit(data.is_a?(Hash) && data["app"].is_a?(Hash) ? 0 : 1)' "$MANIFEST" 2>/dev/null; then
check "Valid YAML with top-level app block" "fail"
echo ""
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
echo "STATUS: REJECTED - fix failures before resubmitting"
# 2. Valid YAML
if ! python3 -c "import yaml; yaml.safe_load(open('$MANIFEST'))" 2>/dev/null; then
check "Valid YAML syntax" "fail"
echo " Cannot continue with invalid YAML"
exit 1
fi
check "Valid YAML with top-level app block" "pass"
check "Valid YAML syntax" "pass"
APP_ID="$(yaml_eval 'app["id"]')"
APP_NAME="$(yaml_eval 'app["name"]')"
APP_VERSION="$(yaml_eval 'app["version"]')"
APP_DESCRIPTION="$(yaml_eval 'app["description"]')"
APP_INTERNAL="$(yaml_eval 'app["internal"]')"
IMAGE="$(yaml_eval '(app["container"] || {})["image"]')"
BUILD_CONTEXT="$(yaml_eval '(((app["container"] || {})["build"] || {})["context"])')"
BUILD_TAG="$(yaml_eval '(((app["container"] || {})["build"] || {})["tag"])')"
# 3. Required fields
CONTENT=$(python3 -c "
import yaml, json
with open('$MANIFEST') as f:
d = yaml.safe_load(f)
print(json.dumps(d))
" 2>/dev/null)
if [[ "$APP_ID" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
check "app.id is lowercase kebab-case ($APP_ID)" "pass"
else
check "app.id is lowercase kebab-case" "fail"
fi
if [[ -n "$APP_NAME" ]]; then
check "app.name present" "pass"
else
check "app.name present" "fail"
fi
if [[ "$APP_VERSION" =~ [0-9] ]]; then
check "app.version present and contains a digit" "pass"
else
check "app.version present and contains a digit" "fail"
fi
if [[ -n "$APP_DESCRIPTION" ]]; then
check "app.description present" "pass"
else
check "app.description present" "warn"
fi
HAS_IMAGE=0
HAS_BUILD=0
[[ -n "$IMAGE" ]] && HAS_IMAGE=1
[[ -n "$BUILD_CONTEXT" || -n "$BUILD_TAG" ]] && HAS_BUILD=1
if [[ "$HAS_IMAGE" -eq 1 && "$HAS_BUILD" -eq 0 ]]; then
check "container.image specified" "pass"
elif [[ "$HAS_IMAGE" -eq 0 && "$HAS_BUILD" -eq 1 ]]; then
if [[ -n "$BUILD_CONTEXT" && -n "$BUILD_TAG" ]]; then
check "container.build specified with context and tag" "pass"
for field in id title version description; do
val=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$field',''))" 2>/dev/null)
if [[ -n "$val" && "$val" != "None" ]]; then
check "Required field '$field' present" "pass"
else
check "container.build requires context and tag" "fail"
check "Required field '$field' present" "fail"
fi
else
check "exactly one of container.image or container.build specified" "fail"
fi
done
if [[ -n "$IMAGE" ]]; then
# 4. Image reference
IMAGE=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('image','') or d.get('docker_image','') or '')" 2>/dev/null)
if [[ -z "$IMAGE" || "$IMAGE" == "None" ]]; then
check "Container image specified" "fail"
else
check "Container image specified" "pass"
# Check trusted registry
TRUSTED=false
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000" "localhost/"; do
if [[ "$IMAGE" == *"$reg"* ]]; then
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000"; do
if echo "$IMAGE" | grep -q "$reg"; then
TRUSTED=true
break
fi
done
if [[ "$TRUSTED" == "true" || "$IMAGE" != */* ]]; then
check "image registry is recognized" "pass"
# Also allow short-form Docker Hub images (no registry prefix)
if ! echo "$IMAGE" | grep -q "/"; then
TRUSTED=true # single-name images are Docker Hub official
fi
if [[ "$TRUSTED" == "true" ]]; then
check "Image from trusted registry" "pass"
else
check "image registry is not in the reviewed list ($IMAGE)" "warn"
check "Image from trusted registry ($IMAGE)" "warn"
fi
if [[ "$IMAGE" == *":latest" ]]; then
if [[ "$APP_INTERNAL" == "true" || "$IMAGE" == localhost/* ]]; then
check "internal/local build uses :latest ($IMAGE)" "warn"
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
check "existing manifest uses :latest and must be pinned before public app submission ($IMAGE)" "warn"
else
check "image tag is pinned and not :latest ($IMAGE)" "fail"
fi
elif [[ "$IMAGE" != *:* ]]; then
check "image tag is explicit ($IMAGE)" "warn"
# Check no :latest
if echo "$IMAGE" | grep -q ":latest$"; then
check "No :latest tag (pin specific version)" "fail"
elif ! echo "$IMAGE" | grep -q ":"; then
check "No version tag specified (should pin version)" "warn"
else
check "image tag is pinned" "pass"
check "Version tag pinned" "pass"
fi
fi
MEMORY_LIMIT="$(yaml_eval '((app["resources"] || {})["memory_limit"] || (app["resources"] || {})["memory"])')"
CPU_LIMIT="$(yaml_eval '((app["resources"] || {})["cpu_limit"] || (app["resources"] || {})["cpu"])')"
[[ -n "$MEMORY_LIMIT" ]] && check "resources.memory_limit specified ($MEMORY_LIMIT)" "pass" || check "resources.memory_limit specified" "warn"
[[ -n "$CPU_LIMIT" ]] && check "resources.cpu_limit specified ($CPU_LIMIT)" "pass" || check "resources.cpu_limit specified" "warn"
READONLY_ROOT="$(yaml_eval '((app["security"] || {})["readonly_root"])')"
NO_NEW_PRIVS="$(yaml_eval '((app["security"] || {})["no_new_privileges"])')"
NETWORK_POLICY="$(yaml_eval '((app["security"] || {})["network_policy"])')"
CONTAINER_NETWORK="$(yaml_eval '((app["container"] || {})["network"])')"
if [[ "$READONLY_ROOT" == "true" || -z "$READONLY_ROOT" ]]; then
check "security.readonly_root true (explicit or Rust default)" "pass"
# 5. Security checks
PRIVILEGED=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('privileged', False))" 2>/dev/null)
if [[ "$PRIVILEGED" == "True" ]]; then
check "No privileged mode" "fail"
else
check "security.readonly_root true or explicitly justified" "warn"
check "No privileged mode" "pass"
fi
if [[ "$NO_NEW_PRIVS" == "true" || -z "$NO_NEW_PRIVS" ]]; then
check "security.no_new_privileges true (explicit or Rust default)" "pass"
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
check "existing manifest disables security.no_new_privileges and needs review" "warn"
HOST_NET=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('host_network', d.get('network_mode','')))" 2>/dev/null)
if [[ "$HOST_NET" == "host" ]]; then
check "No host networking" "fail"
else
check "security.no_new_privileges true" "fail"
check "No host networking" "pass"
fi
if [[ "$NETWORK_POLICY" == "isolated" || "$NETWORK_POLICY" == "bridge" || "$NETWORK_POLICY" == "host" || -z "$NETWORK_POLICY" ]]; then
check "security.network_policy valid" "pass"
# 6. Check for hardcoded secrets in env vars
ENV_VARS=$(echo "$CONTENT" | python3 -c "
import sys,json
d=json.load(sys.stdin)
env = d.get('environment', d.get('env', {}))
if isinstance(env, dict):
for k,v in env.items():
print(f'{k}={v}')
elif isinstance(env, list):
for e in env:
print(e)
" 2>/dev/null || echo "")
SECRET_PATTERNS="password|secret|api_key|private_key|token"
if echo "$ENV_VARS" | grep -iqE "$SECRET_PATTERNS"; then
check "No hardcoded secrets in environment" "warn"
else
check "security.network_policy valid" "fail"
check "No hardcoded secrets in environment" "pass"
fi
if [[ "$CONTAINER_NETWORK" == container:* || "$CONTAINER_NETWORK" == ns:* ]]; then
check "container.network does not share another namespace" "fail"
# 7. Memory limit
MEM=$(echo "$CONTENT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('memory', d.get('mem_limit', d.get('resources',{}).get('memory',''))))" 2>/dev/null)
if [[ -n "$MEM" && "$MEM" != "None" && "$MEM" != "" ]]; then
check "Memory limit specified ($MEM)" "pass"
else
check "container.network does not share another namespace" "pass"
fi
SECRET_ENV="$(yaml_eval '(app["environment"] || [])')"
if echo "$SECRET_ENV" | grep -iqE '^[A-Z0-9_]*(PASSWORD|PASS|SECRET|TOKEN|API_KEY|PRIVATE_KEY)[A-Z0-9_]*=.+$'; then
check "no hardcoded secret-like values in app.environment" "warn"
else
check "no hardcoded secret-like values in app.environment" "pass"
fi
if [[ -n "$APP_ID" && -n "$MANIFEST" ]]; then
EXPECTED_DIR="$(basename "$(dirname "$MANIFEST")")"
if [[ "$EXPECTED_DIR" == "$APP_ID" ]]; then
check "app.id matches directory name" "pass"
elif [[ "$REPO_AUDIT" -eq 1 ]]; then
check "existing manifest app.id differs from directory name ($EXPECTED_DIR)" "warn"
else
check "app.id matches directory name ($EXPECTED_DIR)" "fail"
fi
fi
PORT_CHECK="$(ruby -ryaml -e '
current = ARGV[0]
current_id = File.basename(File.dirname(current))
ports = {}
Dir.glob("apps/*/manifest.yml").sort.each do |path|
data = YAML.load_file(path)
app = data.is_a?(Hash) ? data["app"] : nil
next unless app.is_a?(Hash)
id = app["id"] || File.basename(File.dirname(path))
next if id == current_id
Array(app["ports"]).each do |p|
next unless p.is_a?(Hash)
proto = p["protocol"] || "tcp"
bind = p["bind"] || ""
host = p["host"]
ports[[host, proto, bind]] = id if host
end
end
data = YAML.load_file(current)
app = data["app"]
conflicts = []
Array(app["ports"]).each do |p|
next unless p.is_a?(Hash)
key = [p["host"], p["protocol"] || "tcp", p["bind"] || ""]
conflicts << "#{key[2].empty? ? "*" : key[2]}:#{key[0]}/#{key[1]} already used by #{ports[key]}" if ports.key?(key)
end
puts conflicts.join("\n")
' "$MANIFEST")"
if [[ -n "$PORT_CHECK" ]]; then
while IFS= read -r conflict; do
check "port conflict: $conflict" "warn"
done <<< "$PORT_CHECK"
else
check "no duplicate host port bindings" "pass"
check "Memory limit specified" "warn"
fi
echo ""
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
if [[ "$FAIL" -gt 0 ]]; then
echo "STATUS: REJECTED - fix failures before resubmitting"
echo "STATUS: REJECTED — fix failures before resubmitting"
exit 1
else
echo "STATUS: APPROVED (with $WARN warnings)"
exit 0
fi
echo "STATUS: APPROVED (with $WARN warnings)"