Compare commits
114 Commits
v1.7.112-a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0463196a3 | ||
|
|
8e0939170c | ||
|
|
5c19effdd1 | ||
|
|
8e51164321 | ||
|
|
da14c135e4 | ||
|
|
d7c5d39747 | ||
|
|
6ba39041d0 | ||
|
|
c99f1c7b77 | ||
|
|
7326bb9262 | ||
|
|
3589c3a6b9 | ||
|
|
0aa3941c40 | ||
|
|
e62f911810 | ||
|
|
fb1f4bf0e3 | ||
|
|
79c3cc5947 | ||
|
|
c4f24f3efa | ||
|
|
0acfba40db | ||
|
|
3f76b4960a | ||
|
|
a8c4694c36 | ||
|
|
537c52d11c | ||
|
|
2971623145 | ||
|
|
43130f33f0 | ||
|
|
73228114b9 | ||
|
|
8907cc47d9 | ||
|
|
8fd72b947a | ||
|
|
ea254f63af | ||
|
|
1a306c7450 | ||
|
|
a969f892ea | ||
|
|
43e50e669e | ||
|
|
529c7fe25d | ||
|
|
d605d0d544 | ||
|
|
a3f07d5ac6 | ||
|
|
c83bade022 | ||
|
|
67454974b2 | ||
|
|
e24e0a6473 | ||
|
|
eb2fc0f37b | ||
|
|
94b5374f66 | ||
|
|
9f65f1e7ae | ||
|
|
c598bb8796 | ||
|
|
70996203f9 | ||
|
|
709922c293 | ||
|
|
0aee010f9c | ||
|
|
c5eeb4392f | ||
| 5457b98c66 | |||
|
|
1593c55894 | ||
|
|
1cc18e5b72 | ||
|
|
24582128c5 | ||
|
|
0b038434b1 | ||
|
|
d576f77435 | ||
|
|
7e8d3314d0 | ||
|
|
07772b563f | ||
|
|
d500214766 | ||
|
|
9cd507269c | ||
|
|
aa272bfcf4 | ||
|
|
3b6a32b2f8 | ||
|
|
c66ef048f4 | ||
|
|
32962db6a9 | ||
|
|
4edc50ae47 | ||
|
|
245fb1a815 | ||
|
|
6dffd8e2e8 | ||
|
|
365f3d7d18 | ||
|
|
70bcbc4acc | ||
|
|
56e4c30261 | ||
|
|
f7208e1769 | ||
|
|
77ee39a3df | ||
|
|
02917cc22c | ||
|
|
325b9ea9c9 | ||
|
|
9739bbb3db | ||
|
|
4db1b85fc5 | ||
|
|
a78aa02890 | ||
|
|
0365cc0f9d | ||
|
|
0880824fb3 | ||
|
|
c0d34bd836 | ||
|
|
c8d0dda656 | ||
|
|
57c6a4d512 | ||
|
|
a32e40da31 | ||
|
|
449ed7c1f7 | ||
| 729cee573a | |||
| 5b7ebd85b6 | |||
|
|
c6f11f8ddb | ||
|
|
a26090e561 | ||
|
|
2609f60e6c | ||
|
|
c4c558954b | ||
|
|
d4ea4ed636 | ||
|
|
0fadbb1d0f | ||
|
|
0e5cd24e18 | ||
|
|
1d8e57d564 | ||
|
|
00b7e1798f | ||
|
|
bca1698682 | ||
|
|
0641ee85ef | ||
| 901cf5713a | |||
|
|
875da6c630 | ||
|
|
4589e88442 | ||
|
|
5862689949 | ||
|
|
3995ab5cf5 | ||
|
|
9d83ee3770 | ||
|
|
7e3d01f633 | ||
| 385c950f00 | |||
|
|
98e15b3af0 | ||
| dcb0618012 | |||
| 8403f2233e | |||
| 078f3b3619 | |||
| 4222a8507c | |||
| 5799c37111 | |||
| b14af20d1a | |||
| 7547d03166 | |||
| d6019e47a5 | |||
| 0ca8f25b1b | |||
| 76d14c3bf9 | |||
| a1cb83dfb2 | |||
| ff532465cf | |||
| 39e88529b3 | |||
| 992bf636e0 | |||
| beff5dd577 | |||
| 7d31ca5d65 |
62
.gitea/workflows/build-iso.yml
Normal file
62
.gitea/workflows/build-iso.yml
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
name: Build Archipelago release ISO (gated)
|
||||||
|
|
||||||
|
# Resurrected from image-recipe/_archived/.gitea-workflows/build-iso-dev.yml.
|
||||||
|
# Dispatch-only on purpose: the ISO is cut per release, not per push, and
|
||||||
|
# the iso-builder runner is a live node — builds are deliberate events.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-iso:
|
||||||
|
runs-on: iso-builder
|
||||||
|
timeout-minutes: 180
|
||||||
|
steps:
|
||||||
|
- name: Sync source to workspace
|
||||||
|
run: |
|
||||||
|
# Direct fetch + sync (actions/checkout token is broken on this Gitea)
|
||||||
|
REPO_DIR="$HOME/Projects/archy"
|
||||||
|
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||||
|
cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main
|
||||||
|
echo "=== Source at commit: $(git log --oneline -1) ==="
|
||||||
|
|
||||||
|
- name: Install ISO build dependencies
|
||||||
|
run: |
|
||||||
|
if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \
|
||||||
|
grub-efi-amd64-bin grub-pc-bin grub-common >/dev/null 2>&1; then
|
||||||
|
echo "ISO build deps already installed, skipping apt"
|
||||||
|
else
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq \
|
||||||
|
debootstrap squashfs-tools xorriso \
|
||||||
|
isolinux syslinux-common mtools \
|
||||||
|
grub-efi-amd64-bin grub-pc-bin grub-common
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build backend + frontend if stale
|
||||||
|
run: |
|
||||||
|
REPO_DIR="$HOME/Projects/archy"
|
||||||
|
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
. "$HOME/.cargo/env" 2>/dev/null || true
|
||||||
|
VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
|
||||||
|
if ! strings core/target/release/archipelago 2>/dev/null | grep -qF "$VERSION"; then
|
||||||
|
cargo build --release --manifest-path core/Cargo.toml -p archipelago
|
||||||
|
fi
|
||||||
|
if ! grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js 2>/dev/null; then
|
||||||
|
(cd neode-ui && npm ci && npm run build)
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Gated ISO build (gates + build + smoke + qemu)
|
||||||
|
run: |
|
||||||
|
REPO_DIR="$HOME/Projects/archy"
|
||||||
|
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
. "$HOME/.cargo/env" 2>/dev/null || true
|
||||||
|
bash scripts/build-iso-release.sh
|
||||||
|
|
||||||
|
- name: Report artifacts
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
REPO_DIR="$HOME/Projects/archy"
|
||||||
|
[ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy"
|
||||||
|
ls -lh "$REPO_DIR"/image-recipe/results/*.iso 2>/dev/null | tail -3 || echo "no ISO produced"
|
||||||
18
.github/pull_request_template.md
vendored
18
.github/pull_request_template.md
vendored
@ -1,16 +1,16 @@
|
|||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
<!-- Brief description of what this PR does -->
|
<!-- What changed and why? -->
|
||||||
|
|
||||||
## Changes
|
## Verification
|
||||||
|
|
||||||
-
|
<!-- Commands run, devices tested, screenshots, or reason testing was not run. -->
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] TypeScript type-check passes (`npm run type-check`)
|
- [ ] Rust formatting/clippy/tests pass when backend code changed.
|
||||||
- [ ] Frontend builds (`npm run build`)
|
- [ ] Frontend type-check/build/tests pass when frontend code changed.
|
||||||
- [ ] Tests pass (`npm test`)
|
- [ ] App manifests validate when app packaging changed.
|
||||||
- [ ] Rust clippy clean (if backend changes)
|
- [ ] Generated catalogs are updated when manifest-owned catalog fields changed.
|
||||||
- [ ] No new compiler warnings
|
- [ ] Docs are updated for user-facing or developer-facing behavior changes.
|
||||||
- [ ] Tested on live server
|
- [ ] No secrets, generated build outputs, local screenshots, or private host details are included.
|
||||||
|
|||||||
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
@ -8,11 +8,11 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
RUST_VERSION: stable
|
RUST_VERSION: stable
|
||||||
NODE_VERSION: 18
|
NODE_VERSION: 20
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
name: Rust (fmt + clippy + test)
|
name: Rust
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@ -28,17 +28,17 @@ jobs:
|
|||||||
toolchain: ${{ env.RUST_VERSION }}
|
toolchain: ${{ env.RUST_VERSION }}
|
||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Format
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
- name: Tests
|
- name: Test
|
||||||
run: cargo test --all-features
|
run: cargo test --all-features
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend (type-check + lint)
|
name: Frontend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@ -52,14 +52,31 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'npm'
|
cache: npm
|
||||||
cache-dependency-path: neode-ui/package-lock.json
|
cache-dependency-path: neode-ui/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npm run type-check
|
run: npm run type-check
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run 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
35
.gitignore
vendored
@ -1,10 +1,9 @@
|
|||||||
# SSH keys (sandbox copies)
|
# SSH keys and sandbox copies
|
||||||
.ssh/
|
.ssh/
|
||||||
|
|
||||||
# Rust build output
|
# Rust build output
|
||||||
target/
|
target/
|
||||||
**/target/
|
**/target/
|
||||||
Cargo.lock
|
|
||||||
|
|
||||||
# Node.js
|
# Node.js
|
||||||
node_modules/
|
node_modules/
|
||||||
@ -12,7 +11,6 @@ node_modules/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
package-lock.json
|
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
@ -28,49 +26,46 @@ build/
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
._*
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
# Environment and local overrides
|
# Environment and local overrides
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
.env.production
|
||||||
|
core/.env.production
|
||||||
scripts/deploy-config.sh
|
scripts/deploy-config.sh
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage/
|
coverage/
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
|
|
||||||
# Temporary files
|
# Image / release artifacts
|
||||||
*.tmp
|
|
||||||
*.temp
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
*.iso
|
*.iso
|
||||||
*.img
|
*.img
|
||||||
*.dmg
|
*.dmg
|
||||||
*.app
|
*.app
|
||||||
|
*.apk
|
||||||
|
*.keystore
|
||||||
|
*.s9pk
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
# Release artifacts live in Gitea Release attachments, not Git history.
|
# Release artifacts live in release attachments, not Git history.
|
||||||
releases/**
|
releases/**
|
||||||
!releases/
|
!releases/
|
||||||
!releases/manifest.json
|
!releases/manifest.json
|
||||||
|
|
||||||
# macOS build output
|
|
||||||
build/macos/
|
|
||||||
|
|
||||||
# Image recipe output
|
# Image recipe output
|
||||||
image-recipe/output/
|
image-recipe/output/
|
||||||
image-recipe/*.iso
|
image-recipe/*.iso
|
||||||
image-recipe/*.img
|
image-recipe/*.img
|
||||||
|
|
||||||
# Loop tool artifacts (created in every subdirectory)
|
# Loop tool artifacts
|
||||||
*/loop/
|
*/loop/
|
||||||
loop/loop/
|
loop/loop/
|
||||||
loop/loop.log.bak
|
loop/loop.log.bak
|
||||||
@ -78,19 +73,17 @@ loop/loop.log.bak
|
|||||||
# Separate repos nested in tree
|
# Separate repos nested in tree
|
||||||
web/
|
web/
|
||||||
|
|
||||||
._*
|
# Resilience harness reports contain session cookies.
|
||||||
|
|
||||||
# Resilience harness reports (generated, contains session cookies)
|
|
||||||
scripts/resilience/reports/
|
scripts/resilience/reports/
|
||||||
|
|
||||||
# Codex / pnpm / python caches / editor backups
|
# Codex / pnpm / python caches / editor backups
|
||||||
.codex
|
.codex
|
||||||
.codex-target-*/
|
.codex-target-*/
|
||||||
.codex-tmp/
|
.codex-tmp/
|
||||||
|
.claude/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
*.bak
|
*.bak
|
||||||
.claude/scheduled_tasks.lock
|
|
||||||
|
|
||||||
# Local evidence screenshots; intentional UI screenshots should live under an
|
# Local evidence screenshots; intentional UI screenshots should live under an
|
||||||
# app/docs asset path with a descriptive filename.
|
# app/docs asset path with a descriptive filename.
|
||||||
|
|||||||
@ -23,6 +23,10 @@ used by the `.githooks/pre-push` hook), which:
|
|||||||
**aborts** if any is missing.
|
**aborts** if any is missing.
|
||||||
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
|
5. Stages the signed APK at `neode-ui/public/packages/archipelago-companion.apk`,
|
||||||
commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass).
|
commits, and pushes with `SHIP_COMPANION=1` (the sanctioned pre-push bypass).
|
||||||
|
6. The first-launch companion modal and Android "Share this app" QR point at
|
||||||
|
`http://146.59.87.168:2100/packages/archipelago-companion.apk`. After the
|
||||||
|
repo artifact is built, mirror that exact APK to the VPS2-served path before
|
||||||
|
calling the release done.
|
||||||
|
|
||||||
**Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path
|
**Never** hand-roll `gradlew assembleDebug` + `cp` to the served path. That path
|
||||||
skips the clean build and the signature enforcement and is exactly how a broken
|
skips the clean build and the signature enforcement and is exactly how a broken
|
||||||
@ -82,13 +86,16 @@ home-screen app layouts wiped by an over-broad action.
|
|||||||
|
|
||||||
## Verify the published download after shipping
|
## Verify the published download after shipping
|
||||||
|
|
||||||
The download served to nodes is Gitea raw-on-main. Confirm the live bytes match
|
The checked-in artifact is Gitea raw-on-main. The QR/App Store download served
|
||||||
what you built and signed:
|
to users is the VPS2 `:2100` URL. Confirm both live byte streams match what you
|
||||||
|
built and signed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SERVED=neode-ui/public/packages/archipelago-companion.apk
|
SERVED=neode-ui/public/packages/archipelago-companion.apk
|
||||||
URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
GITEA_URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
||||||
curl -sS -o /tmp/live.apk "$URL"
|
QR_URL=http://146.59.87.168:2100/packages/archipelago-companion.apk
|
||||||
shasum -a 256 "$SERVED" /tmp/live.apk # must match
|
curl -sS -o /tmp/live-gitea.apk "$GITEA_URL"
|
||||||
apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true
|
curl -sS -o /tmp/live-qr.apk "$QR_URL"
|
||||||
|
shasum -a 256 "$SERVED" /tmp/live-gitea.apk /tmp/live-qr.apk # all must match
|
||||||
|
apksigner verify -v --min-sdk-version 21 /tmp/live-qr.apk | grep -i "scheme" # v1/v2/v3 = true
|
||||||
```
|
```
|
||||||
|
|||||||
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 31
|
versionCode = 45
|
||||||
versionName = "0.5.11"
|
versionName = "0.5.25"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
Binary file not shown.
@ -88,8 +88,18 @@ object ServerQrParser {
|
|||||||
|
|
||||||
private fun parseFips(uri: Uri): FipsPairInfo? {
|
private fun parseFips(uri: Uri): FipsPairInfo? {
|
||||||
val npub = uri.getQueryParameter("fnpub")?.trim()
|
val npub = uri.getQueryParameter("fnpub")?.trim()
|
||||||
val host = uri.getQueryParameter("fhost")?.trim()
|
var host = uri.getQueryParameter("fhost")?.trim()
|
||||||
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
|
if (npub.isNullOrBlank() || host.isNullOrBlank()) return null
|
||||||
|
// A .fips fhost is a dead dial hint: Android's system DNS can't
|
||||||
|
// resolve .fips, so every handshake send fails and first connect
|
||||||
|
// falls back to slow anchor discovery. If the QR's `url` host is a
|
||||||
|
// real address, dial that instead (QRs minted while the node UI was
|
||||||
|
// browsed over the mesh carry npub….fips here).
|
||||||
|
if (host.endsWith(".fips")) {
|
||||||
|
val urlHost = uri.getQueryParameter("url")
|
||||||
|
?.let { runCatching { Uri.parse(it).host }.getOrNull() }
|
||||||
|
if (!urlHost.isNullOrBlank() && !urlHost.endsWith(".fips")) host = urlHost
|
||||||
|
}
|
||||||
return FipsPairInfo(
|
return FipsPairInfo(
|
||||||
npub = npub,
|
npub = npub,
|
||||||
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
|
ula = uri.getQueryParameter("fip")?.trim().orEmpty(),
|
||||||
|
|||||||
@ -5,6 +5,10 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
import android.net.VpnService
|
import android.net.VpnService
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@ -34,6 +38,20 @@ class ArchyVpnService : VpnService() {
|
|||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private var warmerJob: Job? = null
|
private var warmerJob: Job? = null
|
||||||
|
|
||||||
|
// Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the
|
||||||
|
// tunnel's underlying network stays pinned to the interface that was
|
||||||
|
// default when the VPN came up; when the phone leaves Wi-Fi for 5G the
|
||||||
|
// mesh sockets ride a dead network and sessions never recover until the
|
||||||
|
// app is restarted (user-reported 2026-07-27). The callback (a) re-pins
|
||||||
|
// the tunnel to the new default network via setUnderlyingNetworks and
|
||||||
|
// (b) forces an immediate mesh re-home so discovery + sessions rebuild
|
||||||
|
// on the new path within seconds instead of waiting out dead-link
|
||||||
|
// timeouts.
|
||||||
|
private var connectivityManager: ConnectivityManager? = null
|
||||||
|
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||||
|
@Volatile
|
||||||
|
private var currentUnderlying: Network? = null
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
if (intent?.action == ACTION_STOP) {
|
if (intent?.action == ACTION_STOP) {
|
||||||
shutdown()
|
shutdown()
|
||||||
@ -113,6 +131,7 @@ class ArchyVpnService : VpnService() {
|
|||||||
shutdown()
|
shutdown()
|
||||||
} else {
|
} else {
|
||||||
startSessionWarmer()
|
startSessionWarmer()
|
||||||
|
registerNetworkHandoff()
|
||||||
// Phone-to-phone chat/beam + the phone's own mesh-served page.
|
// Phone-to-phone chat/beam + the phone's own mesh-served page.
|
||||||
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
|
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
|
||||||
// Mutual pairing: when a phone that scanned OUR QR announces
|
// Mutual pairing: when a phone that scanned OUR QR announces
|
||||||
@ -153,19 +172,28 @@ class ArchyVpnService : VpnService() {
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
emptyList()
|
emptyList()
|
||||||
}.distinct()
|
}.distinct()
|
||||||
for ((ula, port) in targets) {
|
if (round == 0) Log.i(TAG, "session warmer: ${targets.map { it.first }}")
|
||||||
try {
|
// Probe all targets CONCURRENTLY with a short timeout — the
|
||||||
java.net.Socket().use { s ->
|
// old sequential 20s-per-target loop let one cold node starve
|
||||||
s.connect(
|
// every other target for the whole aggressive window.
|
||||||
java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), port),
|
targets.map { (ula, port) ->
|
||||||
20_000,
|
launch {
|
||||||
)
|
try {
|
||||||
|
java.net.Socket().use { s ->
|
||||||
|
s.connect(
|
||||||
|
java.net.InetSocketAddress(
|
||||||
|
java.net.InetAddress.getByName(ula),
|
||||||
|
port,
|
||||||
|
),
|
||||||
|
5_000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Cold path / node away — the attempt still drove
|
||||||
|
// session establishment; try again next round.
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
|
||||||
// Cold path / node away — the connect attempt still
|
|
||||||
// drove session establishment; try again next round.
|
|
||||||
}
|
}
|
||||||
}
|
}.forEach { it.join() }
|
||||||
round++
|
round++
|
||||||
// Aggressive for the first ~minute (session bring-up), then a
|
// Aggressive for the first ~minute (session bring-up), then a
|
||||||
// slow keep-warm tick that costs nearly nothing.
|
// slow keep-warm tick that costs nearly nothing.
|
||||||
@ -174,8 +202,75 @@ class ArchyVpnService : VpnService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track the phone's default network and hand the mesh over to it as the
|
||||||
|
* phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change:
|
||||||
|
* 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live
|
||||||
|
* network instead of dying on the one it launched with.
|
||||||
|
* 2. re-home the mesh — kick the session warmer so discovery + sessions
|
||||||
|
* rebuild on the new path immediately; the node's own fast-reconnect
|
||||||
|
* (1s) redials peers over the new route.
|
||||||
|
* onAvailable also fires for the FIRST network, which is how the initial
|
||||||
|
* underlying network gets set.
|
||||||
|
*/
|
||||||
|
private fun registerNetworkHandoff() {
|
||||||
|
if (networkCallback != null) return
|
||||||
|
val cm = getSystemService(ConnectivityManager::class.java) ?: return
|
||||||
|
connectivityManager = cm
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.build()
|
||||||
|
val cb = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
handoffTo(network)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
// The lost network was our underlying one — clear the pin so
|
||||||
|
// the system falls back to whatever default remains; the next
|
||||||
|
// onAvailable re-pins explicitly.
|
||||||
|
if (network == currentUnderlying) {
|
||||||
|
currentUnderlying = null
|
||||||
|
runCatching { setUnderlyingNetworks(null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
networkCallback = cb
|
||||||
|
// requestNetwork tracks the BEST network of the request; when the
|
||||||
|
// phone moves Wi-Fi→5G the callback re-fires onAvailable with the new
|
||||||
|
// one. (registerDefaultNetworkCallback would also work; requestNetwork
|
||||||
|
// lets us extend to BLE-capable transports later.)
|
||||||
|
runCatching { cm.requestNetwork(request, cb) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handoffTo(network: Network) {
|
||||||
|
val changed = network != currentUnderlying
|
||||||
|
currentUnderlying = network
|
||||||
|
// Always re-assert; cheap and covers capability changes on the same
|
||||||
|
// Network object.
|
||||||
|
runCatching { setUnderlyingNetworks(arrayOf(network)) }
|
||||||
|
if (changed && FipsNative.isRunning()) {
|
||||||
|
Log.i(TAG, "network handoff → re-homing mesh on new default network")
|
||||||
|
// Fresh warmer pass drives immediate rediscovery/session rebuild
|
||||||
|
// on the new path instead of waiting out dead-link timeouts.
|
||||||
|
startSessionWarmer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterNetworkHandoff() {
|
||||||
|
val cm = connectivityManager
|
||||||
|
val cb = networkCallback
|
||||||
|
if (cm != null && cb != null) {
|
||||||
|
runCatching { cm.unregisterNetworkCallback(cb) }
|
||||||
|
}
|
||||||
|
networkCallback = null
|
||||||
|
connectivityManager = null
|
||||||
|
currentUnderlying = null
|
||||||
|
}
|
||||||
|
|
||||||
private fun shutdown() {
|
private fun shutdown() {
|
||||||
warmerJob?.cancel()
|
warmerJob?.cancel()
|
||||||
|
unregisterNetworkHandoff()
|
||||||
FlareServer.stop()
|
FlareServer.stop()
|
||||||
FipsNative.stop()
|
FipsNative.stop()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
@ -183,6 +278,7 @@ class ArchyVpnService : VpnService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
unregisterNetworkHandoff()
|
||||||
FipsNative.stop()
|
FipsNative.stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|||||||
@ -41,7 +41,15 @@ object FipsManager {
|
|||||||
ensureIdentity(prefs)
|
ensureIdentity(prefs)
|
||||||
prefs.upsertNodePeer(info, alias)
|
prefs.upsertNodePeer(info, alias)
|
||||||
peersDirty = true
|
peersDirty = true
|
||||||
_consentNeeded.value = true
|
// Restart the mesh with the new peer RIGHT NOW when consent already
|
||||||
|
// exists — relying on the consentNeeded collector left a running
|
||||||
|
// mesh on the OLD peer list whenever the collector wasn't active
|
||||||
|
// (fresh pairings looked dead until a full app restart).
|
||||||
|
if (VpnService.prepare(context) == null) {
|
||||||
|
startService(context)
|
||||||
|
} else {
|
||||||
|
_consentNeeded.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
|
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
|
||||||
|
|||||||
@ -25,6 +25,26 @@ internal const val ARCHY_ANCHOR_NPUB =
|
|||||||
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
|
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
|
||||||
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
|
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public FIPS network anchors (join.fips.network — the dual-transport TCP
|
||||||
|
* pair; keep in lockstep with core/archipelago/src/fips/anchors.rs
|
||||||
|
* fips_network_anchors()). Baked into every pairing at trailing priority so
|
||||||
|
* a degraded/unreachable vps2 anchor can never strand the phone: the mesh
|
||||||
|
* still joins the public tree and routes to the node through it.
|
||||||
|
*/
|
||||||
|
internal val PUBLIC_FIPS_ANCHORS = listOf(
|
||||||
|
Triple(
|
||||||
|
"npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u",
|
||||||
|
"23.182.128.74:443",
|
||||||
|
"tcp",
|
||||||
|
),
|
||||||
|
Triple(
|
||||||
|
"npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98",
|
||||||
|
"217.77.8.91:443",
|
||||||
|
"tcp",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mesh identity + known node peers. Follows the same plaintext-DataStore
|
* Mesh identity + known node peers. Follows the same plaintext-DataStore
|
||||||
* storage model as ServerPreferences (the server password lives there the
|
* storage model as ServerPreferences (the server password lives there the
|
||||||
@ -126,7 +146,7 @@ class FipsPreferences(private val context: Context) {
|
|||||||
if (peer.ip.isBlank() || peer.port <= 0) continue
|
if (peer.ip.isBlank() || peer.port <= 0) continue
|
||||||
merged.put(JSONObject().apply {
|
merged.put(JSONObject().apply {
|
||||||
put("npub", peer.npub)
|
put("npub", peer.npub)
|
||||||
put("alias", peer.name.ifBlank { "Party phone" })
|
put("alias", hostSafeAlias(peer.name.ifBlank { "party-phone" }))
|
||||||
put("addresses", JSONArray().put(JSONObject().apply {
|
put("addresses", JSONArray().put(JSONObject().apply {
|
||||||
put("transport", "udp")
|
put("transport", "udp")
|
||||||
put("addr", "${peer.ip}:${peer.port}")
|
put("addr", "${peer.ip}:${peer.port}")
|
||||||
@ -145,7 +165,7 @@ class FipsPreferences(private val context: Context) {
|
|||||||
) {
|
) {
|
||||||
merged.put(JSONObject().apply {
|
merged.put(JSONObject().apply {
|
||||||
put("npub", ARCHY_ANCHOR_NPUB)
|
put("npub", ARCHY_ANCHOR_NPUB)
|
||||||
put("alias", "Archipelago anchor")
|
put("alias", "archipelago-anchor")
|
||||||
put("addresses", JSONArray().put(JSONObject().apply {
|
put("addresses", JSONArray().put(JSONObject().apply {
|
||||||
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||||
put("addr", ARCHY_ANCHOR_ADDR)
|
put("addr", ARCHY_ANCHOR_ADDR)
|
||||||
@ -153,9 +173,40 @@ class FipsPreferences(private val context: Context) {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Backfill anchor peers for entries paired before newer QR/app
|
||||||
|
// releases added them. `upsertNodePeer` persists these on re-scan, but
|
||||||
|
// startup must also self-heal old DataStore state so updating the APK is
|
||||||
|
// enough to get off-LAN redundancy.
|
||||||
|
if (merged.length() > 0) {
|
||||||
|
addAnchorIfMissing(merged, ARCHY_ANCHOR_NPUB, "archipelago-anchor", ARCHY_ANCHOR_ADDR, ARCHY_ANCHOR_TRANSPORT, 40)
|
||||||
|
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
|
||||||
|
val (npub, addr, transport) = anchor
|
||||||
|
addAnchorIfMissing(merged, npub, "fips-network-anchor-${i + 1}", addr, transport, 50 + i * 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
return merged.toString()
|
return merged.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun addAnchorIfMissing(
|
||||||
|
peers: JSONArray,
|
||||||
|
npub: String,
|
||||||
|
alias: String,
|
||||||
|
addr: String,
|
||||||
|
transport: String,
|
||||||
|
priority: Int,
|
||||||
|
) {
|
||||||
|
if ((0 until peers.length()).any { peers.optJSONObject(it)?.optString("npub") == npub }) return
|
||||||
|
peers.put(JSONObject().apply {
|
||||||
|
put("npub", npub)
|
||||||
|
put("alias", alias)
|
||||||
|
put("addresses", JSONArray().put(JSONObject().apply {
|
||||||
|
put("transport", transport)
|
||||||
|
put("addr", addr)
|
||||||
|
put("priority", priority)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private fun parsePartyPeers(json: String): List<PartyPeer> = try {
|
private fun parsePartyPeers(json: String): List<PartyPeer> = try {
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).mapNotNull { i ->
|
(0 until arr.length()).mapNotNull { i ->
|
||||||
@ -200,16 +251,19 @@ class FipsPreferences(private val context: Context) {
|
|||||||
val incoming = mutableListOf<JSONObject>()
|
val incoming = mutableListOf<JSONObject>()
|
||||||
incoming += JSONObject().apply {
|
incoming += JSONObject().apply {
|
||||||
put("npub", info.npub)
|
put("npub", info.npub)
|
||||||
put("alias", alias.ifBlank { "Archipelago" })
|
put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" }))
|
||||||
val addresses = JSONArray()
|
val addresses = JSONArray()
|
||||||
if (info.udpPort > 0) {
|
// .fips hosts are unresolvable on Android (no system .fips DNS):
|
||||||
|
// storing one gives the mesh a dial target that fails every
|
||||||
|
// handshake and stalls first connect on anchor discovery.
|
||||||
|
if (info.udpPort > 0 && !info.host.endsWith(".fips")) {
|
||||||
addresses.put(JSONObject().apply {
|
addresses.put(JSONObject().apply {
|
||||||
put("transport", "udp")
|
put("transport", "udp")
|
||||||
put("addr", "${info.host}:${info.udpPort}")
|
put("addr", "${info.host}:${info.udpPort}")
|
||||||
put("priority", 10)
|
put("priority", 10)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (info.tcpPort > 0) {
|
if (info.tcpPort > 0 && !info.host.endsWith(".fips")) {
|
||||||
addresses.put(JSONObject().apply {
|
addresses.put(JSONObject().apply {
|
||||||
put("transport", "tcp")
|
put("transport", "tcp")
|
||||||
put("addr", "${info.host}:${info.tcpPort}")
|
put("addr", "${info.host}:${info.tcpPort}")
|
||||||
@ -220,9 +274,10 @@ class FipsPreferences(private val context: Context) {
|
|||||||
}
|
}
|
||||||
for (anchor in info.anchors) {
|
for (anchor in info.anchors) {
|
||||||
if (anchor.npub == info.npub) continue
|
if (anchor.npub == info.npub) continue
|
||||||
|
if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue
|
||||||
incoming += JSONObject().apply {
|
incoming += JSONObject().apply {
|
||||||
put("npub", anchor.npub)
|
put("npub", anchor.npub)
|
||||||
put("alias", "Mesh anchor")
|
put("alias", "mesh-anchor")
|
||||||
put("addresses", JSONArray().put(JSONObject().apply {
|
put("addresses", JSONArray().put(JSONObject().apply {
|
||||||
put("transport", anchor.transport)
|
put("transport", anchor.transport)
|
||||||
put("addr", anchor.addr)
|
put("addr", anchor.addr)
|
||||||
@ -237,7 +292,7 @@ class FipsPreferences(private val context: Context) {
|
|||||||
) {
|
) {
|
||||||
incoming += JSONObject().apply {
|
incoming += JSONObject().apply {
|
||||||
put("npub", ARCHY_ANCHOR_NPUB)
|
put("npub", ARCHY_ANCHOR_NPUB)
|
||||||
put("alias", "Archipelago anchor")
|
put("alias", "archipelago-anchor")
|
||||||
put("addresses", JSONArray().put(JSONObject().apply {
|
put("addresses", JSONArray().put(JSONObject().apply {
|
||||||
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
put("transport", ARCHY_ANCHOR_TRANSPORT)
|
||||||
put("addr", ARCHY_ANCHOR_ADDR)
|
put("addr", ARCHY_ANCHOR_ADDR)
|
||||||
@ -245,6 +300,21 @@ class FipsPreferences(private val context: Context) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// And the public FIPS network anchors at trailing priority, so one
|
||||||
|
// degraded rendezvous (vps2, 2026-07-24) can never strand the phone.
|
||||||
|
for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) {
|
||||||
|
val (npub, addr, transport) = anchor
|
||||||
|
if (info.npub == npub || incoming.any { it.optString("npub") == npub }) continue
|
||||||
|
incoming += JSONObject().apply {
|
||||||
|
put("npub", npub)
|
||||||
|
put("alias", "fips-network-anchor-${i + 1}")
|
||||||
|
put("addresses", JSONArray().put(JSONObject().apply {
|
||||||
|
put("transport", transport)
|
||||||
|
put("addr", addr)
|
||||||
|
put("priority", 50 + i * 10)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
|
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
|
||||||
context.fipsDataStore.edit { prefs ->
|
context.fipsDataStore.edit { prefs ->
|
||||||
val current = JSONArray(prefs[peersKey] ?: "[]")
|
val current = JSONArray(prefs[peersKey] ?: "[]")
|
||||||
@ -258,3 +328,14 @@ class FipsPreferences(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peer aliases feed the fips host map as `<alias>.fips` hostnames; anything
|
||||||
|
* that isn't a valid DNS label ("Framework PT" — the space) gets rejected and
|
||||||
|
* silently drops the peer from name resolution. Slug it instead of losing it.
|
||||||
|
*/
|
||||||
|
internal fun hostSafeAlias(alias: String): String =
|
||||||
|
alias.lowercase()
|
||||||
|
.replace(Regex("[^a-z0-9.-]+"), "-")
|
||||||
|
.trim('-', '.')
|
||||||
|
.ifBlank { "archipelago" }
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -108,6 +109,7 @@ fun NESController(
|
|||||||
onKey: (String) -> Unit,
|
onKey: (String) -> Unit,
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
onPlayerToggle: () -> Unit = {},
|
onPlayerToggle: () -> Unit = {},
|
||||||
|
onToggleStyle: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val c = paletteFor(style)
|
val c = paletteFor(style)
|
||||||
@ -205,6 +207,7 @@ fun NESController(
|
|||||||
) {
|
) {
|
||||||
PlayerPill(c, playerId, onPlayerToggle)
|
PlayerPill(c, playerId, onPlayerToggle)
|
||||||
SettingsBtn(c, Modifier, onMenu)
|
SettingsBtn(c, Modifier, onMenu)
|
||||||
|
onToggleStyle?.let { StyleBtn(c, Modifier, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -431,6 +434,23 @@ fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Uni
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dark/Classic style toggle — lives next to the settings gear (the menu hub
|
||||||
|
* no longer carries it). */
|
||||||
|
@Composable
|
||||||
|
fun StyleBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
var p by remember { mutableStateOf(false) }
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (p) c.capsulePress else c.capsule)
|
||||||
|
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Palette, "Controller style", Modifier.size(26.dp), tint = c.labelMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Player ID toggle pill (P1/P2/ALL) */
|
/** Player ID toggle pill (P1/P2/ALL) */
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
||||||
|
|||||||
@ -21,20 +21,40 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Bolt
|
||||||
|
import androidx.compose.material.icons.filled.Dashboard
|
||||||
|
import androidx.compose.material.icons.filled.Dns
|
||||||
|
import androidx.compose.material.icons.filled.Groups
|
||||||
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
|
import androidx.compose.material.icons.filled.SportsEsports
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import com.archipelago.app.fips.FipsManager
|
||||||
|
import com.archipelago.app.fips.FipsNative
|
||||||
|
import com.archipelago.app.fips.FipsPreferences
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -51,7 +71,6 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerEntry
|
import com.archipelago.app.data.ServerEntry
|
||||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.ControllerStyle
|
|
||||||
import com.archipelago.app.ui.theme.SurfaceDark
|
import com.archipelago.app.ui.theme.SurfaceDark
|
||||||
import com.archipelago.app.ui.theme.TextMuted
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
@ -75,27 +94,29 @@ fun NESMenu(
|
|||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
servers: List<ServerEntry>,
|
servers: List<ServerEntry>,
|
||||||
activeServer: ServerEntry?,
|
activeServer: ServerEntry?,
|
||||||
isGamepadMode: Boolean,
|
|
||||||
controllerStyle: ControllerStyle,
|
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSelectServer: (ServerEntry) -> Unit,
|
onSelectServer: (ServerEntry) -> Unit,
|
||||||
onAddServer: (ServerEntry) -> Unit,
|
onAddServer: (ServerEntry) -> Unit,
|
||||||
onScanQr: (() -> Unit)? = null,
|
onScanQr: (() -> Unit)? = null,
|
||||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||||
onRemoveServer: (ServerEntry) -> Unit,
|
onRemoveServer: (ServerEntry) -> Unit,
|
||||||
onToggleMode: () -> Unit,
|
onRemote: () -> Unit,
|
||||||
onToggleStyle: () -> Unit,
|
onKeyboard: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)? = null,
|
onBackToWebView: (() -> Unit)? = null,
|
||||||
onMeshParty: (() -> Unit)? = null,
|
onMeshParty: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||||
|
// Contained hub overlay: a centred glass panel (not full-screen) that
|
||||||
|
// holds the card page and its sub-pages (Nodes, FIPS) and scrolls
|
||||||
|
// inside its own bounds when content is tall. Tapping the dimmed
|
||||||
|
// backdrop dismisses.
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView, onMeshParty)
|
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -105,16 +126,14 @@ fun NESMenu(
|
|||||||
private fun MenuPanel(
|
private fun MenuPanel(
|
||||||
servers: List<ServerEntry>,
|
servers: List<ServerEntry>,
|
||||||
activeServer: ServerEntry?,
|
activeServer: ServerEntry?,
|
||||||
isGamepadMode: Boolean,
|
|
||||||
controllerStyle: ControllerStyle,
|
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSelectServer: (ServerEntry) -> Unit,
|
onSelectServer: (ServerEntry) -> Unit,
|
||||||
onAddServer: (ServerEntry) -> Unit,
|
onAddServer: (ServerEntry) -> Unit,
|
||||||
onScanQr: (() -> Unit)?,
|
onScanQr: (() -> Unit)?,
|
||||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||||
onRemoveServer: (ServerEntry) -> Unit,
|
onRemoveServer: (ServerEntry) -> Unit,
|
||||||
onToggleMode: () -> Unit,
|
onRemote: () -> Unit,
|
||||||
onToggleStyle: () -> Unit,
|
onKeyboard: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)?,
|
onBackToWebView: (() -> Unit)?,
|
||||||
onMeshParty: (() -> Unit)?,
|
onMeshParty: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
@ -124,14 +143,15 @@ private fun MenuPanel(
|
|||||||
var nm by remember { mutableStateOf("") }
|
var nm by remember { mutableStateOf("") }
|
||||||
var addr by remember { mutableStateOf("") }
|
var addr by remember { mutableStateOf("") }
|
||||||
var pwd by remember { mutableStateOf("") }
|
var pwd by remember { mutableStateOf("") }
|
||||||
|
var https by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
fun resetForm() {
|
fun resetForm() {
|
||||||
nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
|
nm = ""; addr = ""; pwd = ""; https = false; showAdd = false; editing = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startEdit(server: ServerEntry) {
|
fun startEdit(server: ServerEntry) {
|
||||||
editing = server
|
editing = server
|
||||||
nm = server.name; addr = server.address; pwd = server.password
|
nm = server.name; addr = server.address; pwd = server.password; https = server.useHttps
|
||||||
showAdd = false
|
showAdd = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,161 +159,381 @@ private fun MenuPanel(
|
|||||||
if (addr.isBlank()) return
|
if (addr.isBlank()) return
|
||||||
val orig = editing
|
val orig = editing
|
||||||
if (orig != null) {
|
if (orig != null) {
|
||||||
// Preserve fields the compact form doesn't expose (scheme, port).
|
// Preserve port (compact form doesn't expose it); scheme is now editable.
|
||||||
onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm))
|
onEditServer(orig, orig.copy(address = addr, useHttps = https, password = pwd, name = nm))
|
||||||
} else {
|
} else {
|
||||||
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
|
onAddServer(ServerEntry(addr, https, password = pwd, name = nm))
|
||||||
}
|
}
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var page by remember { mutableStateOf(HubPage.HUB) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 420.dp)
|
.widthIn(max = 420.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 20.dp)
|
.padding(horizontal = 20.dp)
|
||||||
|
// Cap height just short of the full screen; the panel wraps short
|
||||||
|
// content and only scrolls in the rare case it outgrows this.
|
||||||
|
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
|
||||||
.clip(RoundedCornerShape(PANEL_R))
|
.clip(RoundedCornerShape(PANEL_R))
|
||||||
.background(PanelBg)
|
.background(PanelBg.copy(alpha = 0.86f))
|
||||||
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
||||||
.padding(22.dp),
|
.verticalScroll(rememberScrollState())
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
.padding(20.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
// Title
|
// Header: back (on sub-pages) or title, and a close on the hub.
|
||||||
Text(
|
Row(
|
||||||
"Menu",
|
Modifier.fillMaxWidth(),
|
||||||
color = TextPrimary,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontSize = 18.sp,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
fontWeight = FontWeight.SemiBold,
|
) {
|
||||||
letterSpacing = 2.sp,
|
if (page == HubPage.HUB) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
|
||||||
textAlign = TextAlign.Center,
|
IconRound(Icons.Default.Close, "Close") { onDismiss() }
|
||||||
)
|
} else {
|
||||||
Spacer(Modifier.height(2.dp))
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
|
||||||
// Servers
|
Spacer(Modifier.width(12.dp))
|
||||||
servers.forEach { server ->
|
|
||||||
val active = server.serialize() == activeServer?.serialize()
|
|
||||||
MenuItem(
|
|
||||||
label = server.displayName(),
|
|
||||||
selected = active,
|
|
||||||
onClick = { onSelectServer(server) },
|
|
||||||
onEdit = { startEdit(server) },
|
|
||||||
onRemove = { onRemoveServer(server) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (servers.isEmpty()) {
|
|
||||||
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add / edit server
|
|
||||||
if (showAdd || editing != null) {
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(ROW_R))
|
|
||||||
.background(FieldBg)
|
|
||||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
if (editing != null) "Edit Server" else "Add Server",
|
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
|
||||||
color = TextMuted,
|
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
||||||
fontSize = 13.sp,
|
|
||||||
letterSpacing = 1.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Cancel",
|
|
||||||
color = TextMuted,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GlassField(
|
IconRound(Icons.Default.Close, "Close") { onDismiss() }
|
||||||
value = nm, onValueChange = { nm = it },
|
|
||||||
placeholder = "Name (optional)",
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
|
||||||
)
|
|
||||||
GlassField(
|
|
||||||
value = addr, onValueChange = { addr = it.trim() },
|
|
||||||
placeholder = "192.168.1.100",
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
|
||||||
)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
GlassField(
|
|
||||||
value = pwd, onValueChange = { pwd = it },
|
|
||||||
placeholder = "Password",
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
|
||||||
keyboardActions = KeyboardActions(onGo = { submit() }),
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
|
|
||||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
|
||||||
.clickable { submit() },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
Spacer(Modifier.height(4.dp))
|
||||||
Box(Modifier.weight(1f)) {
|
|
||||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
when (page) {
|
||||||
|
HubPage.HUB -> {
|
||||||
|
// Card page — one card per destination. Dashboard first: it's a
|
||||||
|
// peer of the others so three-finger → hub → Dashboard returns
|
||||||
|
// to the node UI, same shape as every other option.
|
||||||
|
if (onBackToWebView != null) {
|
||||||
|
HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() }
|
||||||
}
|
}
|
||||||
if (onScanQr != null) {
|
HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() }
|
||||||
// Add server by scanning the node's pairing QR
|
HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() }
|
||||||
Box(
|
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
|
||||||
|
page = HubPage.NODES
|
||||||
|
}
|
||||||
|
if (FipsNative.available) {
|
||||||
|
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
|
||||||
|
}
|
||||||
|
if (onMeshParty != null) {
|
||||||
|
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
|
||||||
|
}
|
||||||
|
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||||
|
// the settings button — not here.
|
||||||
|
}
|
||||||
|
|
||||||
|
HubPage.NODES -> {
|
||||||
|
servers.forEach { server ->
|
||||||
|
val active = server.serialize() == activeServer?.serialize()
|
||||||
|
MenuItem(
|
||||||
|
label = server.displayName(),
|
||||||
|
selected = active,
|
||||||
|
onClick = { onSelectServer(server) },
|
||||||
|
onEdit = { startEdit(server) },
|
||||||
|
onRemove = { onRemoveServer(server) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (servers.isEmpty()) {
|
||||||
|
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAdd || editing != null) {
|
||||||
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.size(ROW_H)
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(ROW_R))
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
.background(RowBg)
|
.background(FieldBg)
|
||||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
.clickable { onScanQr() },
|
.padding(12.dp),
|
||||||
contentAlignment = Alignment.Center,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Row(
|
||||||
Icons.Default.QrCodeScanner,
|
Modifier.fillMaxWidth(),
|
||||||
contentDescription = stringResource(R.string.add_server_qr),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
tint = BitcoinOrange,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier.size(24.dp),
|
) {
|
||||||
|
Text(
|
||||||
|
if (editing != null) "Edit Server" else "Add Server",
|
||||||
|
color = TextMuted, fontSize = 13.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Cancel", color = TextMuted, fontSize = 13.sp,
|
||||||
|
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GlassField(
|
||||||
|
value = nm, onValueChange = { nm = it },
|
||||||
|
placeholder = "Name (optional)",
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||||
)
|
)
|
||||||
|
GlassField(
|
||||||
|
value = addr, onValueChange = { addr = it.trim() },
|
||||||
|
placeholder = "192.168.1.100",
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
GlassField(
|
||||||
|
value = pwd, onValueChange = { pwd = it },
|
||||||
|
placeholder = "Password",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
||||||
|
keyboardActions = KeyboardActions(onGo = { submit() }),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
|
||||||
|
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||||
|
.clickable { submit() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
||||||
|
}
|
||||||
|
// HTTPS scheme toggle (available on both add and edit).
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
|
.clickable { https = !https }
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text("Use HTTPS", color = TextMuted, fontSize = 13.sp)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(46.dp).height(26.dp)
|
||||||
|
.clip(RoundedCornerShape(13.dp))
|
||||||
|
.background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg)
|
||||||
|
.border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)),
|
||||||
|
contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 3.dp)
|
||||||
|
.size(20.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(if (https) Color.White else TextMuted),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||||
|
}
|
||||||
|
if (onScanQr != null) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(ROW_H)
|
||||||
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
|
.background(RowBg)
|
||||||
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
|
.clickable { onScanQr() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.QrCodeScanner,
|
||||||
|
contentDescription = stringResource(R.string.add_server_qr),
|
||||||
|
tint = BitcoinOrange,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HubPage.FIPS -> {
|
||||||
|
FipsSection(embedded = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(2.dp))
|
private enum class HubPage { HUB, NODES, FIPS }
|
||||||
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
|
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
|
|
||||||
// Mode toggle
|
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
||||||
MenuItem(
|
@Composable
|
||||||
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
|
private fun HubCard(
|
||||||
onClick = onToggleMode,
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
)
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
// Style toggle
|
onClick: () -> Unit,
|
||||||
MenuItem(
|
) {
|
||||||
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
|
Row(
|
||||||
onClick = onToggleStyle,
|
Modifier
|
||||||
)
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
// Phone↔phone mesh pairing + chat
|
.background(RowBg)
|
||||||
if (onMeshParty != null) {
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
MenuItem(label = "Mesh Party", labelColor = BitcoinOrange, onClick = onMeshParty)
|
.clickable { onClick() }
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp))
|
||||||
}
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Back to dashboard
|
/** Small circular icon button used in the hub header. */
|
||||||
if (onBackToWebView != null) {
|
@Composable
|
||||||
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
|
private fun IconRound(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
desc: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(RowBg)
|
||||||
|
.border(1.dp, RowBorder, RoundedCornerShape(20.dp))
|
||||||
|
.clickable { onClick() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */
|
||||||
|
private data class FipsInfo(
|
||||||
|
val available: Boolean,
|
||||||
|
val running: Boolean,
|
||||||
|
val npub: String,
|
||||||
|
val meshAddress: String,
|
||||||
|
val peerCount: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIPS mesh oversight: shows what the phone's embedded mesh node is doing —
|
||||||
|
* running state, its mesh identity (npub), its mesh address (fd…ULA), how
|
||||||
|
* many peers/anchors it's configured with — and a one-tap Reconnect that
|
||||||
|
* re-homes the mesh (also the manual fix if a network handoff ever misses).
|
||||||
|
* Collapsed by default so the menu stays compact.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FipsSection(embedded: Boolean = false) {
|
||||||
|
if (!FipsNative.available) return
|
||||||
|
val context = LocalContext.current
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
var expanded by remember { mutableStateOf(embedded) }
|
||||||
|
var info by remember { mutableStateOf<FipsInfo?>(null) }
|
||||||
|
|
||||||
|
// Load identity/state when the section opens (cheap DataStore + JSON read).
|
||||||
|
LaunchedEffect(expanded) {
|
||||||
|
if (expanded && info == null) {
|
||||||
|
val prefs = FipsPreferences(context)
|
||||||
|
val id = prefs.identity()
|
||||||
|
val peers = runCatching {
|
||||||
|
org.json.JSONArray(prefs.peersJson()).length()
|
||||||
|
}.getOrDefault(0)
|
||||||
|
info = FipsInfo(
|
||||||
|
available = true,
|
||||||
|
running = FipsNative.isRunning(),
|
||||||
|
npub = id?.npub.orEmpty(),
|
||||||
|
meshAddress = id?.address.orEmpty(),
|
||||||
|
peerCount = peers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
// Embedded in the hub's FIPS sub-page the header row would duplicate
|
||||||
|
// the page title, so only the standalone (collapsible) form shows it.
|
||||||
|
if (!embedded) MenuItem(
|
||||||
|
label = "FIPS Mesh",
|
||||||
|
labelColor = BitcoinOrange,
|
||||||
|
onClick = { expanded = !expanded },
|
||||||
|
)
|
||||||
|
if (expanded) {
|
||||||
|
val i = info
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 6.dp)
|
||||||
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
|
.background(FieldBg)
|
||||||
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
|
.padding(14.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
if (i == null) {
|
||||||
|
Text("Loading…", color = TextMuted, fontSize = 13.sp)
|
||||||
|
} else {
|
||||||
|
FipsRow("Status", if (i.running) "Connected" else "Stopped",
|
||||||
|
valueColor = if (i.running) BitcoinOrange else TextMuted)
|
||||||
|
if (i.meshAddress.isNotBlank()) {
|
||||||
|
FipsRow("Mesh address", i.meshAddress, mono = true,
|
||||||
|
onCopy = { clipboard.setText(AnnotatedString(i.meshAddress)) })
|
||||||
|
}
|
||||||
|
if (i.npub.isNotBlank()) {
|
||||||
|
FipsRow("Identity (npub)", i.npub, mono = true,
|
||||||
|
onCopy = { clipboard.setText(AnnotatedString(i.npub)) })
|
||||||
|
}
|
||||||
|
FipsRow("Peers & anchors", i.peerCount.toString())
|
||||||
|
Text(
|
||||||
|
"Your node reaches this phone over the mesh by its npub — no ports opened to the internet.",
|
||||||
|
color = TextMuted, fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
MenuItem(
|
||||||
|
label = "Reconnect mesh",
|
||||||
|
labelColor = BitcoinOrange,
|
||||||
|
onClick = {
|
||||||
|
FipsManager.requestMeshRestart(context)
|
||||||
|
info = null
|
||||||
|
if (!embedded) expanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FipsRow(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
valueColor: Color = TextPrimary,
|
||||||
|
mono: Boolean = false,
|
||||||
|
onCopy: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp))
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
color = valueColor,
|
||||||
|
fontSize = if (mono) 11.sp else 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
)
|
||||||
|
if (onCopy != null) {
|
||||||
|
Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ fun NESPortraitController(
|
|||||||
onMouseScroll: (Int) -> Unit = { _ -> },
|
onMouseScroll: (Int) -> Unit = { _ -> },
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
onPlayerToggle: () -> Unit = {},
|
onPlayerToggle: () -> Unit = {},
|
||||||
|
onToggleStyle: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val c = paletteFor(style)
|
val c = paletteFor(style)
|
||||||
val isClassic = style == ControllerStyle.CLASSIC
|
val isClassic = style == ControllerStyle.CLASSIC
|
||||||
@ -151,6 +152,10 @@ fun NESPortraitController(
|
|||||||
PlayerPill(c, playerId, onPlayerToggle)
|
PlayerPill(c, playerId, onPlayerToggle)
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
SettingsBtn(c, Modifier, onMenu)
|
SettingsBtn(c, Modifier, onMenu)
|
||||||
|
onToggleStyle?.let {
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
StyleBtn(c, Modifier, it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -238,6 +238,7 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
|||||||
val mainExecutor = ContextCompat.getMainExecutor(context)
|
val mainExecutor = ContextCompat.getMainExecutor(context)
|
||||||
val providerFuture = ProcessCameraProvider.getInstance(context)
|
val providerFuture = ProcessCameraProvider.getInstance(context)
|
||||||
var provider: ProcessCameraProvider? = null
|
var provider: ProcessCameraProvider? = null
|
||||||
|
val focusScheduler = Executors.newSingleThreadScheduledExecutor()
|
||||||
|
|
||||||
providerFuture.addListener({
|
providerFuture.addListener({
|
||||||
val p = providerFuture.get()
|
val p = providerFuture.get()
|
||||||
@ -245,12 +246,15 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
|||||||
val preview = Preview.Builder().build().also {
|
val preview = Preview.Builder().build().also {
|
||||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||||
}
|
}
|
||||||
// CameraX's analysis default is 640x480 — too few pixels per module
|
// Dense Lightning-invoice QRs need BOTH enough pixels per module and
|
||||||
// to decode a modal-sized QR at arm's length. 1280x720 more than
|
// sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
|
||||||
// doubles the pixel density at negligible analysis cost.
|
// lens, which won't focus close) left dense invoices undecodable
|
||||||
|
// while sparse address QRs still read — the "scanner doesn't pick up
|
||||||
|
// invoices" report. 1920x1080 roughly doubles module resolution so a
|
||||||
|
// QR held at the camera's actual focus distance still resolves.
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val analysis = ImageAnalysis.Builder()
|
val analysis = ImageAnalysis.Builder()
|
||||||
.setTargetResolution(android.util.Size(1280, 720))
|
.setTargetResolution(android.util.Size(1920, 1080))
|
||||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
.build()
|
.build()
|
||||||
.also {
|
.also {
|
||||||
@ -261,13 +265,27 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
p.unbindAll()
|
p.unbindAll()
|
||||||
p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||||
|
// Force a centre autofocus on a repeating tick. A hand-held QR is
|
||||||
|
// a static scene, so continuous-AF often never retriggers and the
|
||||||
|
// lens sits at its resting (far) focus — fatal for dense codes.
|
||||||
|
// A normalized centre point works before the view is measured.
|
||||||
|
val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
|
||||||
|
.createPoint(0.5f, 0.5f)
|
||||||
|
val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
|
||||||
|
point,
|
||||||
|
androidx.camera.core.FocusMeteringAction.FLAG_AF,
|
||||||
|
).disableAutoCancel().build()
|
||||||
|
focusScheduler.scheduleWithFixedDelay({
|
||||||
|
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
|
||||||
|
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Camera unavailable — the user can dismiss and enter details manually.
|
// Camera unavailable — the user can dismiss and enter details manually.
|
||||||
}
|
}
|
||||||
}, mainExecutor)
|
}, mainExecutor)
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
|
focusScheduler.shutdownNow()
|
||||||
provider?.unbindAll()
|
provider?.unbindAll()
|
||||||
analysisExecutor.shutdown()
|
analysisExecutor.shutdown()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,9 +12,11 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import com.archipelago.app.data.PairResult
|
import com.archipelago.app.data.PairResult
|
||||||
import com.archipelago.app.data.ServerEntry
|
import com.archipelago.app.data.ServerEntry
|
||||||
import com.archipelago.app.data.ServerPreferences
|
import com.archipelago.app.data.ServerPreferences
|
||||||
@ -177,11 +179,25 @@ fun AppNavHost(
|
|||||||
onRemoteInput = {
|
onRemoteInput = {
|
||||||
navController.navigate(Routes.REMOTE_INPUT)
|
navController.navigate(Routes.REMOTE_INPUT)
|
||||||
},
|
},
|
||||||
|
onRemoteKeyboard = {
|
||||||
|
navController.navigate("${Routes.REMOTE_INPUT}?keyboard=true")
|
||||||
|
},
|
||||||
|
onMeshParty = {
|
||||||
|
navController.navigate(Routes.MESH_PARTY)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(Routes.REMOTE_INPUT) {
|
composable(
|
||||||
|
"${Routes.REMOTE_INPUT}?keyboard={keyboard}",
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("keyboard") {
|
||||||
|
type = NavType.BoolType
|
||||||
|
defaultValue = false
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) { entry ->
|
||||||
RemoteInputScreen(
|
RemoteInputScreen(
|
||||||
onBack = {
|
onBack = {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
@ -189,6 +205,7 @@ fun AppNavHost(
|
|||||||
onMeshParty = {
|
onMeshParty = {
|
||||||
navController.navigate(Routes.MESH_PARTY)
|
navController.navigate(Routes.MESH_PARTY)
|
||||||
},
|
},
|
||||||
|
startInKeyboard = entry.arguments?.getBoolean("keyboard") == true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import android.content.res.Configuration
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -54,7 +56,12 @@ import com.archipelago.app.ui.theme.TextMuted
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
fun RemoteInputScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onMeshParty: (() -> Unit)? = null,
|
||||||
|
// Land on the keyboard instead of the gamepad (hub menu's Keyboard card).
|
||||||
|
startInKeyboard: Boolean = false,
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val prefs = remember { ServerPreferences(context) }
|
val prefs = remember { ServerPreferences(context) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@ -63,7 +70,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
|||||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||||
|
|
||||||
var isGamepadMode by remember { mutableStateOf(true) }
|
var isGamepadMode by remember { mutableStateOf(!startInKeyboard) }
|
||||||
var showModal by remember { mutableStateOf(false) }
|
var showModal by remember { mutableStateOf(false) }
|
||||||
var showQrScanner by remember { mutableStateOf(false) }
|
var showQrScanner by remember { mutableStateOf(false) }
|
||||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
||||||
@ -90,6 +97,9 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
|||||||
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
||||||
ws.playerId = playerId
|
ws.playerId = playerId
|
||||||
}
|
}
|
||||||
|
fun toggleStyle() {
|
||||||
|
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||||
|
}
|
||||||
val connectionState by ws.state.collectAsState()
|
val connectionState by ws.state.collectAsState()
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
@ -153,6 +163,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
|||||||
onKey = { ws.sendKey(it) },
|
onKey = { ws.sendKey(it) },
|
||||||
onMenu = { showModal = true },
|
onMenu = { showModal = true },
|
||||||
onPlayerToggle = ::togglePlayer,
|
onPlayerToggle = ::togglePlayer,
|
||||||
|
onToggleStyle = ::toggleStyle,
|
||||||
)
|
)
|
||||||
isGamepadMode && !isLandscape -> NESPortraitController(
|
isGamepadMode && !isLandscape -> NESPortraitController(
|
||||||
style = controllerStyle,
|
style = controllerStyle,
|
||||||
@ -163,6 +174,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
|||||||
onMouseScroll = { ws.sendScroll(it) },
|
onMouseScroll = { ws.sendScroll(it) },
|
||||||
onMenu = { showModal = true },
|
onMenu = { showModal = true },
|
||||||
onPlayerToggle = ::togglePlayer,
|
onPlayerToggle = ::togglePlayer,
|
||||||
|
onToggleStyle = ::toggleStyle,
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
||||||
@ -182,12 +194,20 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Settings icon top-right in keyboard mode
|
// Settings + style icons top-right in keyboard mode
|
||||||
com.archipelago.app.ui.components.SettingsBtn(
|
Row(
|
||||||
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
onClick = { showModal = true },
|
) {
|
||||||
)
|
com.archipelago.app.ui.components.SettingsBtn(
|
||||||
|
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
||||||
|
onClick = { showModal = true },
|
||||||
|
)
|
||||||
|
com.archipelago.app.ui.components.StyleBtn(
|
||||||
|
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
||||||
|
onClick = ::toggleStyle,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -210,8 +230,6 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
|||||||
visible = showModal,
|
visible = showModal,
|
||||||
servers = savedServers,
|
servers = savedServers,
|
||||||
activeServer = activeServer,
|
activeServer = activeServer,
|
||||||
isGamepadMode = isGamepadMode,
|
|
||||||
controllerStyle = controllerStyle,
|
|
||||||
onDismiss = { showModal = false },
|
onDismiss = { showModal = false },
|
||||||
onSelectServer = { server ->
|
onSelectServer = { server ->
|
||||||
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
|
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
|
||||||
@ -245,10 +263,8 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
|
onRemote = { isGamepadMode = true; showModal = false },
|
||||||
onToggleStyle = {
|
onKeyboard = { isGamepadMode = false; showModal = false },
|
||||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
|
||||||
},
|
|
||||||
onBackToWebView = { showModal = false; onBack() },
|
onBackToWebView = { showModal = false; onBack() },
|
||||||
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
|
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
|
||||||
)
|
)
|
||||||
|
|||||||
@ -88,8 +88,11 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||||||
import android.webkit.ValueCallback
|
import android.webkit.ValueCallback
|
||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerPreferences
|
import com.archipelago.app.data.ServerPreferences
|
||||||
|
import com.archipelago.app.fips.FipsManager
|
||||||
import com.archipelago.app.ui.components.GestureHintOverlay
|
import com.archipelago.app.ui.components.GestureHintOverlay
|
||||||
import com.archipelago.app.ui.components.MeshLoadingScreen
|
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||||
|
import com.archipelago.app.ui.components.NESMenu
|
||||||
|
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||||
import com.archipelago.app.ui.components.WalletQrScannerModal
|
import com.archipelago.app.ui.components.WalletQrScannerModal
|
||||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
import com.archipelago.app.ui.theme.ErrorRed
|
import com.archipelago.app.ui.theme.ErrorRed
|
||||||
@ -179,6 +182,78 @@ private fun injectSafeAreaVars(view: WebView) {
|
|||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
|
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
|
||||||
|
// Vue components sample the var into reactive state; tell them it
|
||||||
|
// changed (an authenticated session can mount before we run).
|
||||||
|
window.dispatchEvent(new CustomEvent('archy-insets', { detail: { top: ${sat}, bottom: ${sab} } }));
|
||||||
|
})();
|
||||||
|
""".trimIndent(),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-app browser pages (node apps + same-node links) don't consume the
|
||||||
|
* neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge
|
||||||
|
* their content ran up under the status bar. Pad the document body down by
|
||||||
|
* 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
|
||||||
|
val density = view.resources.displayMetrics.density
|
||||||
|
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
||||||
|
if (sat <= 0) return
|
||||||
|
view.evaluateJavascript(
|
||||||
|
"""
|
||||||
|
(function() {
|
||||||
|
var SAT = $sat;
|
||||||
|
var s = document.getElementById('archy-top-inset');
|
||||||
|
if (!s) {
|
||||||
|
s = document.createElement('style');
|
||||||
|
s.id = 'archy-top-inset';
|
||||||
|
(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 });
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
null,
|
null,
|
||||||
@ -198,13 +273,17 @@ private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
|
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
|
||||||
* (patient — a cold session may still be establishing), else LAN anyway so
|
* (patient — a cold session may still be establishing). If NEITHER answers,
|
||||||
* the existing error/fallback path handles it. */
|
* fall back to the mesh URL when we have one — off-LAN the LAN IP is
|
||||||
|
* unreachable, and loading it just produced a confusing "can't reach
|
||||||
|
* 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL
|
||||||
|
* instead means the load retries against the path that's actually coming up,
|
||||||
|
* and any error shows the mesh address rather than a dead LAN IP. */
|
||||||
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
|
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
|
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
|
||||||
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
|
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
|
||||||
lanUrl
|
meshUrl ?: lanUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
||||||
@ -245,6 +324,10 @@ fun WebViewScreen(
|
|||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
onRemoteInput: () -> Unit = {},
|
onRemoteInput: () -> Unit = {},
|
||||||
|
// Like onRemoteInput but landing on the keyboard (the hub menu's Keyboard card).
|
||||||
|
onRemoteKeyboard: () -> Unit = {},
|
||||||
|
// Opens the phone-to-phone Mesh Party screen; null hides its hub card.
|
||||||
|
onMeshParty: (() -> Unit)? = null,
|
||||||
// Stored password for this server (from QR pairing or manual entry). When
|
// Stored password for this server (from QR pairing or manual entry). When
|
||||||
// non-blank, the login page is auto-filled and submitted — the one-step
|
// non-blank, the login page is auto-filled and submitted — the one-step
|
||||||
// demo flow from docs/companion-pairing-qr.md.
|
// demo flow from docs/companion-pairing-qr.md.
|
||||||
@ -323,6 +406,14 @@ fun WebViewScreen(
|
|||||||
// One-time three-finger-hold teaching overlay (initial=true: never flash
|
// One-time three-finger-hold teaching overlay (initial=true: never flash
|
||||||
// it while DataStore is still loading).
|
// it while DataStore is still loading).
|
||||||
val prefs = remember { ServerPreferences(webViewContext) }
|
val prefs = remember { ServerPreferences(webViewContext) }
|
||||||
|
|
||||||
|
// Hub menu overlay state — the three-finger hold opens the menu right here
|
||||||
|
// over the dashboard (it used to jump to the remote screen).
|
||||||
|
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||||
|
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||||
|
var showHubMenu by remember { mutableStateOf(false) }
|
||||||
|
var showPairScanner by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
|
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
|
||||||
var gestureHintDismissed by remember { mutableStateOf(false) }
|
var gestureHintDismissed by remember { mutableStateOf(false) }
|
||||||
// Don't teach the gesture on top of the login/splash — arm the overlay
|
// Don't teach the gesture on top of the login/splash — arm the overlay
|
||||||
@ -708,7 +799,8 @@ fun WebViewScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Three-finger hold (500ms) → navigate to remote input.
|
// Three-finger hold (500ms) → open the hub menu overlay
|
||||||
|
// in place (Remote/Keyboard cards do the navigating).
|
||||||
// Three fingers, not two: two-finger scroll/pinch on the
|
// Three fingers, not two: two-finger scroll/pinch on the
|
||||||
// page collided with the old two-finger hold.
|
// page collided with the old two-finger hold.
|
||||||
var threeFingerStart = 0L
|
var threeFingerStart = 0L
|
||||||
@ -726,7 +818,7 @@ fun WebViewScreen(
|
|||||||
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
|
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
|
||||||
if (System.currentTimeMillis() - threeFingerStart > 500) {
|
if (System.currentTimeMillis() - threeFingerStart > 500) {
|
||||||
threeFingerFired = true
|
threeFingerFired = true
|
||||||
onRemoteInput()
|
showHubMenu = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -851,6 +943,68 @@ fun WebViewScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hub menu overlay — opened by the three-finger hold, drawn above
|
||||||
|
// everything (also reachable from the error screen, where switching
|
||||||
|
// servers is exactly what's needed).
|
||||||
|
NESMenu(
|
||||||
|
visible = showHubMenu,
|
||||||
|
servers = savedServers,
|
||||||
|
activeServer = activeServer,
|
||||||
|
onDismiss = { showHubMenu = false },
|
||||||
|
onSelectServer = { server ->
|
||||||
|
showHubMenu = false
|
||||||
|
scope.launch { prefs.setActiveServer(server) }
|
||||||
|
},
|
||||||
|
onAddServer = { server ->
|
||||||
|
scope.launch {
|
||||||
|
prefs.addSavedServer(server)
|
||||||
|
if (activeServer == null) prefs.setActiveServer(server)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onScanQr = { showPairScanner = true },
|
||||||
|
onEditServer = { original, updated ->
|
||||||
|
scope.launch {
|
||||||
|
prefs.updateSavedServer(original, updated)
|
||||||
|
// Editing the live server reloads the kiosk with the new
|
||||||
|
// address/credentials via the activeServer recomposition.
|
||||||
|
if (original.serialize() == activeServer?.serialize()) {
|
||||||
|
prefs.setActiveServer(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRemoveServer = { server ->
|
||||||
|
scope.launch {
|
||||||
|
prefs.removeSavedServer(server)
|
||||||
|
// Nothing left to show — back to the Connect screen.
|
||||||
|
val remaining = savedServers.count { it.serialize() != server.serialize() }
|
||||||
|
if (remaining == 0) {
|
||||||
|
prefs.clearActiveServer()
|
||||||
|
showHubMenu = false
|
||||||
|
onDisconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRemote = { showHubMenu = false; onRemoteInput() },
|
||||||
|
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
|
||||||
|
onBackToWebView = { showHubMenu = false },
|
||||||
|
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pairing-QR scan launched from the menu's Nodes page; the menu stays
|
||||||
|
// open behind it so the new entry appears as soon as it closes.
|
||||||
|
QrScannerOverlay(
|
||||||
|
visible = showPairScanner,
|
||||||
|
onDismiss = { showPairScanner = false },
|
||||||
|
onServerScanned = { scan ->
|
||||||
|
showPairScanner = false
|
||||||
|
scope.launch {
|
||||||
|
val merged = prefs.upsertServer(scan.server)
|
||||||
|
FipsManager.registerNode(webViewContext, scan.fips, merged.displayName())
|
||||||
|
if (activeServer == null) prefs.setActiveServer(merged)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -897,7 +1051,17 @@ private fun InAppBrowser(
|
|||||||
fun isSameNode(u: String): Boolean =
|
fun isSameNode(u: String): Boolean =
|
||||||
isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl))
|
isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl))
|
||||||
var browser by remember { mutableStateOf<WebView?>(null) }
|
var browser by remember { mutableStateOf<WebView?>(null) }
|
||||||
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
|
// Loader title: never show a raw IP host — a mesh ULA like
|
||||||
|
// [fd79:1aa:…] is technically the host but reads as garbage on the
|
||||||
|
// loading screen. Show a neutral name until the page reports its
|
||||||
|
// real <title> (onReceivedTitle upgrades it).
|
||||||
|
var title by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
android.net.Uri.parse(url).host
|
||||||
|
?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) }
|
||||||
|
?: "Archipelago",
|
||||||
|
)
|
||||||
|
}
|
||||||
var favicon by remember { mutableStateOf<Bitmap?>(null) }
|
var favicon by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
var progress by remember { mutableIntStateOf(0) }
|
var progress by remember { mutableIntStateOf(0) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
@ -935,12 +1099,21 @@ private fun InAppBrowser(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(SurfaceBlack)
|
.background(SurfaceBlack)
|
||||||
// Bottom inset handled by the touch-shield strip below the bar —
|
// Whole-overlay touch shield: every touch not handled by a child
|
||||||
// NOT by padding: a padded area only paints, it doesn't consume,
|
// (control-bar gaps, inset strips) dies here instead of falling
|
||||||
// so taps in the gesture strip fell straight THROUGH this overlay
|
// through to the kiosk's tab bar behind (a near-miss on Close
|
||||||
// into the kiosk's tab bar behind it (accidental AIUI-tab hits).
|
// was opening the AIUI tab underneath).
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
// Bottom inset handled by the touch-shield strip below the bar.
|
||||||
|
// No TOP inset padding: the WebView draws edge-to-edge behind the
|
||||||
|
// status bar so the app's own background fills it — the padded
|
||||||
|
// version painted an opaque black bar there (user-rejected look).
|
||||||
.windowInsetsPadding(
|
.windowInsetsPadding(
|
||||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)
|
WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
// WebView + loading overlay fill the area above the bottom control bar.
|
// WebView + loading overlay fill the area above the bottom control bar.
|
||||||
@ -992,12 +1165,14 @@ private fun InAppBrowser(
|
|||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||||
loading = true
|
loading = true
|
||||||
|
view?.let { injectTopInset(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView?, u: String?) {
|
override fun onPageFinished(view: WebView?, u: String?) {
|
||||||
loading = false
|
loading = false
|
||||||
canGoBack = view?.canGoBack() == true
|
canGoBack = view?.canGoBack() == true
|
||||||
canGoForward = view?.canGoForward() == true
|
canGoForward = view?.canGoForward() == true
|
||||||
|
view?.let { injectTopInset(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
|
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
|
||||||
|
|||||||
2
Android/rust/archy-fips-core/Cargo.lock
generated
2
Android/rust/archy-fips-core/Cargo.lock
generated
@ -464,7 +464,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "fips"
|
name = "fips"
|
||||||
version = "0.3.0-dev"
|
version = "0.3.0-dev"
|
||||||
source = "git+https://github.com/9qeklajc/fips-native?rev=46494a74fc85b878305d275d42ab719082b06ba6#46494a74fc85b878305d275d42ab719082b06ba6"
|
source = "git+https://github.com/Zazawowow/fips-native?rev=07d21d4482be56b14295d2525e41f8386d1bfe6f#07d21d4482be56b14295d2525e41f8386d1bfe6f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bech32",
|
"bech32",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
|
|||||||
@ -23,7 +23,10 @@ crate-type = ["lib", "cdylib"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd.
|
# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd.
|
||||||
fips = { git = "https://github.com/9qeklajc/fips-native", rev = "46494a74fc85b878305d275d42ab719082b06ba6", default-features = false, features = ["tun-support"] }
|
# Zazawowow/fips-native `fast-join-pinned` = upstream pinned rev 46494a74 +
|
||||||
|
# discovery re-fire on topology change (fresh 5G join: first route no longer
|
||||||
|
# waits out doomed pre-join lookups). Upstream 9qeklajc denies pushes.
|
||||||
|
fips = { git = "https://github.com/Zazawowow/fips-native", rev = "07d21d4482be56b14295d2525e41f8386d1bfe6f", default-features = false, features = ["tun-support"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
|||||||
@ -85,6 +85,27 @@ pub fn build_config(secret: &str, peers: Vec<PeerConfig>, listen_port: u16) -> C
|
|||||||
});
|
});
|
||||||
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
|
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
|
||||||
cfg.transports.tcp = TransportInstances::Single(Default::default());
|
cfg.transports.tcp = TransportInstances::Single(Default::default());
|
||||||
|
// Fast-connect profile — a phone opens the app and expects the node NOW.
|
||||||
|
// Stock pacing is tuned for always-on routers: a failed discovery backs
|
||||||
|
// off 30s, session resends gap out to 8-16s, dead links redial at
|
||||||
|
// 5s→300s. Over 5G that stacked into a ~40s first connect (observed
|
||||||
|
// 2026-07-24: anchor +10s, session +40s). Retries only fire while a
|
||||||
|
// link/session is down, so steady-state traffic is unchanged.
|
||||||
|
cfg.node.retry.base_interval_secs = 1; // dead-link redial 1s,2s,4s…
|
||||||
|
cfg.node.retry.max_backoff_secs = 30; // …capped at 30s, not 5 min
|
||||||
|
cfg.node.retry.max_retries = 30;
|
||||||
|
cfg.node.rate_limit.handshake_resend_interval_ms = 400;
|
||||||
|
cfg.node.rate_limit.handshake_resend_backoff = 1.5;
|
||||||
|
cfg.node.rate_limit.handshake_max_resends = 10;
|
||||||
|
cfg.node.discovery.backoff_base_secs = 1; // failed lookup retries fast
|
||||||
|
cfg.node.discovery.backoff_max_secs = 30;
|
||||||
|
cfg.node.discovery.retry_interval_secs = 2;
|
||||||
|
cfg.node.discovery.max_attempts = 3;
|
||||||
|
// Lookups launched before the tree position settles are doomed; a 10s
|
||||||
|
// completion timeout made each one cost 10s before the 1s retry could
|
||||||
|
// fire (observed: 19s to a route on a fresh join). Fail fast instead —
|
||||||
|
// the resend-within-window above still gives each attempt two shots.
|
||||||
|
cfg.node.discovery.timeout_secs = 5;
|
||||||
cfg.peers = peers;
|
cfg.peers = peers;
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
|
|||||||
45
CHANGELOG.md
45
CHANGELOG.md
@ -1,5 +1,50 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.7.117-alpha (2026-07-29)
|
||||||
|
|
||||||
|
- Flash your LoRa radio from inside the app. The Mesh page now has a "Flash LoRa" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards.
|
||||||
|
- Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband.
|
||||||
|
- Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware.
|
||||||
|
- Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists.
|
||||||
|
- Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing.
|
||||||
|
- The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath.
|
||||||
|
- Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there.
|
||||||
|
- Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest "pending" state, and reports the true final outcome.
|
||||||
|
- Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches.
|
||||||
|
- The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks.
|
||||||
|
- FIPS mesh uptime hardening continues: the node's peer port is opened explicitly everywhere, LAN anchors use the right port, direct peering between co-located nodes works again, dials fail fast instead of hanging, and a connectivity watcher re-applies anchors immediately when the network comes back.
|
||||||
|
- 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 app 0.5.25: a redesigned settings hub (three-finger tap opens it over the dashboard), seamless transport handoff with FIPS mesh settings, the wallet scanner reads dense invoice QR codes, app webviews clear the phone status bar with an HTTPS toggle on add/edit, and off-LAN loads fall back to the mesh URL instead of a dead LAN address.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup.
|
||||||
|
- Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts.
|
||||||
|
|
||||||
|
## v1.7.115-alpha (2026-07-26)
|
||||||
|
|
||||||
|
- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update.
|
||||||
|
- The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing.
|
||||||
|
- Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades.
|
||||||
|
|
||||||
|
## v1.7.114-alpha (2026-07-26)
|
||||||
|
|
||||||
|
- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in.
|
||||||
|
- The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each.
|
||||||
|
- Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves.
|
||||||
|
- Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type.
|
||||||
|
- Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets.
|
||||||
|
|
||||||
|
## v1.7.113-alpha (2026-07-25)
|
||||||
|
|
||||||
|
- Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it.
|
||||||
|
- Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list.
|
||||||
|
- The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance.
|
||||||
|
- The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match.
|
||||||
|
|
||||||
## v1.7.112-alpha (2026-07-23)
|
## v1.7.112-alpha (2026-07-23)
|
||||||
|
|
||||||
- Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.
|
- Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically.
|
||||||
|
|||||||
19
CODE_OF_CONDUCT.md
Normal file
19
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# 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.
|
||||||
191
CONTRIBUTING.md
191
CONTRIBUTING.md
@ -1,161 +1,100 @@
|
|||||||
# Contributing to Archipelago
|
# Contributing to Archipelago
|
||||||
|
|
||||||
Thank you for your interest in contributing to Archipelago! This document covers the process for contributing code, reporting bugs, and submitting apps.
|
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.
|
||||||
|
|
||||||
## Code of Conduct
|
## Development setup
|
||||||
|
|
||||||
Be respectful. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
|
### Frontend
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
cd neode-ui
|
cd neode-ui
|
||||||
npm install
|
npm install
|
||||||
npm start # Dev server on :8100
|
npm start
|
||||||
npm run type-check # TypeScript validation
|
npm run type-check
|
||||||
npm run build # Production build
|
npm test
|
||||||
npm test # Run tests
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend (Rust)
|
### Backend
|
||||||
|
|
||||||
Build on a Linux server (Debian 13), **not** macOS:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo clippy --all-targets --all-features
|
cd core
|
||||||
cargo fmt --all
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
cargo test --all-features
|
cargo test --all-features
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deploy to dev server
|
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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/deploy-to-target.sh --live
|
./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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Style
|
App submissions must:
|
||||||
|
|
||||||
### Frontend (TypeScript + Vue)
|
- 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.
|
||||||
|
|
||||||
- `<script setup lang="ts">` — always Composition API
|
## Code style
|
||||||
- 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
|
|
||||||
|
|
||||||
### Backend (Rust)
|
- 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.
|
||||||
|
|
||||||
- No `unwrap()` or `expect()` in production code — use `?` operator
|
## Pull requests
|
||||||
- `thiserror` for library errors, `anyhow` for application errors
|
|
||||||
- `tracing` for structured logging — never `println!`
|
|
||||||
- Run `cargo clippy` and `cargo fmt` before commits
|
|
||||||
|
|
||||||
### General
|
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.
|
||||||
|
|
||||||
- Functions under 50 lines, single responsibility
|
Suggested commit format:
|
||||||
- Comment WHY not WHAT
|
|
||||||
- Remove dead code — never comment it out
|
|
||||||
- No `TODO`/`FIXME` in commits
|
|
||||||
|
|
||||||
## Commit Format
|
```text
|
||||||
|
feat: add backup scheduling
|
||||||
```
|
fix: reject unsafe manifest volume
|
||||||
type: description
|
docs: clarify app deployment flow
|
||||||
|
test: cover catalog drift check
|
||||||
```
|
```
|
||||||
|
|
||||||
**Types**: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`
|
## Reporting bugs
|
||||||
|
|
||||||
Examples:
|
Include:
|
||||||
- `feat: add backup scheduling to settings page`
|
|
||||||
- `fix: handle WiFi connection timeout gracefully`
|
|
||||||
- `test: add unit tests for RPC client retry logic`
|
|
||||||
|
|
||||||
## Pull Request Process
|
- exact version or commit;
|
||||||
|
- host platform and architecture;
|
||||||
|
- steps to reproduce;
|
||||||
|
- expected and actual behavior;
|
||||||
|
- logs from the relevant component;
|
||||||
|
- screenshots for UI issues.
|
||||||
|
|
||||||
1. Ensure your branch is up to date with `main`
|
## Security
|
||||||
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
|
|
||||||
|
|
||||||
### PR Checklist
|
Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
- [ ] 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
|
## License
|
||||||
|
|
||||||
By contributing, you agree that your contributions will be licensed under the same license as the project.
|
By contributing, you agree that your contribution is licensed under the
|
||||||
|
project's MIT License.
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
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
Normal file
73
NOTICE
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# 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
229
README.md
@ -1,8 +1,11 @@
|
|||||||
# Archipelago
|
# Archipelago
|
||||||
|
|
||||||
> Self-Sovereign Bitcoin Node OS
|
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
|
||||||
|
|
||||||
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, mesh communication, and decentralized identity through a glassmorphism web UI.
|
Archipelago is a bootable personal server OS 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.
|
||||||
|
|
||||||
[](https://www.debian.org/)
|
[](https://www.debian.org/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
@ -10,193 +13,101 @@
|
|||||||
[](https://vuejs.org/)
|
[](https://vuejs.org/)
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
## Philosophy
|
## What is here
|
||||||
|
|
||||||
Archipelago is being built as a **developer-ready app platform**, not a fixed appliance:
|
- `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.
|
||||||
|
|
||||||
- **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.
|
## Platform model
|
||||||
- **Signed distribution.** App manifests ship inside an Ed25519-signed catalog verified against a pinned release-root key, not as loose files on disk. OTA release manifests are signed the same way.
|
|
||||||
- **Decentralized marketplace.** Third-party developers publish apps via Nostr-based discovery (NIP-78) with DID-signed manifests and federation-weighted trust scoring — no gatekept central store.
|
|
||||||
- **Rootless and secure by default.** Rootless Podman only. Read-only root, no-new-privileges, capability allow-list, secrets materialised 0600 and never logged. Never rootful, never a Docker socket mount.
|
|
||||||
- **100%-uptime-capable.** Every container is a systemd Quadlet unit under `user.slice` that survives backend restarts; a level-triggered reconciler self-heals drift every 30 seconds; migrations never destroy data.
|
|
||||||
|
|
||||||
## Features
|
Archipelago is built as a developer-ready app platform, not a fixed appliance:
|
||||||
|
|
||||||
### Bitcoin Infrastructure
|
- Apps are declared in `apps/<app-id>/manifest.yml`.
|
||||||
- **Bitcoin Core and Bitcoin Knots** full nodes with per-app version pinning and bulletproof version switching, automatic prune/full mode based on disk size
|
- The Rust parser in `core/container/src/manifest.rs` is the canonical schema.
|
||||||
- **LND** and **Core Lightning** with channel management
|
- The orchestrator compiles manifests to rootless Podman/Quadlet runtime state.
|
||||||
- **ElectrumX** Electrum server for wallet connectivity
|
- App data lives under `/var/lib/archipelago/<app-id>/`.
|
||||||
- **BTCPay Server** for accepting Bitcoin payments
|
- Secrets are generated or read from `/var/lib/archipelago/secrets/` and
|
||||||
- **Mempool** block explorer and fee estimator
|
injected through Podman secrets rather than static environment values.
|
||||||
- **Fedimint** federation guardian, gateway, and client — plus Cashu ecash wallet support
|
- Release and app catalogs are signed and verified against a pinned trust
|
||||||
|
anchor.
|
||||||
|
|
||||||
### Self-Hosted Apps (50+)
|
Start with:
|
||||||
Storage (FileBrowser, Immich, Nextcloud), Productivity (Vaultwarden), Media (Jellyfin, PhotoPrism, IndeeHub), Search (SearXNG), Network (NetBird, Tailscale), Home (Home Assistant), Nostr (nostr-rs-relay, strfry), Dev/Ops (Gitea, Grafana, Portainer, Uptime Kuma), and more — 27 curated in the store UI, 50+ packaged as manifests.
|
|
||||||
|
|
||||||
### Mesh Networking (tri-protocol)
|
- [Architecture](docs/architecture.md)
|
||||||
- **Meshtastic**, **MeshCore**, and **Reticulum (RNS/LXMF)** LoRa transports behind one mesh chat UI
|
- [Developer Guide](docs/developer-guide.md)
|
||||||
- End-to-end encryption with X3DH key agreement + double-ratchet
|
- [App Developer Guide](docs/app-developer-guide.md)
|
||||||
- RNode radio support with an OS-level `archy-rnodeconf` tool; interop verified against Sideband
|
- [App Manifest Spec](docs/app-manifest-spec.md)
|
||||||
- Image/voice attachments, mesh AI assistant (`!ai`), Bitcoin balance relay over mesh
|
- [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md)
|
||||||
|
- [Operations Runbook](docs/operations-runbook.md)
|
||||||
|
- [Troubleshooting](docs/troubleshooting.md)
|
||||||
|
|
||||||
### Decentralized Identity
|
## Quick start
|
||||||
- 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
|
|
||||||
|
|
||||||
### Multi-Node Federation
|
### Frontend
|
||||||
- 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
|
```bash
|
||||||
cd neode-ui
|
cd neode-ui
|
||||||
npm install
|
npm install
|
||||||
npm start # Dev server on http://localhost:8100 (mock backend on :5959)
|
npm start
|
||||||
npm run type-check # TypeScript validation
|
|
||||||
npm run build # Production build → web/dist/neode-ui/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend Development
|
The dev UI runs at `http://localhost:8100` with a mock backend on `:5959`.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd core # Rust workspace root (no Cargo.toml at repo root)
|
cd core
|
||||||
cargo build
|
cargo build
|
||||||
cargo test
|
cargo test --all-features
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deploy to a Test Node
|
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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/deploy-to-target.sh --live # Deploy to primary dev server
|
./scripts/validate-app-manifest.sh apps/filebrowser/manifest.yml
|
||||||
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
|
python3 scripts/generate-app-catalog.py
|
||||||
|
python3 scripts/check-app-catalog-drift.py --release --strict
|
||||||
```
|
```
|
||||||
|
|
||||||
### Release (tarball-only)
|
`scripts/generate-app-catalog.py` requires Python with PyYAML installed.
|
||||||
|
|
||||||
Releases ship as a backend binary and a frontend tarball referenced by
|
## Documentation map
|
||||||
`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 |
|
| Doc | Purpose |
|
||||||
|-----|---------|
|
|-----|---------|
|
||||||
| [Architecture](docs/architecture.md) | System design, crate map, data paths |
|
| [Architecture](docs/architecture.md) | System layers, crates, data paths, security model |
|
||||||
| [Developer Guide](docs/developer-guide.md) | Dev setup, workflow, code conventions |
|
| [Developer Guide](docs/developer-guide.md) | Local setup, code workflow, testing |
|
||||||
| [API Reference](docs/api-reference.md) | RPC endpoint reference |
|
| [API Reference](docs/api-reference.md) | JSON-RPC API overview |
|
||||||
| [App Developer Guide](docs/app-developer-guide.md) | Building and publishing apps |
|
| [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps |
|
||||||
| [App Manifest Spec](docs/app-manifest-spec.md) | The `manifest.yml` schema |
|
| [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules |
|
||||||
| [User Walkthrough](docs/user-walkthrough.md) | End-user installation and usage guide |
|
| [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model |
|
||||||
| [Troubleshooting](docs/troubleshooting.md) | Diagnostic scenarios and solutions |
|
| [Apps README](apps/README.md) | Packaged app catalog overview |
|
||||||
| [Operations Runbook](docs/operations-runbook.md) | Ops commands and emergency recovery |
|
| [Image Recipe](image-recipe/README.md) | Bootable image build flow |
|
||||||
| [Production Master Plan](docs/PRODUCTION-MASTER-PLAN.md) | North star and workstream narrative |
|
| [Operations Runbook](docs/operations-runbook.md) | Production operations and recovery |
|
||||||
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Live, priority-ordered open items |
|
| [Open Source Readiness](docs/OPEN_SOURCE_READINESS.md) | Public-release cleanup checklist |
|
||||||
| [Test Gate](tests/lifecycle/TESTING.md) | Production lifecycle test gate (definition of done) |
|
| [Roadmap](docs/ROADMAP.md) | Shipped, in-progress, and planned work |
|
||||||
| [Archive](docs/archive/) | Historical audits, session logs, shipped designs |
|
| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Launch hardening task list |
|
||||||
|
| [Archive](docs/archive/) | Historical plans, audits, and handoffs |
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For
|
||||||
2. Create a feature branch (`feature/description`)
|
security issues, follow [SECURITY.md](SECURITY.md) and do not open a public
|
||||||
3. Follow the coding standards in [CONTRIBUTING.md](CONTRIBUTING.md) and [CLAUDE.md](CLAUDE.md)
|
issue.
|
||||||
4. Submit a pull request
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT License](LICENSE)
|
Archipelago is licensed under the [MIT License](LICENSE). Third-party notices
|
||||||
|
are listed in [NOTICE](NOTICE) and generated license inventories in component
|
||||||
## Acknowledgments
|
release artifacts.
|
||||||
|
|
||||||
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/)
|
|
||||||
|
|||||||
38
SECURITY.md
Normal file
38
SECURITY.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# 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.
|
||||||
@ -76,7 +76,17 @@ podman run -p 18084:8080 \
|
|||||||
|
|
||||||
## Integration Checklist
|
## Integration Checklist
|
||||||
|
|
||||||
Adding a new app requires updates in multiple places. See the full checklist in [CLAUDE.md](../CLAUDE.md) under "App 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.
|
||||||
|
|
||||||
## Port Assignments
|
## Port Assignments
|
||||||
|
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
# 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
|
|
||||||
52
core/Cargo.lock
generated
52
core/Cargo.lock
generated
@ -84,6 +84,15 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||||
|
dependencies = [
|
||||||
|
"derive_arbitrary",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arc-swap"
|
name = "arc-swap"
|
||||||
version = "1.9.1"
|
version = "1.9.1"
|
||||||
@ -95,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.112-alpha"
|
version = "1.7.116-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
@ -161,6 +170,7 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"zbase32",
|
"zbase32",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1175,6 +1185,17 @@ version = "0.5.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_builder"
|
name = "derive_builder"
|
||||||
version = "0.20.2"
|
version = "0.20.2"
|
||||||
@ -6895,8 +6916,37 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||||
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
|
"crc32fast",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"displaydoc",
|
||||||
|
"flate2",
|
||||||
|
"indexmap",
|
||||||
|
"memchr",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"zopfli",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.16"
|
version = "1.0.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
|
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zopfli"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"crc32fast",
|
||||||
|
"log",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|||||||
656
core/THIRD-PARTY-LICENSES.md
Normal file
656
core/THIRD-PARTY-LICENSES.md
Normal file
@ -0,0 +1,656 @@
|
|||||||
|
# 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 |
|
||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.7.112-alpha"
|
version = "1.7.116-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
authors = ["Archipelago Team"]
|
authors = ["Archipelago Team"]
|
||||||
@ -108,6 +108,10 @@ bytes = "1"
|
|||||||
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
|
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
|
||||||
serial2-tokio = "0.1"
|
serial2-tokio = "0.1"
|
||||||
|
|
||||||
|
# LoRa radio firmware flashing: Meshtastic ships per-board images inside a
|
||||||
|
# per-platform release zip (see mesh/flash.rs).
|
||||||
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
|
|
||||||
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
||||||
hkdf = "0.12.4"
|
hkdf = "0.12.4"
|
||||||
|
|
||||||
|
|||||||
@ -465,6 +465,7 @@ impl RpcHandler {
|
|||||||
signing_key.as_ref().map(|i| i.signing_key()),
|
signing_key.as_ref().map(|i| i.signing_key()),
|
||||||
Some(&peer.pubkey),
|
Some(&peer.pubkey),
|
||||||
data.server_info.name.as_deref(),
|
data.server_info.name.as_deref(),
|
||||||
|
Some(&self.config.data_dir),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@ -279,6 +279,7 @@ impl RpcHandler {
|
|||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.header("X-Federation-DID", local_did)
|
.header("X-Federation-DID", local_did)
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(8))
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.context("Failed to connect to peer")?;
|
.context("Failed to connect to peer")?;
|
||||||
@ -364,6 +365,11 @@ impl RpcHandler {
|
|||||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.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()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.context("Failed to connect to peer")?;
|
.context("Failed to connect to peer")?;
|
||||||
@ -1137,6 +1143,7 @@ impl RpcHandler {
|
|||||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.context("Failed to connect to peer for preview")?;
|
.context("Failed to connect to peer for preview")?;
|
||||||
|
|||||||
@ -124,12 +124,15 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||||
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
||||||
|
"lnd.closedchannels" => self.handle_lnd_closedchannels().await,
|
||||||
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
||||||
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
|
"lnd.closechannel" => self.handle_lnd_closechannel(params).await,
|
||||||
"lnd.newaddress" => self.handle_lnd_newaddress().await,
|
"lnd.newaddress" => self.handle_lnd_newaddress().await,
|
||||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||||
|
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||||
|
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
|
||||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||||
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
||||||
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
|
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
|
||||||
@ -388,7 +391,12 @@ impl RpcHandler {
|
|||||||
// Mesh networking (Meshcore LoRa)
|
// Mesh networking (Meshcore LoRa)
|
||||||
"mesh.status" => self.handle_mesh_status().await,
|
"mesh.status" => self.handle_mesh_status().await,
|
||||||
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
||||||
|
"mesh.flash-list-firmware" => self.handle_mesh_flash_list_firmware(params).await,
|
||||||
|
"mesh.flash-device" => self.handle_mesh_flash_device(params).await,
|
||||||
|
"mesh.flash-status" => self.handle_mesh_flash_status().await,
|
||||||
|
"mesh.flash-cancel" => self.handle_mesh_flash_cancel().await,
|
||||||
"mesh.peers" => self.handle_mesh_peers().await,
|
"mesh.peers" => self.handle_mesh_peers().await,
|
||||||
|
"mesh.refresh" => self.handle_mesh_refresh().await,
|
||||||
"mesh.messages" => self.handle_mesh_messages(params).await,
|
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||||
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
||||||
"mesh.send" => self.handle_mesh_send(params).await,
|
"mesh.send" => self.handle_mesh_send(params).await,
|
||||||
|
|||||||
@ -865,7 +865,9 @@ impl RpcHandler {
|
|||||||
"/rpc/v1",
|
"/rpc/v1",
|
||||||
)
|
)
|
||||||
.service(crate::settings::transport::PeerService::Peers)
|
.service(crate::settings::transport::PeerService::Peers)
|
||||||
.timeout(std::time::Duration::from_secs(30));
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
|
.record_transport(&self.config.data_dir);
|
||||||
|
|
||||||
match req.send_json(&body).await {
|
match req.send_json(&body).await {
|
||||||
Ok((resp, transport)) if resp.status().is_success() => {
|
Ok((resp, transport)) if resp.status().is_success() => {
|
||||||
|
|||||||
@ -13,7 +13,14 @@ use anyhow::Result;
|
|||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
||||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||||
Ok(serde_json::to_value(status)?)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything the companion app needs to join this node's mesh, embedded
|
/// Everything the companion app needs to join this node's mesh, embedded
|
||||||
@ -83,7 +90,8 @@ impl RpcHandler {
|
|||||||
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
|
||||||
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||||
fips::config::install(&identity_dir).await?;
|
fips::config::install(&identity_dir).await?;
|
||||||
fips::service::activate(fips::SERVICE_UNIT).await?;
|
let unit = fips::service::activation_unit().await;
|
||||||
|
fips::service::activate(unit).await?;
|
||||||
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
let status = fips::FipsStatus::query(&self.config.data_dir).await;
|
||||||
Ok(serde_json::to_value(status)?)
|
Ok(serde_json::to_value(status)?)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,8 @@ struct ChannelInfo {
|
|||||||
active: bool,
|
active: bool,
|
||||||
status: String,
|
status: String,
|
||||||
channel_point: String,
|
channel_point: String,
|
||||||
|
#[serde(skip_serializing_if = "String::is_empty")]
|
||||||
|
closing_txid: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@ -58,6 +60,10 @@ struct LndChannel {
|
|||||||
#[derive(Debug, Deserialize, Default)]
|
#[derive(Debug, Deserialize, Default)]
|
||||||
struct LndPendingChannelsResponse {
|
struct LndPendingChannelsResponse {
|
||||||
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
|
pending_open_channels: Option<Vec<LndPendingOpenChannel>>,
|
||||||
|
// Cooperative closes waiting for their closing tx to confirm
|
||||||
|
waiting_close_channels: Option<Vec<LndWaitingCloseChannel>>,
|
||||||
|
// Force closes serving out their timelock
|
||||||
|
pending_force_closing_channels: Option<Vec<LndForceClosingChannel>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@ -65,6 +71,18 @@ struct LndPendingOpenChannel {
|
|||||||
channel: Option<LndPendingChannel>,
|
channel: Option<LndPendingChannel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LndWaitingCloseChannel {
|
||||||
|
channel: Option<LndPendingChannel>,
|
||||||
|
closing_txid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LndForceClosingChannel {
|
||||||
|
channel: Option<LndPendingChannel>,
|
||||||
|
closing_txid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct LndPendingChannel {
|
struct LndPendingChannel {
|
||||||
remote_node_pub: Option<String>,
|
remote_node_pub: Option<String>,
|
||||||
@ -74,6 +92,52 @@ struct LndPendingChannel {
|
|||||||
channel_point: Option<String>,
|
channel_point: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl LndPendingChannel {
|
||||||
|
fn into_channel_info(self, status: &str, closing_txid: Option<String>) -> ChannelInfo {
|
||||||
|
let parse = |s: &Option<String>| s.as_deref().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||||
|
ChannelInfo {
|
||||||
|
chan_id: String::new(),
|
||||||
|
remote_pubkey: self.remote_node_pub.clone().unwrap_or_default(),
|
||||||
|
capacity: parse(&self.capacity),
|
||||||
|
local_balance: parse(&self.local_balance),
|
||||||
|
remote_balance: parse(&self.remote_balance),
|
||||||
|
active: false,
|
||||||
|
status: status.into(),
|
||||||
|
channel_point: self.channel_point.unwrap_or_default(),
|
||||||
|
closing_txid: closing_txid.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
struct LndClosedChannelsResponse {
|
||||||
|
channels: Option<Vec<LndClosedChannel>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LndClosedChannel {
|
||||||
|
chan_id: Option<String>,
|
||||||
|
remote_pubkey: Option<String>,
|
||||||
|
capacity: Option<String>,
|
||||||
|
settled_balance: Option<String>,
|
||||||
|
close_type: Option<String>,
|
||||||
|
closing_tx_hash: Option<String>,
|
||||||
|
channel_point: Option<String>,
|
||||||
|
close_height: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ClosedChannelInfo {
|
||||||
|
chan_id: String,
|
||||||
|
remote_pubkey: String,
|
||||||
|
capacity: i64,
|
||||||
|
settled_balance: i64,
|
||||||
|
close_type: String,
|
||||||
|
closing_tx_hash: String,
|
||||||
|
channel_point: String,
|
||||||
|
close_height: i64,
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_listchannels(&self) -> Result<serde_json::Value> {
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
@ -131,6 +195,7 @@ impl RpcHandler {
|
|||||||
"inactive".into()
|
"inactive".into()
|
||||||
},
|
},
|
||||||
channel_point: ch.channel_point.unwrap_or_default(),
|
channel_point: ch.channel_point.unwrap_or_default(),
|
||||||
|
closing_txid: String::new(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@ -138,31 +203,20 @@ impl RpcHandler {
|
|||||||
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
||||||
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
||||||
if let Some(ch) = pch.channel {
|
if let Some(ch) = pch.channel {
|
||||||
let capacity: i64 = ch
|
pending_channels.push(ch.into_channel_info("pending_open", None));
|
||||||
.capacity
|
}
|
||||||
.as_deref()
|
}
|
||||||
.and_then(|s| s.parse().ok())
|
for wch in pending_resp.waiting_close_channels.unwrap_or_default() {
|
||||||
.unwrap_or(0);
|
if let Some(ch) = wch.channel {
|
||||||
let local: i64 = ch
|
pending_channels.push(ch.into_channel_info("closing", wch.closing_txid));
|
||||||
.local_balance
|
}
|
||||||
.as_deref()
|
}
|
||||||
.and_then(|s| s.parse().ok())
|
for fch in pending_resp
|
||||||
.unwrap_or(0);
|
.pending_force_closing_channels
|
||||||
let remote: i64 = ch
|
.unwrap_or_default()
|
||||||
.remote_balance
|
{
|
||||||
.as_deref()
|
if let Some(ch) = fch.channel {
|
||||||
.and_then(|s| s.parse().ok())
|
pending_channels.push(ch.into_channel_info("force_closing", fch.closing_txid));
|
||||||
.unwrap_or(0);
|
|
||||||
pending_channels.push(ChannelInfo {
|
|
||||||
chan_id: String::new(),
|
|
||||||
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
|
|
||||||
capacity,
|
|
||||||
local_balance: local,
|
|
||||||
remote_balance: remote,
|
|
||||||
active: false,
|
|
||||||
status: "pending_open".into(),
|
|
||||||
channel_point: ch.channel_point.unwrap_or_default(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -349,6 +403,46 @@ impl RpcHandler {
|
|||||||
Ok(body)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_closedchannels(&self) -> Result<serde_json::Value> {
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
let resp: LndClosedChannelsResponse = client
|
||||||
|
.get(format!("{LND_REST_BASE_URL}/v1/channels/closed"))
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("LND REST connection failed")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse LND closed channels response")?;
|
||||||
|
|
||||||
|
let channels: Vec<ClosedChannelInfo> = resp
|
||||||
|
.channels
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|ch| ClosedChannelInfo {
|
||||||
|
chan_id: ch.chan_id.unwrap_or_default(),
|
||||||
|
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
||||||
|
capacity: ch
|
||||||
|
.capacity
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0),
|
||||||
|
settled_balance: ch
|
||||||
|
.settled_balance
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0),
|
||||||
|
close_type: ch.close_type.unwrap_or_default(),
|
||||||
|
closing_tx_hash: ch.closing_tx_hash.unwrap_or_default(),
|
||||||
|
channel_point: ch.channel_point.unwrap_or_default(),
|
||||||
|
close_height: ch.close_height.unwrap_or(0),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "channels": channels }))
|
||||||
|
}
|
||||||
|
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_closechannel(
|
pub(in crate::api::rpc) async fn handle_lnd_closechannel(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
@ -389,27 +483,35 @@ impl RpcHandler {
|
|||||||
"Closing Lightning channel"
|
"Closing Lightning channel"
|
||||||
);
|
);
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
let (_, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
// The close endpoint is server-streaming: LND holds the connection
|
||||||
|
// open and emits updates until the closing tx CONFIRMS on-chain
|
||||||
|
// (potentially hours). Reading the whole body hangs the RPC even
|
||||||
|
// though the close already went through, and the shared lnd_client's
|
||||||
|
// 15s total timeout would abort the stream mid-read. Use a dedicated
|
||||||
|
// client and return as soon as the first streamed update arrives.
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.build()
|
||||||
|
.context("Failed to create streaming HTTP client")?;
|
||||||
|
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||||
parts[0], parts[1], force
|
parts[0], parts[1], force
|
||||||
);
|
);
|
||||||
|
|
||||||
let resp = client
|
let mut resp = client
|
||||||
.delete(&url)
|
.delete(&url)
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("Failed to close channel")?;
|
.context("Failed to close channel")?;
|
||||||
|
|
||||||
let status = resp.status();
|
if !resp.status().is_success() {
|
||||||
let body: serde_json::Value = resp
|
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.context("Failed to parse close channel response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
|
||||||
let msg = body
|
let msg = body
|
||||||
.get("message")
|
.get("message")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@ -417,6 +519,56 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(serde_json::json!({ "success": true }))
|
// First streamed line is {"result":{"close_pending":…}} on success or
|
||||||
|
// {"error":…} — the stream reports errors in-band after a 200.
|
||||||
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
|
let first_update = tokio::time::timeout(std::time::Duration::from_secs(25), async {
|
||||||
|
while let Some(chunk) = resp.chunk().await? {
|
||||||
|
buf.extend_from_slice(&chunk);
|
||||||
|
let line = match buf.iter().position(|&b| b == b'\n') {
|
||||||
|
Some(pos) => &buf[..pos],
|
||||||
|
None => &buf[..],
|
||||||
|
};
|
||||||
|
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
|
||||||
|
return Ok::<_, anyhow::Error>(Some(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match first_update {
|
||||||
|
Ok(Ok(Some(update))) => {
|
||||||
|
if let Some(err) = update.get("error") {
|
||||||
|
let msg = err
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||||
|
}
|
||||||
|
// txid arrives base64-encoded in internal byte order; flip it
|
||||||
|
// into the display order explorers use.
|
||||||
|
use base64::Engine as _;
|
||||||
|
let closing_txid = update
|
||||||
|
.pointer("/result/close_pending/txid")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||||
|
.map(|mut bytes| {
|
||||||
|
bytes.reverse();
|
||||||
|
hex::encode(bytes)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
info!(channel_point, closing_txid, "Channel close initiated");
|
||||||
|
Ok(serde_json::json!({ "success": true, "closing_txid": closing_txid }))
|
||||||
|
}
|
||||||
|
Ok(Ok(None)) => Err(anyhow::anyhow!(
|
||||||
|
"LND ended the close stream without an update — check the channel list"
|
||||||
|
)),
|
||||||
|
Ok(Err(e)) => Err(e).context("Failed reading close channel response"),
|
||||||
|
// No update inside the window: the close is almost certainly still
|
||||||
|
// negotiating with the peer — report initiated, the channel list
|
||||||
|
// will show it under Closing.
|
||||||
|
Err(_) => Ok(serde_json::json!({ "success": true, "closing_txid": "" })),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,33 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
// Decode the invoice up front (fast, local) so we know its payment
|
||||||
|
// hash BEFORE handing it to LND. If the payment outlives our wait
|
||||||
|
// below, the hash is what lets the UI keep tracking it instead of
|
||||||
|
// declaring a false failure. Best-effort: a decode hiccup must not
|
||||||
|
// block the payment itself.
|
||||||
|
let (decoded_hash, decoded_amt) = match client
|
||||||
|
.get(format!("{LND_REST_BASE_URL}/v1/payreq/{payment_request}"))
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => match r.json::<serde_json::Value>().await {
|
||||||
|
Ok(d) => (
|
||||||
|
d.get("payment_hash")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
d.get("num_satoshis")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0),
|
||||||
|
),
|
||||||
|
Err(_) => (String::new(), 0),
|
||||||
|
},
|
||||||
|
Err(_) => (String::new(), 0),
|
||||||
|
};
|
||||||
|
|
||||||
let mut pay_body = serde_json::json!({
|
let mut pay_body = serde_json::json!({
|
||||||
"payment_request": payment_request,
|
"payment_request": payment_request,
|
||||||
});
|
});
|
||||||
@ -43,13 +70,46 @@ impl RpcHandler {
|
|||||||
pay_body["amt"] = serde_json::json!(amt.to_string());
|
pay_body["amt"] = serde_json::json!(amt.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp = client
|
// `/v1/channels/transactions` is SYNCHRONOUS: it blocks until the
|
||||||
|
// payment settles or definitively fails, and multi-hop routing with
|
||||||
|
// retries routinely takes longer than the shared client's 15s budget.
|
||||||
|
// That 15s abort used to surface as "Payment failed" while LND kept
|
||||||
|
// paying in the background — the payment then succeeded and appeared
|
||||||
|
// in history a minute later. Wait up to 120s on a dedicated client,
|
||||||
|
// and treat a post-connect timeout as IN FLIGHT (status: pending),
|
||||||
|
// never as failure — only LND may declare a payment failed.
|
||||||
|
let pay_client = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.build()
|
||||||
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
|
let resp = match pay_client
|
||||||
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
.json(&pay_body)
|
.json(&pay_body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("Failed to pay invoice")?;
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) if e.is_connect() => {
|
||||||
|
// Never reached LND — nothing was sent; this IS a hard error.
|
||||||
|
return Err(anyhow::anyhow!("Could not reach LND to pay: {e}"));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Timed out (or lost the connection) AFTER the payment was
|
||||||
|
// handed to LND — it may well still succeed. Report pending
|
||||||
|
// with the hash so the caller can poll lnd.paymentstatus.
|
||||||
|
info!("payinvoice wait elapsed; payment still in flight");
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"status": "pending",
|
||||||
|
"payment_hash": decoded_hash,
|
||||||
|
"amount_sats": decoded_amt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp
|
let body: serde_json::Value = resp
|
||||||
@ -86,20 +146,104 @@ impl RpcHandler {
|
|||||||
.and_then(|r| r.get("total_amt"))
|
.and_then(|r| r.get("total_amt"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| s.parse::<i64>().ok())
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(decoded_amt);
|
||||||
|
|
||||||
let payment_hash = body
|
let payment_hash = body
|
||||||
.get("payment_hash")
|
.get("payment_hash")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.filter(|s| !s.is_empty())
|
||||||
.to_string();
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or(decoded_hash);
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
|
"status": "succeeded",
|
||||||
"payment_hash": payment_hash,
|
"payment_hash": payment_hash,
|
||||||
"amount_sats": amount_sat,
|
"amount_sats": amount_sat,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Status of an outgoing Lightning payment by hex payment hash. Lets the
|
||||||
|
/// UI resolve a payinvoice that outlived its synchronous wait (`status:
|
||||||
|
/// "pending"`) to a real terminal state instead of guessing.
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_paymentstatus(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.unwrap_or_default();
|
||||||
|
let payment_hash = params
|
||||||
|
.get("payment_hash")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_hash' parameter"))?;
|
||||||
|
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid payment hash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
let resp = client
|
||||||
|
.get(format!(
|
||||||
|
"{LND_REST_BASE_URL}/v1/payments?include_incomplete=true&max_payments=100&reversed=true"
|
||||||
|
))
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("LND REST connection failed")?;
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse payments response")?;
|
||||||
|
|
||||||
|
let hash_lower = payment_hash.to_lowercase();
|
||||||
|
let found = body
|
||||||
|
.get("payments")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.and_then(|arr| {
|
||||||
|
arr.iter().find(|p| {
|
||||||
|
p.get("payment_hash").and_then(|v| v.as_str()) == Some(hash_lower.as_str())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let Some(p) = found else {
|
||||||
|
// Not in the latest window — either very old or LND never saw it.
|
||||||
|
return Ok(serde_json::json!({ "status": "unknown" }));
|
||||||
|
};
|
||||||
|
|
||||||
|
let lnd_status = p.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let status = match lnd_status {
|
||||||
|
"SUCCEEDED" => "succeeded",
|
||||||
|
"FAILED" => "failed",
|
||||||
|
_ => "in_flight",
|
||||||
|
};
|
||||||
|
let failure_reason = match p
|
||||||
|
.get("failure_reason")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
{
|
||||||
|
"FAILURE_REASON_NO_ROUTE" => "No route to the recipient",
|
||||||
|
"FAILURE_REASON_INSUFFICIENT_BALANCE" => "Insufficient channel balance",
|
||||||
|
"FAILURE_REASON_TIMEOUT" => "Payment timed out in the network",
|
||||||
|
"FAILURE_REASON_INCORRECT_PAYMENT_DETAILS" => {
|
||||||
|
"Recipient rejected the payment (wrong details or expired invoice)"
|
||||||
|
}
|
||||||
|
"FAILURE_REASON_ERROR" => "Payment failed",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
|
||||||
|
fn amt(p: &serde_json::Value, key: &str) -> i64 {
|
||||||
|
p.get(key)
|
||||||
|
.and_then(|f| f.as_str())
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.or_else(|| p.get(key).and_then(|f| f.as_i64()))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"status": status,
|
||||||
|
"failure_reason": failure_reason,
|
||||||
|
"amount_sats": amt(p, "value_sat"),
|
||||||
|
"fee_sats": amt(p, "fee_sat"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// List on-chain transactions from LND.
|
/// List on-chain transactions from LND.
|
||||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(
|
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(
|
||||||
|
|||||||
@ -124,16 +124,41 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fee control: either a confirmation target or an explicit fee rate
|
||||||
|
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||||
|
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
|
||||||
|
if target_conf.is_some() && sat_per_vbyte.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(tc) = target_conf {
|
||||||
|
if !(1..=1008).contains(&tc) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(rate) = sat_per_vbyte {
|
||||||
|
if !(1..=5000).contains(&rate) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid sat_per_vbyte: must be between 1 and 5000"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
addr = addr,
|
addr = addr,
|
||||||
amount = amount,
|
amount = amount,
|
||||||
send_all = send_all,
|
send_all = send_all,
|
||||||
|
target_conf = target_conf,
|
||||||
|
sat_per_vbyte = sat_per_vbyte,
|
||||||
"Sending on-chain Bitcoin"
|
"Sending on-chain Bitcoin"
|
||||||
);
|
);
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
let send_body = match amount {
|
let mut send_body = match amount {
|
||||||
Some(amount) => serde_json::json!({
|
Some(amount) => serde_json::json!({
|
||||||
"addr": addr,
|
"addr": addr,
|
||||||
"amount": amount.to_string(),
|
"amount": amount.to_string(),
|
||||||
@ -143,6 +168,13 @@ impl RpcHandler {
|
|||||||
"send_all": true,
|
"send_all": true,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
if let Some(tc) = target_conf {
|
||||||
|
send_body["target_conf"] = serde_json::json!(tc);
|
||||||
|
}
|
||||||
|
if let Some(rate) = sat_per_vbyte {
|
||||||
|
// LND REST encodes uint64 as a JSON string
|
||||||
|
send_body["sat_per_vbyte"] = serde_json::json!(rate.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
.post(format!("{LND_REST_BASE_URL}/v1/transactions"))
|
||||||
@ -171,6 +203,82 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "txid": txid }))
|
Ok(serde_json::json!({ "txid": txid }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Estimate the on-chain fee for sending `amount` sats to `addr` at a
|
||||||
|
/// confirmation target. Returns `{ fee_sat, sat_per_vbyte }` so the send
|
||||||
|
/// UI can show the cost of each preset before the user confirms.
|
||||||
|
pub(in crate::api::rpc) async fn handle_lnd_estimatefee(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.unwrap_or_default();
|
||||||
|
let addr = params
|
||||||
|
.get("addr")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
||||||
|
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||||
|
}
|
||||||
|
let amount = params
|
||||||
|
.get("amount")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||||
|
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid amount"));
|
||||||
|
}
|
||||||
|
let target_conf = params
|
||||||
|
.get("target_conf")
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(6);
|
||||||
|
if !(1..=1008).contains(&target_conf) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{LND_REST_BASE_URL}/v1/transactions/fee"))
|
||||||
|
.query(&[
|
||||||
|
(format!("AddrToAmount[{addr}]"), amount.to_string()),
|
||||||
|
("target_conf".to_string(), target_conf.to_string()),
|
||||||
|
])
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Failed to estimate fee")?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse fee estimate response")?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
|
return Err(anyhow::anyhow!("Failed to estimate fee: {}", msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// LND REST encodes int64 as JSON strings
|
||||||
|
let fee_sat = body
|
||||||
|
.get("fee_sat")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let sat_per_vbyte = body
|
||||||
|
.get("sat_per_vbyte")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"fee_sat": fee_sat,
|
||||||
|
"sat_per_vbyte": sat_per_vbyte,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a Lightning invoice.
|
/// Create a Lightning invoice.
|
||||||
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
|
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
|
||||||
///
|
///
|
||||||
|
|||||||
134
core/archipelago/src/api/rpc/mesh/flash.rs
Normal file
134
core/archipelago/src/api/rpc/mesh/flash.rs
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
use super::super::RpcHandler;
|
||||||
|
use crate::mesh;
|
||||||
|
use crate::mesh::flash::{self, FlashBoard, FlashJobStatus};
|
||||||
|
use crate::mesh::types::DeviceType;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
fn parse_family(s: &str) -> Result<DeviceType> {
|
||||||
|
match s.trim().to_lowercase().as_str() {
|
||||||
|
"meshcore" => Ok(DeviceType::Meshcore),
|
||||||
|
"meshtastic" => Ok(DeviceType::Meshtastic),
|
||||||
|
"reticulum" | "rnode" => Ok(DeviceType::Reticulum),
|
||||||
|
other => anyhow::bail!(
|
||||||
|
"Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_board(s: &str) -> Result<FlashBoard> {
|
||||||
|
match s.trim().to_lowercase().as_str() {
|
||||||
|
"heltec-v3" | "heltec_v3" | "heltecv3" => Ok(FlashBoard::HeltecV3),
|
||||||
|
"heltec-v4" | "heltec_v4" | "heltecv4" => Ok(FlashBoard::HeltecV4),
|
||||||
|
other => anyhow::bail!("Unknown board: {other} (expected heltec-v3|heltec-v4)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// mesh.flash-list-firmware — resolve the available firmware version(s)
|
||||||
|
/// for a given family. v1 only ever surfaces "latest".
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_flash_list_firmware(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let family = params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("family"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
|
||||||
|
let family = parse_family(family)?;
|
||||||
|
let versions = flash::list_firmware(family).await?;
|
||||||
|
Ok(serde_json::json!({ "versions": versions }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.flash-device — erase and reflash a detected LoRa radio with the
|
||||||
|
/// latest firmware for the given family, defaulting to a full chip
|
||||||
|
/// erase before write. `board` is optional: if the port's USB vid:pid
|
||||||
|
/// unambiguously resolves to a known board, that's used; otherwise the
|
||||||
|
/// caller must supply it explicitly (see `flash::resolve_flash_board`'s
|
||||||
|
/// doc comment on why we refuse to guess).
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_flash_device(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let path = params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("path"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
|
||||||
|
.to_string();
|
||||||
|
let family = params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("family"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
|
||||||
|
let family = parse_family(family)?;
|
||||||
|
|
||||||
|
let detected = mesh::detect_devices().await;
|
||||||
|
anyhow::ensure!(
|
||||||
|
detected.iter().any(|d| d == &path),
|
||||||
|
"{path} is not a detected mesh-radio candidate port"
|
||||||
|
);
|
||||||
|
|
||||||
|
let board = match params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("board"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
{
|
||||||
|
Some(explicit) => parse_board(explicit)?,
|
||||||
|
None => {
|
||||||
|
let info = mesh::detect_devices_info()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.find(|d| d.path == path);
|
||||||
|
info.as_ref()
|
||||||
|
.and_then(flash::resolve_flash_board)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Could not auto-detect the board on {path} — specify board explicitly"
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
flash::start_flash_job(
|
||||||
|
&self.flash_job,
|
||||||
|
&self.mesh_service_arc(),
|
||||||
|
self.config.data_dir.clone(),
|
||||||
|
path,
|
||||||
|
board,
|
||||||
|
family,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "started": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.flash-status — poll the current (or most recent) flash job.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_flash_status(&self) -> Result<serde_json::Value> {
|
||||||
|
let job = self.flash_job.read().await;
|
||||||
|
match job.as_ref() {
|
||||||
|
Some(j) => {
|
||||||
|
let status: FlashJobStatus = j.snapshot().await;
|
||||||
|
let mut value = serde_json::to_value(&status)?;
|
||||||
|
if let Some(obj) = value.as_object_mut() {
|
||||||
|
obj.insert("active".into(), (!status.done).into());
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
None => Ok(serde_json::json!({ "active": false })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.flash-cancel — best-effort; only honored before erase/write has
|
||||||
|
/// started (see `FlashJob::cancel`'s doc comment).
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_flash_cancel(&self) -> Result<serde_json::Value> {
|
||||||
|
let job = self.flash_job.read().await;
|
||||||
|
match job.as_ref() {
|
||||||
|
Some(j) => {
|
||||||
|
j.cancel().await?;
|
||||||
|
Ok(serde_json::json!({ "cancelled": true }))
|
||||||
|
}
|
||||||
|
None => anyhow::bail!("No flash job in progress"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
use super::super::RpcHandler;
|
use super::super::RpcHandler;
|
||||||
use crate::mesh;
|
use crate::mesh;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
@ -131,7 +132,14 @@ impl RpcHandler {
|
|||||||
config.broadcast_identity = broadcast;
|
config.broadcast_identity = broadcast;
|
||||||
}
|
}
|
||||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||||
config.advert_name = Some(name.to_string());
|
// Empty clears the custom mesh name (falls back to the server
|
||||||
|
// name) — without this, a name could be set but never unset.
|
||||||
|
let trimmed = name.trim();
|
||||||
|
config.advert_name = if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if let Some(announce) = params
|
if let Some(announce) = params
|
||||||
.get("announce_block_headers")
|
.get("announce_block_headers")
|
||||||
@ -202,10 +210,24 @@ impl RpcHandler {
|
|||||||
|
|
||||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||||
|
|
||||||
// If we have a running service, update its config
|
// Apply to the running service in the background: configure() may
|
||||||
let mut service = self.mesh_service.write().await;
|
// stop+start the listener (config changes now restart the session so
|
||||||
if let Some(svc) = service.as_mut() {
|
// they actually take effect), and that can take seconds when the old
|
||||||
svc.configure(config.clone()).await?;
|
// session is mid-probe. Holding the service write-lock for that long
|
||||||
|
// inside this handler stalled every concurrent mesh.status/mesh.peers
|
||||||
|
// poll behind it — the UI froze and nginx surfaced 502s. The config is
|
||||||
|
// already persisted above; the UI observes progress via mesh.status.
|
||||||
|
{
|
||||||
|
let service_arc = Arc::clone(&self.mesh_service);
|
||||||
|
let config_for_apply = config.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut service = service_arc.write().await;
|
||||||
|
if let Some(svc) = service.as_mut() {
|
||||||
|
if let Err(e) = svc.configure(config_for_apply).await {
|
||||||
|
tracing::error!("Applying mesh config to running service failed: {e:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Mesh config updated");
|
info!("Mesh config updated");
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
mod assistant;
|
mod assistant;
|
||||||
mod bitcoin_ops;
|
mod bitcoin_ops;
|
||||||
|
mod flash;
|
||||||
mod messaging;
|
mod messaging;
|
||||||
mod safety;
|
mod safety;
|
||||||
mod status;
|
mod status;
|
||||||
|
|||||||
@ -101,15 +101,61 @@ impl RpcHandler {
|
|||||||
detected.iter().any(|d| d == &path),
|
detected.iter().any(|d| d == &path),
|
||||||
"{path} is not a detected mesh-radio candidate port"
|
"{path} is not a detected mesh-radio candidate port"
|
||||||
);
|
);
|
||||||
let service = self.mesh_service.read().await;
|
// Refuse to probe while a firmware flash is in flight. Confirmed
|
||||||
let probe = match service.as_ref() {
|
// live 2026-07-23: esptool ("multiple access on port?") and
|
||||||
Some(svc) => svc.probe_device(&path).await?,
|
// rnodeconf (OSError Errno 71 Protocol error on an RTS ioctl) both
|
||||||
// No mesh service yet (radio never enabled) — probe directly.
|
// failed with symptoms consistent with a second process holding the
|
||||||
None => mesh::listener::probe_device(&path).await?,
|
// same serial fd — the flash subprocess runs for minutes outside
|
||||||
};
|
// our own async runtime, so nothing previously stopped a concurrent
|
||||||
|
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe)
|
||||||
|
// from opening the identical port at the same time and corrupting
|
||||||
|
// both operations' handshakes.
|
||||||
|
if let Some(job) = self.flash_job.read().await.as_ref() {
|
||||||
|
anyhow::ensure!(
|
||||||
|
job.snapshot().await.done,
|
||||||
|
"A firmware flash is in progress — refusing to probe the serial port until it finishes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Only hold the mesh_service lock long enough for the quick
|
||||||
|
// active-path guard check — NEVER across the actual probe, which
|
||||||
|
// can take 15-60s across its internal collision retries. Confirmed
|
||||||
|
// live 2026-07-23: holding this read lock for the full probe starved
|
||||||
|
// a concurrent firmware-flash job's MeshService::stop() (which needs
|
||||||
|
// the write lock) well past its own bounded timeout, surfacing as
|
||||||
|
// "Mesh listener did not release the serial port" even though
|
||||||
|
// stop() itself was fast.
|
||||||
|
{
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
if let Some(svc) = service.as_ref() {
|
||||||
|
svc.ensure_probe_allowed(&path).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let probe = mesh::listener::probe_device(&path).await?;
|
||||||
Ok(serde_json::to_value(probe)?)
|
Ok(serde_json::to_value(probe)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// mesh.refresh — Actively refresh discovery state: re-query the radio's
|
||||||
|
/// contact table and re-announce ourselves so quiet-but-alive neighbours
|
||||||
|
/// answer. This is what the UI's Refresh button calls — before it existed
|
||||||
|
/// the button only re-read server caches and never touched the radio.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_refresh(&self) -> Result<serde_json::Value> {
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let Some(svc) = service.as_ref() else {
|
||||||
|
return Ok(serde_json::json!({ "refreshed": false, "device_connected": false }));
|
||||||
|
};
|
||||||
|
let status = svc.status().await;
|
||||||
|
if status.device_connected {
|
||||||
|
let state = svc.shared_state();
|
||||||
|
let _ = state
|
||||||
|
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"refreshed": status.device_connected,
|
||||||
|
"device_connected": status.device_connected,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// mesh.peers — List discovered mesh peers.
|
/// mesh.peers — List discovered mesh peers.
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
|
|||||||
@ -575,14 +575,17 @@ impl RpcHandler {
|
|||||||
let nodes = crate::federation::load_nodes(&self.config.data_dir)
|
let nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let has_tor = peer_pubkey_hex
|
let fed_node = nodes.iter().find(|n| {
|
||||||
.as_ref()
|
peer_pubkey_hex.as_deref() == Some(n.pubkey.as_str())
|
||||||
.map(|pk| nodes.iter().any(|n| &n.pubkey == pk))
|
|| peer_did.as_deref() == Some(n.did.as_str())
|
||||||
.unwrap_or(false)
|
});
|
||||||
|| peer_did
|
let has_tor = fed_node.is_some();
|
||||||
.as_ref()
|
// Distinct FIPS capability so the frontend can offer FIPS as its own
|
||||||
.map(|d| nodes.iter().any(|n| &n.did == d))
|
// pill (not just a generic "Tor") — the dial layer still picks
|
||||||
.unwrap_or(false);
|
// FIPS-first with Tor fallback on the actual send; these are honest
|
||||||
|
// capability labels, with `last_transport` saying what worked last.
|
||||||
|
let has_fips = fed_node.is_some_and(|n| n.fips_npub.is_some());
|
||||||
|
let last_transport = fed_node.and_then(|n| n.last_transport.clone());
|
||||||
|
|
||||||
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
||||||
|
|
||||||
@ -618,6 +621,8 @@ impl RpcHandler {
|
|||||||
"tier": tier,
|
"tier": tier,
|
||||||
"est_seconds": est_seconds,
|
"est_seconds": est_seconds,
|
||||||
"has_tor": has_tor,
|
"has_tor": has_tor,
|
||||||
|
"has_fips": has_fips,
|
||||||
|
"last_transport": last_transport,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"size": size,
|
"size": size,
|
||||||
"mesh_auto_max": MESH_AUTO_MAX,
|
"mesh_auto_max": MESH_AUTO_MAX,
|
||||||
@ -820,6 +825,8 @@ impl RpcHandler {
|
|||||||
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), &onion_bare, &path)
|
||||||
.service(crate::settings::transport::PeerService::MeshFileSharing)
|
.service(crate::settings::transport::PeerService::MeshFileSharing)
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(8))
|
||||||
|
.record_transport(&self.config.data_dir)
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Fetch failed: {}", e))?;
|
||||||
|
|||||||
@ -89,6 +89,9 @@ pub struct RpcHandler {
|
|||||||
endpoint_rate_limiter: EndpointRateLimiter,
|
endpoint_rate_limiter: EndpointRateLimiter,
|
||||||
response_cache: ResponseCache,
|
response_cache: ResponseCache,
|
||||||
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
|
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
|
||||||
|
/// LoRa radio firmware-flash job state, sibling to `mesh_service` — one
|
||||||
|
/// job at a time, since flashing needs exclusive access to the port.
|
||||||
|
flash_job: crate::mesh::flash::FlashJobHandle,
|
||||||
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
|
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
|
||||||
/// Shared content-addressed blob store. Set by ApiHandler after construction
|
/// Shared content-addressed blob store. Set by ApiHandler after construction
|
||||||
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
|
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
|
||||||
@ -160,6 +163,7 @@ impl RpcHandler {
|
|||||||
endpoint_rate_limiter,
|
endpoint_rate_limiter,
|
||||||
response_cache: ResponseCache::new(5),
|
response_cache: ResponseCache::new(5),
|
||||||
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
|
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
|
flash_job: crate::mesh::flash::new_job_handle(),
|
||||||
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
|
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
|
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
|
|||||||
@ -137,6 +137,7 @@ impl RpcHandler {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
Some(&self.config.data_dir),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -225,6 +226,7 @@ impl RpcHandler {
|
|||||||
signing_key.as_ref().map(|i| i.signing_key()),
|
signing_key.as_ref().map(|i| i.signing_key()),
|
||||||
Some(&req.from_pubkey),
|
Some(&req.from_pubkey),
|
||||||
data.server_info.name.as_deref(),
|
data.server_info.name.as_deref(),
|
||||||
|
Some(&self.config.data_dir),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2324,17 +2324,28 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup_stale_pasta_port(port: &str) {
|
async fn cleanup_stale_pasta_port(port: &str) {
|
||||||
|
// NEVER kill our own process. The daemon holds catalog app ports over
|
||||||
|
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
|
||||||
|
// terminate archipelago itself mid-install — installs failed and apps
|
||||||
|
// vanished on framework-pt 2026-07-27. Kill every listener on the port
|
||||||
|
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
|
||||||
|
let self_pid = std::process::id();
|
||||||
let kill_listener = format!(
|
let kill_listener = format!(
|
||||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
"ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
|
||||||
port
|
while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
|
||||||
);
|
);
|
||||||
let _ = tokio::process::Command::new("sh")
|
let _ = tokio::process::Command::new("sh")
|
||||||
.args(["-c", &kill_listener])
|
.args(["-c", &kill_listener])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let _ = tokio::process::Command::new("sudo")
|
// sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
|
||||||
.args(["fuser", "-k", &format!("{}/tcp", port)])
|
// the port; kill each except self. (`fuser -k` has no exclusion flag.)
|
||||||
|
let fuser_kill = format!(
|
||||||
|
"for p in $(sudo fuser {port}/tcp 2>/dev/null); do [ \"$p\" = \"{self_pid}\" ] || sudo kill \"$p\" 2>/dev/null; done || true",
|
||||||
|
);
|
||||||
|
let _ = tokio::process::Command::new("sh")
|
||||||
|
.args(["-c", &fuser_kill])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@ -133,6 +133,7 @@ impl RpcHandler {
|
|||||||
Some(node_id.signing_key()),
|
Some(node_id.signing_key()),
|
||||||
recipient_pubkey.as_deref(),
|
recipient_pubkey.as_deref(),
|
||||||
node_name.as_deref(),
|
node_name.as_deref(),
|
||||||
|
Some(&self.config.data_dir),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
||||||
|
|||||||
@ -56,12 +56,12 @@ pub(in crate::api::rpc) async fn save_pending_seed_encrypted(
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort: install fips.yaml + start archipelago-fips.service after the
|
/// 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
|
/// seed onboarding has written the fips_key to disk. Runs in a detached task so
|
||||||
/// so the user-facing RPC returns immediately — the systemctl calls can take
|
/// the user-facing RPC returns immediately — the systemctl calls can take a few
|
||||||
/// a few seconds the first time on slow hardware. Any failure is logged but
|
/// seconds the first time on slow hardware. Any failure is logged but does not
|
||||||
/// does not break onboarding; the user can still hit fips.install manually
|
/// break onboarding; the user can still hit fips.install manually from the
|
||||||
/// from the dashboard as an escape hatch.
|
/// dashboard as an escape hatch.
|
||||||
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let identity_dir = data_dir.join("identity");
|
let identity_dir = data_dir.join("identity");
|
||||||
@ -78,11 +78,12 @@ fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
|||||||
tracing::warn!("post-onboarding fips config install failed: {}", e);
|
tracing::warn!("post-onboarding fips config install failed: {}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
|
let unit = crate::fips::service::activation_unit().await;
|
||||||
tracing::warn!("post-onboarding archipelago-fips activate failed: {}", e);
|
if let Err(e) = crate::fips::service::activate(unit).await {
|
||||||
|
tracing::warn!("post-onboarding FIPS activate failed via {}: {}", unit, e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tracing::info!("archipelago-fips auto-activated post-onboarding");
|
tracing::info!("FIPS auto-activated post-onboarding via {}", unit);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,8 +139,8 @@ impl RpcHandler {
|
|||||||
// Initialize identity index at 0.
|
// Initialize identity index at 0.
|
||||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||||
|
|
||||||
// fips_key is now on disk — auto-activate archipelago-fips so the
|
// fips_key is now on disk — auto-activate FIPS so the user doesn't
|
||||||
// user doesn't have to hit an "Activate" button. Detached task;
|
// have to hit a manual Start button. Detached task;
|
||||||
// the onboarding RPC returns immediately.
|
// the onboarding RPC returns immediately.
|
||||||
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,28 @@ impl RpcHandler {
|
|||||||
|
|
||||||
info!("Server name updated to: {}", name);
|
info!("Server name updated to: {}", name);
|
||||||
|
|
||||||
|
// Propagate to the mesh: the listener advertises the server name (when
|
||||||
|
// no explicit mesh advert_name overrides it), but it was only read at
|
||||||
|
// process startup — a rename never reached the radio/RNS until the
|
||||||
|
// next full restart. Push it into the service and bounce the listener
|
||||||
|
// in the background (the restart re-probes the radio, which can take
|
||||||
|
// seconds — don't block the rename response on it).
|
||||||
|
{
|
||||||
|
let mesh_arc = self.mesh_service_arc();
|
||||||
|
let name_for_mesh = name.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut guard = mesh_arc.write().await;
|
||||||
|
if let Some(svc) = guard.as_mut() {
|
||||||
|
svc.set_server_name(Some(name_for_mesh));
|
||||||
|
if svc.config().advert_name.is_none() {
|
||||||
|
if let Err(e) = svc.restart_listener_if_running().await {
|
||||||
|
warn!("Mesh listener restart after rename failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Push the new name to federation peers in background
|
// Push the new name to federation peers in background
|
||||||
let data_dir = self.config.data_dir.clone();
|
let data_dir = self.config.data_dir.clone();
|
||||||
let state_manager = self.state_manager.clone();
|
let state_manager = self.state_manager.clone();
|
||||||
|
|||||||
@ -498,7 +498,9 @@ pub(super) async fn notify_federation_peers_address_change(
|
|||||||
"/rpc/v1",
|
"/rpc/v1",
|
||||||
)
|
)
|
||||||
.service(crate::settings::transport::PeerService::Peers)
|
.service(crate::settings::transport::PeerService::Peers)
|
||||||
.timeout(std::time::Duration::from_secs(30));
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
|
.record_transport(data_dir);
|
||||||
match req.send_json(&payload).await {
|
match req.send_json(&payload).await {
|
||||||
Ok((_, transport)) => {
|
Ok((_, transport)) => {
|
||||||
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
|
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
|
||||||
|
|||||||
@ -22,8 +22,10 @@
|
|||||||
//! single declarative call.
|
//! single declarative call.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@ -35,6 +37,15 @@ const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
|||||||
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
const COMPANION_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
const COMPANION_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
||||||
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
|
const COMPANION_PULL_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
/// After a failed repair (image build/pull included), leave the companion
|
||||||
|
/// alone for this long. Without it, a node under IO pressure retried a 900s
|
||||||
|
/// image build every 30s reconcile tick — each build pegging the disk that
|
||||||
|
/// made the probes fail in the first place (live-diagnosed on zaza-optiplex
|
||||||
|
/// 2026-07-28: load 50, podman scans starved, apps page stuck).
|
||||||
|
const REPAIR_COOLDOWN: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
|
static REPAIR_FAILED_AT: LazyLock<Mutex<HashMap<&'static str, Instant>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
/// Static description of one companion. The full list per backend
|
/// Static description of one companion. The full list per backend
|
||||||
/// app_id lives in `companions_for`.
|
/// app_id lives in `companions_for`.
|
||||||
@ -464,12 +475,28 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
|
|||||||
match needs_repair(spec).await {
|
match needs_repair(spec).await {
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
|
if let Some(failed_at) =
|
||||||
|
REPAIR_FAILED_AT.lock().unwrap().get(spec.name).copied()
|
||||||
|
{
|
||||||
|
if failed_at.elapsed() < REPAIR_COOLDOWN {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
info!(
|
info!(
|
||||||
companion = spec.name,
|
companion = spec.name,
|
||||||
"reconcile: companion not active, repairing"
|
"reconcile: companion not active, repairing"
|
||||||
);
|
);
|
||||||
if let Err(e) = install_one(spec).await {
|
match install_one(spec).await {
|
||||||
failures.push((spec.name.to_string(), e));
|
Ok(()) => {
|
||||||
|
REPAIR_FAILED_AT.lock().unwrap().remove(spec.name);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
REPAIR_FAILED_AT
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(spec.name, Instant::now());
|
||||||
|
failures.push((spec.name.to_string(), e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@ -484,19 +511,58 @@ pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)
|
|||||||
|
|
||||||
/// Does this companion need install_one to be re-run? Returns true if
|
/// Does this companion need install_one to be re-run? Returns true if
|
||||||
/// the unit file is missing, stale, or the service is not active.
|
/// the unit file is missing, stale, or the service is not active.
|
||||||
|
///
|
||||||
|
/// This probe runs every reconcile tick for every companion, so it must be
|
||||||
|
/// PASSIVE: no image builds, no pulls. It used to call ensure_image_present
|
||||||
|
/// to render the expected unit — under IO pressure the image-existence check
|
||||||
|
/// inside timed out, read as "image missing", and a 900s `podman build` ran
|
||||||
|
/// inside the probe even though the companion was up (the .198 load spiral).
|
||||||
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
||||||
let dir = quadlet::unit_dir().await?;
|
let dir = quadlet::unit_dir().await?;
|
||||||
let unit_path = dir.join(format!("{}.container", spec.name));
|
let unit_path = dir.join(format!("{}.container", spec.name));
|
||||||
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
|
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
let expected_image = ensure_image_present(spec).await?;
|
let svc = format!("{}.service", spec.name);
|
||||||
let expected_unit = build_unit(spec, &expected_image);
|
// A hung `systemctl is-active` under IO pressure must not read as
|
||||||
if expected_unit.render() != fs::read_to_string(&unit_path).await.unwrap_or_default() {
|
// "companion dead" — that's a repair (and possibly an image build) fired
|
||||||
|
// off exactly when the node can least afford one.
|
||||||
|
match tokio::time::timeout(Duration::from_secs(10), quadlet::is_active(&svc)).await {
|
||||||
|
Ok(active) => {
|
||||||
|
if !active {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn!(
|
||||||
|
companion = spec.name,
|
||||||
|
"is-active probe timed out; assuming active"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Service is running. Flag it stale only on definitive, cheap signals:
|
||||||
|
// the on-disk unit matching none of the image refs install_one could
|
||||||
|
// have written, or a local build context newer than the built image.
|
||||||
|
let on_disk = fs::read_to_string(&unit_path).await.unwrap_or_default();
|
||||||
|
let local_image = format!("localhost/{}:latest", spec.image_base);
|
||||||
|
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
||||||
|
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||||
|
let matches_known_shape = [&local_image, &local_image_compat, ®istry_image]
|
||||||
|
.iter()
|
||||||
|
.any(|img| build_unit(spec, img).render() == on_disk);
|
||||||
|
if !matches_known_shape {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
let svc = format!("{}.service", spec.name);
|
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
|
||||||
Ok(!quadlet::is_active(&svc).await)
|
for dir in spec.build_dir_candidates {
|
||||||
|
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||||
|
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||||
|
// Conservative on any timeout/error inside: reuse the cache.
|
||||||
|
return Ok(context_is_newer_than_image(dir, &local_image).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -212,9 +212,21 @@ impl DockerPackageScanner {
|
|||||||
website: lan_address.clone(),
|
website: lan_address.clone(),
|
||||||
tier: Some(metadata.tier.to_string()),
|
tier: Some(metadata.tier.to_string()),
|
||||||
interfaces: if lan_address.is_some() || tor_address.is_some() {
|
interfaces: if lan_address.is_some() || tor_address.is_some() {
|
||||||
|
// `ui` is no longer implied by a published port: a
|
||||||
|
// headless backend with an exposed port is a service,
|
||||||
|
// not a launchable app. ui_detection consults the
|
||||||
|
// manifest declaration first, then HTTP-probes the
|
||||||
|
// port. Addresses stay present either way so the
|
||||||
|
// Services tab can still show where a backend lives.
|
||||||
|
let has_ui = super::ui_detection::has_web_ui(
|
||||||
|
&app_id,
|
||||||
|
lan_address.as_deref(),
|
||||||
|
package_state == PackageState::Running,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Some(Interfaces {
|
Some(Interfaces {
|
||||||
main: Some(MainInterface {
|
main: Some(MainInterface {
|
||||||
ui: Some("true".to_string()),
|
ui: has_ui.then(|| "true".to_string()),
|
||||||
tor_config: tor_address.clone(),
|
tor_config: tor_address.clone(),
|
||||||
lan_config: None,
|
lan_config: None,
|
||||||
}),
|
}),
|
||||||
@ -729,7 +741,7 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
|
|||||||
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
|
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
|
||||||
/// drops a reachable launch URL. Reading digits after the final colon mirrors
|
/// drops a reachable launch URL. Reading digits after the final colon mirrors
|
||||||
/// `port_from_url` in the RPC layer and tolerates a trailing path.
|
/// `port_from_url` in the RPC layer and tolerates a trailing path.
|
||||||
fn launch_url_port(url: &str) -> Option<u16> {
|
pub(crate) fn launch_url_port(url: &str) -> Option<u16> {
|
||||||
let after_colon = url.rsplit_once(':')?.1;
|
let after_colon = url.rsplit_once(':')?.1;
|
||||||
after_colon
|
after_colon
|
||||||
.chars()
|
.chars()
|
||||||
|
|||||||
@ -15,6 +15,7 @@ pub mod quadlet;
|
|||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
pub mod ui_detection;
|
||||||
pub mod version_config;
|
pub mod version_config;
|
||||||
|
|
||||||
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
|
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
|
||||||
|
|||||||
@ -248,10 +248,17 @@ impl QuadletUnit {
|
|||||||
} else {
|
} else {
|
||||||
proto.as_str()
|
proto.as_str()
|
||||||
};
|
};
|
||||||
// Keep the rendered directive byte-identical for unbound
|
// Unbound publishes are pinned to 0.0.0.0: rootlessport's
|
||||||
// ports so existing units don't read as drifted.
|
// wildcard bind claims [::] too but BLACK-HOLES inbound v6
|
||||||
|
// (accepts, forwards nothing — "empty response" from the
|
||||||
|
// mesh, confirmed 2026-07-26). Pinning v4 frees the port's
|
||||||
|
// v6 side for the daemon's mesh relay (app_port_v6_relay_loop),
|
||||||
|
// which forwards to the v4 loopback listener that works.
|
||||||
|
// NOTE: this changes the rendered directive for unbound
|
||||||
|
// ports on purpose — the one-time drift/recreate at upgrade
|
||||||
|
// is the deploy vehicle for the fix.
|
||||||
if bind.is_empty() {
|
if bind.is_empty() {
|
||||||
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
|
let _ = writeln!(s, "PublishPort=0.0.0.0:{host}:{container}/{p}");
|
||||||
} else {
|
} else {
|
||||||
let _ = writeln!(s, "PublishPort={bind}:{host}:{container}/{p}");
|
let _ = writeln!(s, "PublishPort={bind}:{host}:{container}/{p}");
|
||||||
}
|
}
|
||||||
@ -1018,7 +1025,7 @@ mod tests {
|
|||||||
u.network = NetworkMode::Bridge("archy-net".into());
|
u.network = NetworkMode::Bridge("archy-net".into());
|
||||||
u.ports = vec![(3000, 3000, "tcp".into(), String::new())];
|
u.ports = vec![(3000, 3000, "tcp".into(), String::new())];
|
||||||
let s = u.render();
|
let s = u.render();
|
||||||
assert!(s.contains("PublishPort=3000:3000/tcp"));
|
assert!(s.contains("PublishPort=0.0.0.0:3000:3000/tcp"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1167,8 +1174,8 @@ mod tests {
|
|||||||
let s = u.render();
|
let s = u.render();
|
||||||
assert!(s.contains("PublishPort=127.0.0.1:8332:8332/tcp"));
|
assert!(s.contains("PublishPort=127.0.0.1:8332:8332/tcp"));
|
||||||
assert!(s.contains("PublishPort=10.89.0.1:8332:8332/tcp"));
|
assert!(s.contains("PublishPort=10.89.0.1:8332:8332/tcp"));
|
||||||
assert!(s.contains("PublishPort=8333:8333/tcp"));
|
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
|
||||||
assert!(!s.contains("PublishPort=8332:8332/tcp"));
|
assert!(!s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1200,8 +1207,8 @@ mod tests {
|
|||||||
..QuadletUnit::default()
|
..QuadletUnit::default()
|
||||||
};
|
};
|
||||||
let s = u.render();
|
let s = u.render();
|
||||||
assert!(s.contains("PublishPort=8332:8332/tcp"));
|
assert!(s.contains("PublishPort=0.0.0.0:8332:8332/tcp"));
|
||||||
assert!(s.contains("PublishPort=8333:8333/tcp"));
|
assert!(s.contains("PublishPort=0.0.0.0:8333:8333/tcp"));
|
||||||
assert!(s.contains("Environment=BITCOIN_RPC_USER=archipelago"));
|
assert!(s.contains("Environment=BITCOIN_RPC_USER=archipelago"));
|
||||||
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
|
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
|
||||||
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
|
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
|
||||||
@ -1317,7 +1324,7 @@ app:
|
|||||||
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
||||||
let s = QuadletUnit::from_manifest(&m, "searxng").render();
|
let s = QuadletUnit::from_manifest(&m, "searxng").render();
|
||||||
|
|
||||||
assert!(s.contains("PublishPort=8888:8080/tcp"));
|
assert!(s.contains("PublishPort=0.0.0.0:8888:8080/tcp"));
|
||||||
assert!(!s.contains("Network=host"));
|
assert!(!s.contains("Network=host"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1746,7 +1753,7 @@ app:
|
|||||||
assert!(body.contains("Network=archy-net"));
|
assert!(body.contains("Network=archy-net"));
|
||||||
assert!(body.contains("NetworkAlias=lnd"));
|
assert!(body.contains("NetworkAlias=lnd"));
|
||||||
assert!(body.contains("PodmanArgs=--network-alias=lnd"));
|
assert!(body.contains("PodmanArgs=--network-alias=lnd"));
|
||||||
assert!(body.contains("PublishPort=10009:10009/tcp"));
|
assert!(body.contains("PublishPort=0.0.0.0:10009:10009/tcp"));
|
||||||
assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z"));
|
assert!(body.contains("Volume=/var/lib/archipelago/lnd:/root/.lnd:Z"));
|
||||||
assert!(body.contains("Environment=LND_NETWORK=mainnet"));
|
assert!(body.contains("Environment=LND_NETWORK=mainnet"));
|
||||||
assert!(body.contains("PodmanArgs=--memory=1024m"));
|
assert!(body.contains("PodmanArgs=--memory=1024m"));
|
||||||
|
|||||||
243
core/archipelago/src/container/ui_detection.rs
Normal file
243
core/archipelago/src/container/ui_detection.rs
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
//! Web-UI detection for discovered containers.
|
||||||
|
//!
|
||||||
|
//! The packages list used to mark every container that published a port (or
|
||||||
|
//! carried an onion address) as a UI app, which gave headless backends —
|
||||||
|
//! databases, media servers, self-deployed compose stacks — a Launch button.
|
||||||
|
//! UI-ness is decided here instead, for manifest apps and ad-hoc containers
|
||||||
|
//! alike:
|
||||||
|
//!
|
||||||
|
//! 1. A manifest that declares an `interfaces:` block is definitive: any
|
||||||
|
//! entry of `type: ui` means a browsable UI, a block without one means a
|
||||||
|
//! backend service. The signed catalog overlay is consulted before disk
|
||||||
|
//! manifests (catalog supremacy).
|
||||||
|
//! 2. Manifests without an `interfaces:` block (the overwhelming majority)
|
||||||
|
//! and manifest-less containers fall through to a short HTTP probe of the
|
||||||
|
//! launch port: an HTML page, a redirect, or a browser-auth wall means a
|
||||||
|
//! UI; JSON APIs, raw TCP protocols, and dead ports mean a service.
|
||||||
|
//!
|
||||||
|
//! Verdicts are cached — positives longer than negatives, since "no UI yet"
|
||||||
|
//! is often just an app that hasn't finished starting.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
/// A confirmed UI stays a UI — re-check rarely.
|
||||||
|
const POSITIVE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||||
|
/// A "no UI" verdict may be a slow-starting app — re-check sooner.
|
||||||
|
const NEGATIVE_TTL: Duration = Duration::from_secs(2 * 60);
|
||||||
|
|
||||||
|
/// Decide whether `app_id` exposes a browsable web UI. `lan_address` is the
|
||||||
|
/// launch candidate already computed by the package scanner (host-published),
|
||||||
|
/// and `running` gates the probe: a stopped container can't answer, and a
|
||||||
|
/// dead-port verdict against it would poison the cache.
|
||||||
|
pub async fn has_web_ui(app_id: &str, lan_address: Option<&str>, running: bool) -> bool {
|
||||||
|
if let Some(declared) = manifest_declares_ui(app_id) {
|
||||||
|
return declared;
|
||||||
|
}
|
||||||
|
let Some(port) = lan_address.and_then(super::docker_packages::launch_url_port) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !running {
|
||||||
|
// Serve a cached verdict if we have one, but never record one.
|
||||||
|
return cached_verdict(app_id, port).unwrap_or(false);
|
||||||
|
}
|
||||||
|
if let Some(v) = cached_verdict(app_id, port) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
let verdict = probe_port(port).await;
|
||||||
|
debug!(app_id, port, verdict, "web-UI probe");
|
||||||
|
cache_verdict(app_id, port, verdict);
|
||||||
|
verdict
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Manifest declarations ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// `Some(true)`: a manifest covers this app and declares a `type: ui`
|
||||||
|
/// interface. `Some(false)`: a manifest declares interfaces, none of them UI.
|
||||||
|
/// `None`: no manifest, or one without an `interfaces:` block — undeclared,
|
||||||
|
/// let the probe decide.
|
||||||
|
fn manifest_declares_ui(app_id: &str) -> Option<bool> {
|
||||||
|
// Catalog overlay first: disk manifests don't apply to catalog-covered
|
||||||
|
// apps, so the catalog must win where it speaks.
|
||||||
|
for (id, value) in super::app_catalog::catalog_manifest_values() {
|
||||||
|
if id == app_id {
|
||||||
|
if let Some(v) = declared_ui_in_value(&value) {
|
||||||
|
return Some(v);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path in disk_manifest_candidates(app_id) {
|
||||||
|
if !path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match archipelago_container::AppManifest::from_file(&path) {
|
||||||
|
Ok(m) => {
|
||||||
|
if m.app.interfaces.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some(m.app.interfaces.values().any(|i| i.interface_type == "ui"));
|
||||||
|
}
|
||||||
|
// Malformed manifests are already reported by the orchestrator's
|
||||||
|
// loader; here they simply don't count as a declaration.
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same declaration logic against a catalog manifest carried as raw JSON.
|
||||||
|
fn declared_ui_in_value(manifest: &serde_json::Value) -> Option<bool> {
|
||||||
|
let interfaces = manifest.get("app")?.get("interfaces")?.as_object()?;
|
||||||
|
if interfaces.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(interfaces.values().any(|i| {
|
||||||
|
// An omitted `type` defaults to "ui", mirroring the YAML schema.
|
||||||
|
i.get("type")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.map(|t| t == "ui")
|
||||||
|
.unwrap_or(true)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disk_manifest_candidates(app_id: &str) -> Vec<std::path::PathBuf> {
|
||||||
|
let mut roots: Vec<std::path::PathBuf> = Vec::new();
|
||||||
|
if let Ok(v) = std::env::var("ARCHIPELAGO_APPS_DIR") {
|
||||||
|
let v = v.trim();
|
||||||
|
if !v.is_empty() {
|
||||||
|
roots.push(v.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots.push("/opt/archipelago/apps".into());
|
||||||
|
roots
|
||||||
|
.into_iter()
|
||||||
|
.map(|root| root.join(app_id).join("manifest.yml"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTTP probe ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn probe_port(port: u16) -> bool {
|
||||||
|
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||||
|
let client = CLIENT.get_or_init(|| {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(PROBE_TIMEOUT)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.expect("static probe client")
|
||||||
|
});
|
||||||
|
match client.get(format!("http://127.0.0.1:{port}/")).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let content_type = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
let auth_challenge = resp
|
||||||
|
.headers()
|
||||||
|
.contains_key(reqwest::header::WWW_AUTHENTICATE);
|
||||||
|
ui_verdict(resp.status().as_u16(), content_type, auth_challenge)
|
||||||
|
}
|
||||||
|
// Connection refused, timeout, or a non-HTTP protocol on the port.
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pure classification rule, split out for tests: a browsable UI is an
|
||||||
|
/// HTML response (any status — error pages included), a redirect (login
|
||||||
|
/// flows), or a browser auth prompt. JSON/plaintext APIs are services.
|
||||||
|
fn ui_verdict(status: u16, content_type: &str, auth_challenge: bool) -> bool {
|
||||||
|
if content_type.contains("text/html") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (300..400).contains(&status) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (status == 401 || status == 403) && auth_challenge {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Verdict cache ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn cache() -> &'static Mutex<HashMap<String, (bool, Instant)>> {
|
||||||
|
static CACHE: OnceLock<Mutex<HashMap<String, (bool, Instant)>>> = OnceLock::new();
|
||||||
|
CACHE.get_or_init(Default::default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cached_verdict(app_id: &str, port: u16) -> Option<bool> {
|
||||||
|
let cache = cache().lock().ok()?;
|
||||||
|
let (verdict, at) = cache.get(&format!("{app_id}:{port}"))?;
|
||||||
|
let ttl = if *verdict { POSITIVE_TTL } else { NEGATIVE_TTL };
|
||||||
|
(at.elapsed() < ttl).then_some(*verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_verdict(app_id: &str, port: u16, verdict: bool) {
|
||||||
|
if let Ok(mut cache) = cache().lock() {
|
||||||
|
cache.insert(format!("{app_id}:{port}"), (verdict, Instant::now()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn html_is_ui_regardless_of_status() {
|
||||||
|
assert!(ui_verdict(200, "text/html; charset=utf-8", false));
|
||||||
|
assert!(ui_verdict(404, "text/html", false));
|
||||||
|
assert!(ui_verdict(401, "text/html", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redirects_are_ui() {
|
||||||
|
assert!(ui_verdict(302, "", false));
|
||||||
|
assert!(ui_verdict(307, "text/plain", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_walls_are_ui_only_with_challenge() {
|
||||||
|
assert!(ui_verdict(401, "text/plain", true));
|
||||||
|
assert!(!ui_verdict(401, "application/json", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apis_and_plain_ports_are_services() {
|
||||||
|
assert!(!ui_verdict(200, "application/json", false));
|
||||||
|
assert!(!ui_verdict(200, "text/plain", false));
|
||||||
|
assert!(!ui_verdict(500, "", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_value_declaration() {
|
||||||
|
let ui = json!({"app": {"interfaces": {"main": {"type": "ui", "port": 80}}}});
|
||||||
|
assert_eq!(declared_ui_in_value(&ui), Some(true));
|
||||||
|
|
||||||
|
// Omitted type defaults to "ui", mirroring the YAML schema default.
|
||||||
|
let default_ty = json!({"app": {"interfaces": {"main": {"port": 80}}}});
|
||||||
|
assert_eq!(declared_ui_in_value(&default_ty), Some(true));
|
||||||
|
|
||||||
|
let api_only = json!({"app": {"interfaces": {"rpc": {"type": "api", "port": 80}}}});
|
||||||
|
assert_eq!(declared_ui_in_value(&api_only), Some(false));
|
||||||
|
|
||||||
|
// No interfaces block ⇒ undeclared, not "no UI".
|
||||||
|
let none = json!({"app": {"id": "x"}});
|
||||||
|
assert_eq!(declared_ui_in_value(&none), None);
|
||||||
|
let empty = json!({"app": {"interfaces": {}}});
|
||||||
|
assert_eq!(declared_ui_in_value(&empty), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verdict_cache_roundtrip() {
|
||||||
|
cache_verdict("test-app", 1234, true);
|
||||||
|
assert_eq!(cached_verdict("test-app", 1234), Some(true));
|
||||||
|
assert_eq!(cached_verdict("test-app", 9999), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -156,7 +156,24 @@ pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
|
|||||||
.with_context(|| format!("read {}", path.display()))?;
|
.with_context(|| format!("read {}", path.display()))?;
|
||||||
let anchors: Vec<SeedAnchor> =
|
let anchors: Vec<SeedAnchor> =
|
||||||
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
|
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
|
||||||
Ok(anchors)
|
Ok(repair_legacy_anchor_set(anchors))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add the newer redundant public TCP anchors to legacy files that only carry
|
||||||
|
/// the Archipelago-operated vps2 anchor. A deliberately custom/private anchor
|
||||||
|
/// list remains authoritative; this only repairs the exact stale shape shipped
|
||||||
|
/// before the join.fips.network anchors became defaults.
|
||||||
|
fn repair_legacy_anchor_set(mut anchors: Vec<SeedAnchor>) -> Vec<SeedAnchor> {
|
||||||
|
let has_archy = anchors.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB);
|
||||||
|
let has_fips_network = fips_network_anchors()
|
||||||
|
.iter()
|
||||||
|
.any(|default| anchors.iter().any(|a| a.npub == default.npub));
|
||||||
|
if has_archy && !has_fips_network {
|
||||||
|
for anchor in fips_network_anchors() {
|
||||||
|
anchors.push(anchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anchors
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the list. Overwrites atomically via write-then-rename so a
|
/// Persist the list. Overwrites atomically via write-then-rename so a
|
||||||
@ -215,26 +232,32 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
|
|||||||
/// leaving `anchor_connected=false` and every peer dial falling back to
|
/// leaving `anchor_connected=false` and every peer dial falling back to
|
||||||
/// a slow Tor timeout.
|
/// a slow Tor timeout.
|
||||||
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
||||||
let mut results = Vec::with_capacity(anchors.len());
|
// Concurrent, each connect hard-capped: the old serial loop waited
|
||||||
for anchor in anchors {
|
// unbounded on every `sudo fipsctl connect`, so one hung subprocess
|
||||||
let out = Command::new("sudo")
|
// stalled the whole apply — and the periodic anchor tick behind it,
|
||||||
.args([
|
// which is exactly when a wedged daemon most needs the re-apply.
|
||||||
"-n",
|
let futs = anchors.iter().cloned().map(|anchor| async move {
|
||||||
"fipsctl",
|
let out = tokio::time::timeout(
|
||||||
"connect",
|
std::time::Duration::from_secs(15),
|
||||||
&anchor.npub,
|
Command::new("sudo")
|
||||||
&anchor.address,
|
.args([
|
||||||
&anchor.transport,
|
"-n",
|
||||||
])
|
"fipsctl",
|
||||||
.output()
|
"connect",
|
||||||
.await;
|
&anchor.npub,
|
||||||
|
&anchor.address,
|
||||||
|
&anchor.transport,
|
||||||
|
])
|
||||||
|
.output(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let result = match out {
|
let result = match out {
|
||||||
Ok(o) if o.status.success() => ApplyResult {
|
Ok(Ok(o)) if o.status.success() => ApplyResult {
|
||||||
npub: anchor.npub.clone(),
|
npub: anchor.npub.clone(),
|
||||||
ok: true,
|
ok: true,
|
||||||
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
message: String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||||
},
|
},
|
||||||
Ok(o) => ApplyResult {
|
Ok(Ok(o)) => ApplyResult {
|
||||||
npub: anchor.npub.clone(),
|
npub: anchor.npub.clone(),
|
||||||
ok: false,
|
ok: false,
|
||||||
message: format!(
|
message: format!(
|
||||||
@ -243,11 +266,16 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
|||||||
String::from_utf8_lossy(&o.stderr).trim()
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
Err(e) => ApplyResult {
|
Ok(Err(e)) => ApplyResult {
|
||||||
npub: anchor.npub.clone(),
|
npub: anchor.npub.clone(),
|
||||||
ok: false,
|
ok: false,
|
||||||
message: format!("sudo fipsctl launch failed: {}", e),
|
message: format!("sudo fipsctl launch failed: {}", e),
|
||||||
},
|
},
|
||||||
|
Err(_) => ApplyResult {
|
||||||
|
npub: anchor.npub.clone(),
|
||||||
|
ok: false,
|
||||||
|
message: "sudo fipsctl connect timed out after 15s".to_string(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if result.ok {
|
if result.ok {
|
||||||
tracing::debug!(npub = %result.npub, "Seed anchor applied");
|
tracing::debug!(npub = %result.npub, "Seed anchor applied");
|
||||||
@ -258,9 +286,9 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
|
|||||||
"Seed anchor apply failed (non-fatal)"
|
"Seed anchor apply failed (non-fatal)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
results.push(result);
|
result
|
||||||
}
|
});
|
||||||
results
|
futures_util::future::join_all(futs).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome of a single `fipsctl connect` call.
|
/// Outcome of a single `fipsctl connect` call.
|
||||||
@ -273,12 +301,12 @@ pub struct ApplyResult {
|
|||||||
|
|
||||||
/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated
|
/// 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.
|
/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port.
|
||||||
const FIPS_UDP_PORT: u16 = 8668;
|
const FIPS_UDP_PORT: u16 = crate::fips::PUBLISHED_UDP_PORT;
|
||||||
|
|
||||||
/// Build transient seed-anchor entries that dial LAN-discovered federation peers
|
/// Build transient seed-anchor entries that dial LAN-discovered federation peers
|
||||||
/// directly over their FIPS UDP transport. For each peer the registry knows both
|
/// 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
|
/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at
|
||||||
/// `<lan-ip>:8668`. This lets co-located federation nodes form a DIRECT FIPS link
|
/// `<lan-ip>:<FIPS_UDP_PORT>`. This lets co-located federation nodes form a DIRECT FIPS link
|
||||||
/// instead of depending on the global anchor's spanning tree to route between
|
/// 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).
|
/// them (the cause of every dial falling back to Tor when the anchor link flaps).
|
||||||
///
|
///
|
||||||
@ -338,6 +366,30 @@ mod tests {
|
|||||||
assert!(got.iter().all(|a| a.transport == "tcp"));
|
assert!(got.iter().all(|a| a.transport == "tcp"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_repairs_legacy_archy_only_anchor_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
save(dir.path(), &[archy_anchor()]).await.unwrap();
|
||||||
|
|
||||||
|
let got = load(dir.path()).await.unwrap();
|
||||||
|
assert!(got.iter().any(|a| a.npub == ARCHY_ANCHOR_NPUB));
|
||||||
|
for anchor in fips_network_anchors() {
|
||||||
|
assert!(got.iter().any(|a| a.npub == anchor.npub));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_keeps_private_anchor_file_authoritative() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let private = mk("npub1private");
|
||||||
|
save(dir.path(), std::slice::from_ref(&private))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let got = load(dir.path()).await.unwrap();
|
||||||
|
assert_eq!(got, vec![private]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn removing_one_default_persists_and_keeps_the_other() {
|
async fn removing_one_default_persists_and_keeps_the_other() {
|
||||||
// Editing the anchor list (here removing one default) makes the file
|
// Editing the anchor list (here removing one default) makes the file
|
||||||
@ -407,4 +459,39 @@ mod tests {
|
|||||||
assert_eq!(a.transport, "udp");
|
assert_eq!(a.transport, "udp");
|
||||||
assert_eq!(a.label, "");
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
core/archipelago/src/fips/app_ports.rs
Normal file
12
core/archipelago/src/fips/app_ports.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
//! Generated by scripts/generate-app-catalog.py. Do not edit manually.
|
||||||
|
//!
|
||||||
|
//! Catalog app launch ports (the web UIs the companion opens by direct
|
||||||
|
//! port). Used to write the fips0 firewall allowance drop-in so app UIs
|
||||||
|
//! are reachable over the mesh; ports of apps that aren't installed have
|
||||||
|
//! no listener, so allowing them is inert.
|
||||||
|
|
||||||
|
pub const APP_LAUNCH_PORTS: &[u16] = &[
|
||||||
|
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
|
||||||
|
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8888, 8999, 9000, 9100, 10380, 11434, 18081,
|
||||||
|
18083, 23000, 32838, 50002,
|
||||||
|
];
|
||||||
@ -48,6 +48,30 @@ pub struct FipsConfig {
|
|||||||
pub struct NodeSection {
|
pub struct NodeSection {
|
||||||
pub identity: IdentitySection,
|
pub identity: IdentitySection,
|
||||||
pub discovery: DiscoverySection,
|
pub discovery: DiscoverySection,
|
||||||
|
pub retry: RetrySection,
|
||||||
|
pub rate_limit: RateLimitSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fast-reconnect profile (`node.retry.*`). Upstream defaults (5s base
|
||||||
|
/// doubling to 300s) are tuned for stable always-on links; a node redialing
|
||||||
|
/// a recycled anchor sat off-mesh for ~90s. Field names verified live on
|
||||||
|
/// 2026-07-24: a daemon restarted with these keys in fips.yaml and peered.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
|
pub struct RetrySection {
|
||||||
|
pub base_interval_secs: u64,
|
||||||
|
pub max_backoff_secs: u64,
|
||||||
|
pub max_retries: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session-handshake resend pacing (`node.rate_limit.*`). Stock 1s x2.0
|
||||||
|
/// gaps out to 8-16s between resends exactly when a route has just
|
||||||
|
/// appeared; 400ms x1.5 keeps continuous coverage through the connect
|
||||||
|
/// window (phone measured session-after-route: 225ms).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
|
pub struct RateLimitSection {
|
||||||
|
pub handshake_resend_interval_ms: u64,
|
||||||
|
pub handshake_resend_backoff: f64,
|
||||||
|
pub handshake_max_resends: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
@ -60,6 +84,14 @@ pub struct IdentitySection {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
pub struct DiscoverySection {
|
pub struct DiscoverySection {
|
||||||
|
/// Lookup completion timeout. Lookups fired while the tree position is
|
||||||
|
/// still settling are doomed; failing them fast (5s, not 10s) lets the
|
||||||
|
/// 1s-backoff retry find the route the moment it exists.
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
pub backoff_base_secs: u64,
|
||||||
|
pub backoff_max_secs: u64,
|
||||||
|
pub retry_interval_secs: u64,
|
||||||
|
pub max_attempts: u8,
|
||||||
pub lan: LanDiscoverySection,
|
pub lan: LanDiscoverySection,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,8 +155,23 @@ impl Default for FipsConfig {
|
|||||||
node: NodeSection {
|
node: NodeSection {
|
||||||
identity: IdentitySection { persistent: true },
|
identity: IdentitySection { persistent: true },
|
||||||
discovery: DiscoverySection {
|
discovery: DiscoverySection {
|
||||||
|
timeout_secs: 5,
|
||||||
|
backoff_base_secs: 1,
|
||||||
|
backoff_max_secs: 30,
|
||||||
|
retry_interval_secs: 2,
|
||||||
|
max_attempts: 3,
|
||||||
lan: LanDiscoverySection { enabled: true },
|
lan: LanDiscoverySection { enabled: true },
|
||||||
},
|
},
|
||||||
|
retry: RetrySection {
|
||||||
|
base_interval_secs: 1,
|
||||||
|
max_backoff_secs: 30,
|
||||||
|
max_retries: 30,
|
||||||
|
},
|
||||||
|
rate_limit: RateLimitSection {
|
||||||
|
handshake_resend_interval_ms: 400,
|
||||||
|
handshake_resend_backoff: 1.5,
|
||||||
|
handshake_max_resends: 10,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tun: TunSection {
|
tun: TunSection {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -186,6 +233,78 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
|
|||||||
let _ = tokio::fs::remove_file(&stage).await;
|
let _ = tokio::fs::remove_file(&stage).await;
|
||||||
install_result?;
|
install_result?;
|
||||||
|
|
||||||
|
// The release-hardening firewall (/etc/fips/fips.nft, provisioned
|
||||||
|
// out-of-band) default-denies inbound on fips0 — without an explicit
|
||||||
|
// allowance the node's web UI is unreachable over the mesh (phones got
|
||||||
|
// RST on :80 with a healthy session; root-caused 2026-07-26 on
|
||||||
|
// 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 nft_stage = std::env::temp_dir().join(format!("fips-webui-{}.nft", std::process::id()));
|
||||||
|
tokio::fs::write(&nft_stage, dropin)
|
||||||
|
.await
|
||||||
|
.context("Failed to stage web-ui nft drop-in")?;
|
||||||
|
let nft_install = sudo_install_file(&nft_stage, "/etc/fips/fips.d/80-web-ui.nft", "0644").await;
|
||||||
|
let _ = tokio::fs::remove_file(&nft_stage).await;
|
||||||
|
nft_install?;
|
||||||
|
|
||||||
|
// App launch ports: the companion opens catalog apps by direct port
|
||||||
|
// over the mesh. Ports of apps that aren't installed have no listener,
|
||||||
|
// so the allowance is inert until an app exists to answer.
|
||||||
|
let port_list = super::app_ports::APP_LAUNCH_PORTS
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let app_dropin = format!(
|
||||||
|
"# Written by archipelago on every daemon config install.\n\
|
||||||
|
# Catalog app launch ports (web UIs) allowed through the fips0\n\
|
||||||
|
# default-deny inbound baseline. Service/RPC ports stay closed.\n\
|
||||||
|
tcp dport {{ {port_list} }} accept\n"
|
||||||
|
);
|
||||||
|
let app_stage = std::env::temp_dir().join(format!("fips-appports-{}.nft", std::process::id()));
|
||||||
|
tokio::fs::write(&app_stage, app_dropin)
|
||||||
|
.await
|
||||||
|
.context("Failed to stage app-ports nft drop-in")?;
|
||||||
|
let app_install =
|
||||||
|
sudo_install_file(&app_stage, "/etc/fips/fips.d/85-app-ports.nft", "0644").await;
|
||||||
|
let _ = tokio::fs::remove_file(&app_stage).await;
|
||||||
|
app_install?;
|
||||||
|
// Make the allowance live immediately; a no-op error when the
|
||||||
|
// hardening baseline isn't installed on this node yet.
|
||||||
|
if tokio::fs::try_exists("/etc/fips/fips.nft")
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
match Command::new("sudo")
|
||||||
|
.args(["nft", "-f", "/etc/fips/fips.nft"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(out) if !out.status.success() => tracing::warn!(
|
||||||
|
"nft reload after web-ui drop-in failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
),
|
||||||
|
Err(e) => tracing::warn!("nft reload after web-ui drop-in failed: {e}"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
|
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
|
||||||
// Heal a legacy fips_key.pub that was written as bech32 npub text
|
// Heal a legacy fips_key.pub that was written as bech32 npub text
|
||||||
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
|
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
|
||||||
@ -318,8 +437,21 @@ node:
|
|||||||
identity:
|
identity:
|
||||||
persistent: true
|
persistent: true
|
||||||
discovery:
|
discovery:
|
||||||
|
timeout_secs: 5
|
||||||
|
backoff_base_secs: 1
|
||||||
|
backoff_max_secs: 30
|
||||||
|
retry_interval_secs: 2
|
||||||
|
max_attempts: 3
|
||||||
lan:
|
lan:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
retry:
|
||||||
|
base_interval_secs: 1
|
||||||
|
max_backoff_secs: 30
|
||||||
|
max_retries: 30
|
||||||
|
rate_limit:
|
||||||
|
handshake_resend_interval_ms: 400
|
||||||
|
handshake_resend_backoff: 1.5
|
||||||
|
handshake_max_resends: 10
|
||||||
tun:
|
tun:
|
||||||
enabled: true
|
enabled: true
|
||||||
name: fips0
|
name: fips0
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
//! ```
|
//! ```
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use super::telemetry::{self, FallbackReason};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::net::{IpAddr, Ipv6Addr};
|
use std::net::{IpAddr, Ipv6Addr};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@ -150,6 +151,12 @@ pub async fn warm_path(npub: &str) {
|
|||||||
if !is_service_active().await {
|
if !is_service_active().await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
warm_path_unchecked(npub).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`warm_path`] without the service-active check — for callers (the warm
|
||||||
|
/// tick) that already verified the daemon once for the whole batch.
|
||||||
|
pub async fn warm_path_unchecked(npub: &str) {
|
||||||
let Ok(base) = peer_base_url(npub).await else {
|
let Ok(base) = peer_base_url(npub).await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@ -276,21 +283,39 @@ pub fn as_ip_addr(v6: Ipv6Addr) -> IpAddr {
|
|||||||
|
|
||||||
// ── High-level peer request helpers ────────────────────────────────────
|
// ── High-level peer request helpers ────────────────────────────────────
|
||||||
|
|
||||||
|
/// TTL for the [`is_service_active`] cache. Every FIPS dial attempt and
|
||||||
|
/// every warm-tick peer used to spawn up to two `systemctl` subprocesses;
|
||||||
|
/// service state changes on human timescales, so 10s staleness is free.
|
||||||
|
const SERVICE_ACTIVE_TTL_MS: u64 = 10_000;
|
||||||
|
static SERVICE_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
static SERVICE_PROBED_AT_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
|
||||||
/// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream)
|
/// Quick poll: is the FIPS daemon (archipelago-supervised OR upstream)
|
||||||
/// currently `systemctl is-active`? Async wrapper intended for the
|
/// currently `systemctl is-active`? Cached for [`SERVICE_ACTIVE_TTL_MS`];
|
||||||
/// migration call sites; unlike `FipsTransport::is_available` this does
|
/// concurrent refreshes are harmless (idempotent probe, last write wins).
|
||||||
/// not maintain a cache, so callers that poll frequently should cache
|
|
||||||
/// themselves.
|
|
||||||
pub async fn is_service_active() -> bool {
|
pub async fn is_service_active() -> bool {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
let now_ms = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let probed_at = SERVICE_PROBED_AT_MS.load(Ordering::Relaxed);
|
||||||
|
if probed_at != 0 && now_ms.saturating_sub(probed_at) < SERVICE_ACTIVE_TTL_MS {
|
||||||
|
return SERVICE_ACTIVE.load(Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
let mut active = false;
|
||||||
for unit in [
|
for unit in [
|
||||||
crate::fips::SERVICE_UNIT,
|
crate::fips::SERVICE_UNIT,
|
||||||
crate::fips::UPSTREAM_SERVICE_UNIT,
|
crate::fips::UPSTREAM_SERVICE_UNIT,
|
||||||
] {
|
] {
|
||||||
if crate::fips::service::unit_state(unit).await == "active" {
|
if crate::fips::service::unit_state(unit).await == "active" {
|
||||||
return true;
|
active = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
SERVICE_ACTIVE.store(active, Ordering::Relaxed);
|
||||||
|
SERVICE_PROBED_AT_MS.store(now_ms, Ordering::Relaxed);
|
||||||
|
active
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builder for a peer request that may be sent over FIPS (preferred) or
|
/// Builder for a peer request that may be sent over FIPS (preferred) or
|
||||||
@ -317,6 +342,11 @@ pub struct PeerRequest<'a> {
|
|||||||
/// large content download needs so its long FIPS transfer isn't truncated.
|
/// large content download needs so its long FIPS transfer isn't truncated.
|
||||||
pub fips_timeout: Option<std::time::Duration>,
|
pub fips_timeout: Option<std::time::Duration>,
|
||||||
pub service: Option<crate::settings::transport::PeerService>,
|
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> {
|
impl<'a> PeerRequest<'a> {
|
||||||
@ -329,6 +359,27 @@ impl<'a> PeerRequest<'a> {
|
|||||||
timeout: std::time::Duration::from_secs(30),
|
timeout: std::time::Duration::from_secs(30),
|
||||||
fips_timeout: None,
|
fips_timeout: None,
|
||||||
service: 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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -389,8 +440,22 @@ impl<'a> PeerRequest<'a> {
|
|||||||
// fix (404 path-not-served / 5xx) and we're allowed to
|
// fix (404 path-not-served / 5xx) and we're allowed to
|
||||||
// fall back. FIPS-only never falls back.
|
// fall back. FIPS-only never falls back.
|
||||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
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));
|
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 => {
|
None => {
|
||||||
if pref == TransportPref::Fips {
|
if pref == TransportPref::Fips {
|
||||||
@ -402,6 +467,7 @@ impl<'a> PeerRequest<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let resp = self.send_tor_post_json(body).await?;
|
let resp = self.send_tor_post_json(body).await?;
|
||||||
|
self.spawn_record(crate::transport::TransportKind::Tor);
|
||||||
Ok((resp, crate::transport::TransportKind::Tor))
|
Ok((resp, crate::transport::TransportKind::Tor))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -413,8 +479,22 @@ impl<'a> PeerRequest<'a> {
|
|||||||
match self.try_fips_get().await? {
|
match self.try_fips_get().await? {
|
||||||
Some(resp) => {
|
Some(resp) => {
|
||||||
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
|
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));
|
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 => {
|
None => {
|
||||||
if pref == TransportPref::Fips {
|
if pref == TransportPref::Fips {
|
||||||
@ -426,6 +506,7 @@ impl<'a> PeerRequest<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let resp = self.send_tor_get().await?;
|
let resp = self.send_tor_get().await?;
|
||||||
|
self.spawn_record(crate::transport::TransportKind::Tor);
|
||||||
Ok((resp, crate::transport::TransportKind::Tor))
|
Ok((resp, crate::transport::TransportKind::Tor))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -434,67 +515,127 @@ impl<'a> PeerRequest<'a> {
|
|||||||
body: &B,
|
body: &B,
|
||||||
) -> Result<Option<reqwest::Response>> {
|
) -> Result<Option<reqwest::Response>> {
|
||||||
let Some(npub) = self.fips_npub else {
|
let Some(npub) = self.fips_npub else {
|
||||||
|
telemetry::record_fallback(FallbackReason::NoNpub);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
if !is_service_active().await {
|
if !is_service_active().await {
|
||||||
|
telemetry::record_fallback(FallbackReason::ServiceInactive);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let base = match peer_base_url(npub).await {
|
let base = match peer_base_url(npub).await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
|
telemetry::record_fallback(FallbackReason::DnsFail);
|
||||||
|
tracing::info!(
|
||||||
|
reason = FallbackReason::DnsFail.key(),
|
||||||
|
"FIPS resolve for {} failed: {}, falling back to Tor",
|
||||||
|
npub,
|
||||||
|
e
|
||||||
|
);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let url = format!("{}{}", base, self.path);
|
let url = format!("{}{}", base, self.path);
|
||||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
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 mut rb = c.post(&url).json(body);
|
let mut rb = c.post(&url).json(body);
|
||||||
for (k, v) in &self.headers {
|
for (k, v) in &self.headers {
|
||||||
rb = rb.header(*k, v);
|
rb = rb.header(*k, v);
|
||||||
}
|
}
|
||||||
match send_with_retry(rb).await {
|
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
||||||
Ok(r) => Ok(Some(r)),
|
Ok(Ok(r)) => Ok(Some(r)),
|
||||||
Err(e) => {
|
Ok(Err(e)) => {
|
||||||
tracing::debug!(
|
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||||
|
tracing::info!(
|
||||||
|
reason = FallbackReason::ConnectFail.key(),
|
||||||
"FIPS POST {} failed after retry: {}, falling back to Tor",
|
"FIPS POST {} failed after retry: {}, falling back to Tor",
|
||||||
url,
|
url,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
Ok(None)
|
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>> {
|
async fn try_fips_get(&self) -> Result<Option<reqwest::Response>> {
|
||||||
let Some(npub) = self.fips_npub else {
|
let Some(npub) = self.fips_npub else {
|
||||||
|
telemetry::record_fallback(FallbackReason::NoNpub);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
if !is_service_active().await {
|
if !is_service_active().await {
|
||||||
|
telemetry::record_fallback(FallbackReason::ServiceInactive);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let base = match peer_base_url(npub).await {
|
let base = match peer_base_url(npub).await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!("FIPS resolve for {} failed: {}", npub, e);
|
telemetry::record_fallback(FallbackReason::DnsFail);
|
||||||
|
tracing::info!(
|
||||||
|
reason = FallbackReason::DnsFail.key(),
|
||||||
|
"FIPS resolve for {} failed: {}, falling back to Tor",
|
||||||
|
npub,
|
||||||
|
e
|
||||||
|
);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let url = format!("{}{}", base, self.path);
|
let url = format!("{}{}", base, self.path);
|
||||||
let c = client_with_timeout(self.fips_attempt_timeout());
|
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 mut rb = c.get(&url);
|
let mut rb = c.get(&url);
|
||||||
for (k, v) in &self.headers {
|
for (k, v) in &self.headers {
|
||||||
rb = rb.header(*k, v);
|
rb = rb.header(*k, v);
|
||||||
}
|
}
|
||||||
match send_with_retry(rb).await {
|
match tokio::time::timeout(budget, send_with_retry(rb)).await {
|
||||||
Ok(r) => Ok(Some(r)),
|
Ok(Ok(r)) => Ok(Some(r)),
|
||||||
Err(e) => {
|
Ok(Err(e)) => {
|
||||||
tracing::debug!(
|
telemetry::record_fallback(FallbackReason::ConnectFail);
|
||||||
|
tracing::info!(
|
||||||
|
reason = FallbackReason::ConnectFail.key(),
|
||||||
"FIPS GET {} failed after retry: {}, falling back to Tor",
|
"FIPS GET {} failed after retry: {}, falling back to Tor",
|
||||||
url,
|
url,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
Ok(None)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,10 +26,12 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
pub mod anchors;
|
pub mod anchors;
|
||||||
|
pub mod app_ports;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod dial;
|
pub mod dial;
|
||||||
pub mod iface;
|
pub mod iface;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
pub mod telemetry;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -53,7 +55,8 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
|||||||
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
|
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(e) = service::activate(SERVICE_UNIT).await {
|
let unit = service::activation_unit().await;
|
||||||
|
if let Err(e) = service::activate(unit).await {
|
||||||
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
|
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -77,25 +80,71 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
|||||||
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
|
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||||
|
// Connectivity watcher state: re-apply seed anchors the moment the
|
||||||
|
// anchor link drops (edge) or the data path degrades (dials keep
|
||||||
|
// failing with zero successes), instead of waiting for the 300s
|
||||||
|
// anchor tick. Bounded: at most one re-apply per RE_APPLY_BACKOFF.
|
||||||
|
const RE_APPLY_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
let mut prev_connected: Option<bool> = None;
|
||||||
|
let mut prev_totals = telemetry::totals();
|
||||||
|
let mut last_apply: Option<std::time::Instant> = None;
|
||||||
loop {
|
loop {
|
||||||
tick.tick().await;
|
tick.tick().await;
|
||||||
// Bring FIPS up on its own once onboarding has materialised the key.
|
// Bring FIPS up on its own once onboarding has materialised the key.
|
||||||
ensure_activated(&data_dir).await;
|
ensure_activated(&data_dir).await;
|
||||||
if !dial::is_service_active().await {
|
if !dial::is_service_active().await {
|
||||||
|
prev_connected = None; // daemon restart = fresh edge detection
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Warm the union of federation peers + configured seed
|
||||||
|
// anchors. Warming only federation npubs left the direct
|
||||||
|
// anchors (vps2, LAN peers) to go cold between 300s ticks.
|
||||||
let nodes = crate::federation::load_nodes(&data_dir)
|
let nodes = crate::federation::load_nodes(&data_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let seed = anchors::load(&data_dir).await.unwrap_or_default();
|
||||||
|
let mut warm_npubs: std::collections::BTreeSet<String> =
|
||||||
|
nodes.iter().filter_map(|n| n.fips_npub.clone()).collect();
|
||||||
|
warm_npubs.extend(seed.iter().map(|a| a.npub.clone()));
|
||||||
let mut handles = Vec::new();
|
let mut handles = Vec::new();
|
||||||
for node in nodes {
|
for npub in warm_npubs {
|
||||||
if let Some(npub) = node.fips_npub.clone() {
|
// Service-active was checked once above for the whole batch.
|
||||||
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await }));
|
handles.push(tokio::spawn(async move {
|
||||||
}
|
dial::warm_path_unchecked(&npub).await
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
for h in handles {
|
for h in handles {
|
||||||
let _ = h.await;
|
let _ = h.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Connectivity watcher: detect anchor-link loss AND silent
|
||||||
|
// data-path death (daemon reports "connected" but every dial
|
||||||
|
// connect-fails — observed live on .198, 2026-07-27, where the
|
||||||
|
// 300s tick never healed it).
|
||||||
|
let mut anchor_npubs = vec![service::PUBLIC_ANCHOR_NPUB.to_string()];
|
||||||
|
anchor_npubs.extend(seed.iter().map(|a| a.npub.clone()));
|
||||||
|
let (_, connected) = service::peer_connectivity_summary(&anchor_npubs).await;
|
||||||
|
let totals = telemetry::totals();
|
||||||
|
let link_dropped = prev_connected == Some(true) && !connected;
|
||||||
|
let never_connected = prev_connected.is_none() && !connected;
|
||||||
|
let data_path_dead =
|
||||||
|
totals.1.saturating_sub(prev_totals.1) >= 5 && totals.0 == prev_totals.0;
|
||||||
|
prev_connected = Some(connected);
|
||||||
|
prev_totals = totals;
|
||||||
|
|
||||||
|
let backoff_ok = last_apply.is_none_or(|t| t.elapsed() >= RE_APPLY_BACKOFF);
|
||||||
|
if (link_dropped || never_connected || data_path_dead) && backoff_ok && !seed.is_empty()
|
||||||
|
{
|
||||||
|
tracing::info!(
|
||||||
|
link_dropped,
|
||||||
|
never_connected,
|
||||||
|
data_path_dead,
|
||||||
|
"FIPS connectivity degraded — re-applying seed anchors now"
|
||||||
|
);
|
||||||
|
last_apply = Some(std::time::Instant::now());
|
||||||
|
let _ = anchors::apply(&seed).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,16 @@ 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.
|
/// Whether the `fips` debian package is installed on the host.
|
||||||
pub async fn package_installed() -> bool {
|
pub async fn package_installed() -> bool {
|
||||||
// dpkg-query -W -f='${Status}' fips → "install ok installed" when present.
|
// dpkg-query -W -f='${Status}' fips → "install ok installed" when present.
|
||||||
@ -81,6 +91,7 @@ async fn sudo_systemctl(verb: &str, unit: &str) -> Result<()> {
|
|||||||
/// Unmask + start + enable the FIPS service. Idempotent — safe to call
|
/// Unmask + start + enable the FIPS service. Idempotent — safe to call
|
||||||
/// on every backend startup once the key is on disk.
|
/// on every backend startup once the key is on disk.
|
||||||
pub async fn activate(unit: &str) -> Result<()> {
|
pub async fn activate(unit: &str) -> Result<()> {
|
||||||
|
kill_stale_daemons().await?;
|
||||||
// Order matters: unmask before enable/start, otherwise enable fails
|
// Order matters: unmask before enable/start, otherwise enable fails
|
||||||
// on a masked unit.
|
// on a masked unit.
|
||||||
sudo_systemctl("unmask", unit).await?;
|
sudo_systemctl("unmask", unit).await?;
|
||||||
@ -94,20 +105,64 @@ pub async fn stop(unit: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn restart(unit: &str) -> Result<()> {
|
pub async fn restart(unit: &str) -> Result<()> {
|
||||||
|
kill_stale_daemons().await?;
|
||||||
sudo_systemctl("restart", unit).await
|
sudo_systemctl("restart", unit).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve which systemd unit is actually supervising the fips daemon
|
/// Kill orphaned `fips` processes not owned by either known systemd unit.
|
||||||
/// on this host. Nodes installed from the archipelago ISO run
|
///
|
||||||
/// `archipelago-fips.service`; nodes that were apt-installed (or had
|
/// Field failure, 2026-07-24: a stale daemon survived outside systemd and kept
|
||||||
/// fips running before archipelago took over) may only have the
|
/// `0.0.0.0:8443` bound. The supervised daemon then started UDP-only, so every
|
||||||
/// upstream `fips.service`. Restart/Reconnect must operate on whichever
|
/// TCP seed-anchor connect failed with "no operational transport" and phones
|
||||||
/// one is running, otherwise the UI button is a silent no-op.
|
/// on 5G could not discover the node. We keep the cleanup narrow: preserve the
|
||||||
|
/// MainPID of both units and terminate only extra `pgrep -x fips` matches.
|
||||||
|
pub async fn kill_stale_daemons() -> Result<()> {
|
||||||
|
let script = format!(
|
||||||
|
r#"keep="$(systemctl show -p MainPID --value {managed} 2>/dev/null; systemctl show -p MainPID --value {upstream} 2>/dev/null)"
|
||||||
|
for pid in $(pgrep -x fips 2>/dev/null || true); do
|
||||||
|
case " $keep " in
|
||||||
|
*" $pid "*) ;;
|
||||||
|
*) kill "$pid" 2>/dev/null || true ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
"#,
|
||||||
|
managed = super::SERVICE_UNIT,
|
||||||
|
upstream = super::UPSTREAM_SERVICE_UNIT,
|
||||||
|
);
|
||||||
|
let out = Command::new("sudo")
|
||||||
|
.args(["sh", "-c", &script])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("sudo stale fips cleanup failed to launch")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
|
anyhow::bail!("stale fips cleanup failed: {}", stderr);
|
||||||
|
}
|
||||||
|
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.
|
||||||
///
|
///
|
||||||
/// Returns the archipelago-managed unit name if it's active,
|
/// Returns the archipelago-managed unit name if it's active,
|
||||||
/// else the upstream unit name if that's active,
|
/// else the upstream unit name if that's active,
|
||||||
/// else the archipelago-managed name as a default (so activate() can
|
/// else a startable activation unit.
|
||||||
/// bring it up).
|
|
||||||
pub async fn active_unit() -> &'static str {
|
pub async fn active_unit() -> &'static str {
|
||||||
if unit_state(super::SERVICE_UNIT).await == "active" {
|
if unit_state(super::SERVICE_UNIT).await == "active" {
|
||||||
return super::SERVICE_UNIT;
|
return super::SERVICE_UNIT;
|
||||||
@ -115,7 +170,7 @@ pub async fn active_unit() -> &'static str {
|
|||||||
if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" {
|
if unit_state(super::UPSTREAM_SERVICE_UNIT).await == "active" {
|
||||||
return super::UPSTREAM_SERVICE_UNIT;
|
return super::UPSTREAM_SERVICE_UNIT;
|
||||||
}
|
}
|
||||||
super::SERVICE_UNIT
|
activation_unit().await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mask(unit: &str) -> Result<()> {
|
pub async fn mask(unit: &str) -> Result<()> {
|
||||||
@ -214,4 +269,10 @@ mod tests {
|
|||||||
// Must not panic regardless of host state.
|
// Must not panic regardless of host state.
|
||||||
let _ = package_installed().await;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
155
core/archipelago/src/fips/telemetry.rs
Normal file
155
core/archipelago/src/fips/telemetry.rs
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
//! 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 (F1–F6). 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(fips_ok, connect_fail)` totals for the connectivity watcher: a window
|
||||||
|
/// where connect_fail grows while fips_ok doesn't is a degraded data path —
|
||||||
|
/// including the "daemon says connected but packets blackhole" failure the
|
||||||
|
/// link-state check alone can't see (observed live 2026-07-27 on .198).
|
||||||
|
pub fn totals() -> (u64, u64) {
|
||||||
|
(
|
||||||
|
FIPS_OK.load(Ordering::Relaxed),
|
||||||
|
CONNECT_FAIL.load(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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -199,6 +199,26 @@ async fn main() -> Result<()> {
|
|||||||
// Now mark this instance as running so the next startup can detect a crash.
|
// Now mark this instance as running so the next startup can detect a crash.
|
||||||
crash_recovery::write_pid_marker(&config.data_dir).await?;
|
crash_recovery::write_pid_marker(&config.data_dir).await?;
|
||||||
|
|
||||||
|
// Signal READY *before* the heavy synchronous boot recovery below. On a
|
||||||
|
// node with many stacks that recovery takes minutes, and the unit sat in
|
||||||
|
// `activating` the whole time — so anything that touched the service in
|
||||||
|
// that window (a superseding start/restart, a start-timeout) killed a
|
||||||
|
// half-started instance, which then exited 0 and (under the old
|
||||||
|
// Restart=on-failure) never came back: "server starting up" forever,
|
||||||
|
// reproduced on framework-pt installing apps on 2026-07-26. The daemon's
|
||||||
|
// real work (recovery, reconcile, listener) continues after READY; being
|
||||||
|
// "active" early is honest — the process is up and doing its job.
|
||||||
|
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Ready]);
|
||||||
|
// Watchdog pings must run DURING the long recovery too, or a slow boot
|
||||||
|
// trips WatchdogSec. Spawn the keepalive here rather than after serve().
|
||||||
|
tokio::spawn(async {
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(120));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Run crash recovery before starting the manifest reconciler. Both paths
|
// Run crash recovery before starting the manifest reconciler. Both paths
|
||||||
// mutate Podman; running them concurrently can corrupt transient runtime
|
// mutate Podman; running them concurrently can corrupt transient runtime
|
||||||
// state and leave netavark/conmon unable to start containers.
|
// state and leave netavark/conmon unable to start containers.
|
||||||
@ -479,16 +499,9 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
// Notify systemd that we're ready (Type=notify)
|
// Notify systemd that we're ready (Type=notify)
|
||||||
// Note: first param `false` keeps NOTIFY_SOCKET so watchdog pings work
|
// Note: first param `false` keeps NOTIFY_SOCKET so watchdog pings work
|
||||||
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Ready]);
|
// READY + watchdog keepalive were already signalled/spawned earlier
|
||||||
|
// (before boot recovery) so the unit reaches `active` in seconds instead
|
||||||
// Spawn systemd watchdog ping (WatchdogSec=300, ping every 120s)
|
// of sitting in `activating` through a minutes-long recovery.
|
||||||
tokio::spawn(async {
|
|
||||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(120));
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Graceful shutdown: wait for SIGTERM or SIGINT
|
// Graceful shutdown: wait for SIGTERM or SIGINT
|
||||||
let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate())
|
let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||||
|
|||||||
972
core/archipelago/src/mesh/flash.rs
Normal file
972
core/archipelago/src/mesh/flash.rs
Normal file
@ -0,0 +1,972 @@
|
|||||||
|
// WIP mesh/transport protocol — suppress dead code warnings
|
||||||
|
#![allow(dead_code)]
|
||||||
|
//! Firmware flashing for LoRa mesh radios — Heltec V3/V4 in v1, across all
|
||||||
|
//! three firmware families the mesh module already knows how to detect (see
|
||||||
|
//! `mesh::types::DeviceType`). Firmware is always fetched from upstream at
|
||||||
|
//! flash time (never bundled/pinned in the repo), and every flash defaults
|
||||||
|
//! to a full chip erase before write.
|
||||||
|
//!
|
||||||
|
//! MeshCore and Meshtastic are flashed the same way: download a released
|
||||||
|
//! image, `esptool erase_flash`, then `esptool write_flash 0x0 <image>`.
|
||||||
|
//! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the
|
||||||
|
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
|
||||||
|
//! via `archy-rnodeconf --help` — there is no raw esptool path exposed for
|
||||||
|
//! this family, so we deliberately don't resolve a firmware URL ourselves
|
||||||
|
//! for Reticulum; rnodeconf already knows how).
|
||||||
|
|
||||||
|
use super::serial::DetectedDeviceInfo;
|
||||||
|
use super::types::DeviceType;
|
||||||
|
use super::MeshService;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// Boards supported for v1. Both are ESP32-S3 (a single `--chip esp32s3`
|
||||||
|
/// esptool target covers both), but ship different USB identities and
|
||||||
|
/// different per-board firmware assets upstream.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FlashBoard {
|
||||||
|
HeltecV3,
|
||||||
|
HeltecV4,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlashBoard {
|
||||||
|
/// Meshtastic's board id (matches the release manifest's `board` field
|
||||||
|
/// and its per-board asset naming, e.g. `firmware-heltec-v3-<ver>.factory.bin`).
|
||||||
|
fn meshtastic_id(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::HeltecV3 => "heltec-v3",
|
||||||
|
Self::HeltecV4 => "heltec-v4",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a detected USB vid:pid to a known flashable board, using the same
|
||||||
|
/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60)
|
||||||
|
/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to
|
||||||
|
/// auto-match since that vid:pid is bridge-chip-specific.
|
||||||
|
///
|
||||||
|
/// Heltec V4 is NOT auto-matchable and deliberately has no entry here: it
|
||||||
|
/// was confirmed live (real hardware, 2026-07-23) to use the ESP32-S3's
|
||||||
|
/// built-in native-USB JTAG/serial peripheral, reporting vid:pid 303a:1001
|
||||||
|
/// with product string "USB JTAG/serial debug unit" — that descriptor is
|
||||||
|
/// baked into the chip's ROM and is IDENTICAL across every ESP32-S3 board
|
||||||
|
/// with native USB enabled, not just Heltec V4. Adding `303a:1001 =>
|
||||||
|
/// HeltecV4` here would silently misidentify any other native-USB ESP32-S3
|
||||||
|
/// board (a T3-S3, a bare devkit, etc.) as a V4 and risk writing the wrong
|
||||||
|
/// board's image. Callers (the RPC layer / frontend) must let the user pick
|
||||||
|
/// the board manually whenever this returns `None`.
|
||||||
|
pub fn resolve_flash_board(info: &DetectedDeviceInfo) -> Option<FlashBoard> {
|
||||||
|
match (info.vid.as_deref(), info.pid.as_deref()) {
|
||||||
|
(Some("10c4"), Some("ea60")) => Some(FlashBoard::HeltecV3),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FlashStage {
|
||||||
|
Downloading,
|
||||||
|
Erasing,
|
||||||
|
Writing,
|
||||||
|
Autoinstalling,
|
||||||
|
Done,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FlashJobStatus {
|
||||||
|
pub board: FlashBoard,
|
||||||
|
pub family: DeviceType,
|
||||||
|
pub path: String,
|
||||||
|
pub stage: FlashStage,
|
||||||
|
pub percent: Option<u8>,
|
||||||
|
pub log_tail: Vec<String>,
|
||||||
|
pub done: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_TAIL_MAX: usize = 200;
|
||||||
|
|
||||||
|
/// How long to wait after a successful flash before resuming the mesh
|
||||||
|
/// listener, so the board finishes its own post-flash boot/reset before we
|
||||||
|
/// start opening the port (which itself toggles DTR/RTS) again.
|
||||||
|
const POST_FLASH_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Absolute ceiling on a whole flash job (download + erase + write, or
|
||||||
|
/// autoinstall), regardless of what it's doing internally. Last-resort
|
||||||
|
/// safety net so a hang anywhere can't wedge the single-flash-job guard
|
||||||
|
/// forever — generous enough to never trigger on a legitimately slow
|
||||||
|
/// multi-hundred-MB transfer.
|
||||||
|
const MAX_JOB_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60);
|
||||||
|
|
||||||
|
/// How long to wait for MeshService::stop() to release the serial port
|
||||||
|
/// before giving up. Confirmed live 2026-07-23: the listener's own
|
||||||
|
/// reconnect/multi-candidate-probe loop doesn't check its shutdown signal
|
||||||
|
/// between candidates, so stop() can take a while (or, if the loop is
|
||||||
|
/// wedged, never return) — 20s comfortably covers a normal handshake-probe
|
||||||
|
/// cycle without leaving a flash request hanging indefinitely if the
|
||||||
|
/// listener genuinely won't let go.
|
||||||
|
const STOP_LISTENER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||||
|
|
||||||
|
/// How long to keep retrying the port-free check before giving up.
|
||||||
|
const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Confirm nothing else has `path` open by actually opening (and immediately
|
||||||
|
/// closing) it ourselves. Retries across the timeout since a just-stopped
|
||||||
|
/// listener's fd can take a moment to actually release even after `stop()`
|
||||||
|
/// returns (task abort is a request, not an instant guarantee the OS-level
|
||||||
|
/// resource is gone yet).
|
||||||
|
async fn wait_for_port_free(path: &str) -> Result<()> {
|
||||||
|
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
|
||||||
|
let mut last_err = None;
|
||||||
|
loop {
|
||||||
|
match serial2_tokio::SerialPort::open(path, 115200) {
|
||||||
|
Ok(_) => return Ok(()),
|
||||||
|
Err(e) => last_err = Some(e),
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"{path} is still held open by something else after {}s (last error: {}) — refusing to start the flasher against a contended port",
|
||||||
|
PORT_FREE_TIMEOUT.as_secs(),
|
||||||
|
last_err.map(|e| e.to_string()).unwrap_or_default()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live state for the one flash job that can run at a time. A single global
|
||||||
|
/// slot is sufficient because flashing needs exclusive serial access to the
|
||||||
|
/// one port being flashed — there is no meaningful concept of two concurrent
|
||||||
|
/// flash jobs on this node.
|
||||||
|
pub struct FlashJob {
|
||||||
|
status: RwLock<FlashJobStatus>,
|
||||||
|
/// Set once the background task is spawned. Only used while `stage` is
|
||||||
|
/// still `Downloading` — an interrupted erase/write can leave the chip
|
||||||
|
/// in a worse state than either finished or unstarted, so cancellation
|
||||||
|
/// is refused once erase begins (see `cancel()`).
|
||||||
|
abort_handle: RwLock<Option<tokio::task::AbortHandle>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlashJob {
|
||||||
|
fn new(board: FlashBoard, family: DeviceType, path: String) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
abort_handle: RwLock::new(None),
|
||||||
|
status: RwLock::new(FlashJobStatus {
|
||||||
|
board,
|
||||||
|
family,
|
||||||
|
path,
|
||||||
|
stage: FlashStage::Downloading,
|
||||||
|
percent: None,
|
||||||
|
log_tail: Vec::new(),
|
||||||
|
done: false,
|
||||||
|
error: None,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn snapshot(&self) -> FlashJobStatus {
|
||||||
|
self.status.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_stage(&self, stage: FlashStage) {
|
||||||
|
let mut s = self.status.write().await;
|
||||||
|
s.stage = stage;
|
||||||
|
s.percent = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_percent(&self, percent: u8) {
|
||||||
|
self.status.write().await.percent = Some(percent.min(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn push_log(&self, line: impl Into<String>) {
|
||||||
|
let mut s = self.status.write().await;
|
||||||
|
s.log_tail.push(line.into());
|
||||||
|
let overflow = s.log_tail.len().saturating_sub(LOG_TAIL_MAX);
|
||||||
|
if overflow > 0 {
|
||||||
|
s.log_tail.drain(0..overflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fail(&self, err: &anyhow::Error) {
|
||||||
|
let mut s = self.status.write().await;
|
||||||
|
s.stage = FlashStage::Failed;
|
||||||
|
s.error = Some(format!("{err:#}"));
|
||||||
|
s.done = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn finish(&self) {
|
||||||
|
let mut s = self.status.write().await;
|
||||||
|
s.stage = FlashStage::Done;
|
||||||
|
s.done = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort cancel: only honored before erase/write/autoinstall has
|
||||||
|
/// started (i.e. still in `Downloading`). Once a stage that touches the
|
||||||
|
/// chip begins, this refuses — interrupting an erase or write can leave
|
||||||
|
/// the flash in a state worse than either finished or unstarted.
|
||||||
|
pub async fn cancel(&self) -> Result<()> {
|
||||||
|
let mut s = self.status.write().await;
|
||||||
|
if s.done {
|
||||||
|
anyhow::bail!("Flash job already finished");
|
||||||
|
}
|
||||||
|
if s.stage != FlashStage::Downloading {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Cannot cancel once {:?} has started — let it finish or fail on its own",
|
||||||
|
s.stage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(handle) = self.abort_handle.write().await.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
s.stage = FlashStage::Failed;
|
||||||
|
s.error = Some("Cancelled by user".to_string());
|
||||||
|
s.done = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared handle held by `RpcHandler`, sibling to `mesh_service`.
|
||||||
|
pub type FlashJobHandle = Arc<RwLock<Option<Arc<FlashJob>>>>;
|
||||||
|
|
||||||
|
pub fn new_job_handle() -> FlashJobHandle {
|
||||||
|
Arc::new(RwLock::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn firmware_cache_dir(data_dir: &Path) -> PathBuf {
|
||||||
|
data_dir.join("mesh").join("firmware-cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No blanket `.timeout()` here on purpose: reqwest's request timeout covers
|
||||||
|
/// the *entire* request including streaming the response body, which would
|
||||||
|
/// kill a legitimate large download partway through (Meshtastic's esp32s3
|
||||||
|
/// zip is ~170MB) — not just a hung connection. `download_to_file` instead
|
||||||
|
/// applies a per-chunk stall timeout, and metadata calls (small JSON
|
||||||
|
/// responses) get their own short timeout at the call site.
|
||||||
|
fn github_client() -> Result<reqwest::Client> {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.user_agent("archipelago-mesh-flash")
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.context("Failed to build HTTP client")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applied per-chunk while streaming a firmware download — if the transfer
|
||||||
|
/// stalls (no bytes for this long) it's treated as a failure, but a slow
|
||||||
|
/// download that's still making progress is never killed just for taking a
|
||||||
|
/// while.
|
||||||
|
const DOWNLOAD_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Applied to metadata calls (GitHub release JSON) — these are small
|
||||||
|
/// responses with no reason to ever take this long.
|
||||||
|
const METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||||
|
|
||||||
|
/// Resolve what firmware is available for a board+family. v1 only ever
|
||||||
|
/// offers "latest" — MeshCore/Meshtastic latest GitHub release, or, for
|
||||||
|
/// Reticulum, "latest" meaning "whatever archy-rnodeconf --autoinstall
|
||||||
|
/// resolves on its own" (it does its own version checking upstream).
|
||||||
|
pub async fn list_firmware(family: DeviceType) -> Result<Vec<String>> {
|
||||||
|
match family {
|
||||||
|
DeviceType::Reticulum => Ok(vec!["latest".to_string()]),
|
||||||
|
DeviceType::Meshtastic => {
|
||||||
|
let client = github_client()?;
|
||||||
|
let release: GithubRelease = client
|
||||||
|
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Fetching Meshtastic release list")?
|
||||||
|
.error_for_status()
|
||||||
|
.context("Meshtastic releases API error")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Parsing Meshtastic release JSON")?;
|
||||||
|
Ok(vec![release.tag_name])
|
||||||
|
}
|
||||||
|
DeviceType::Meshcore => {
|
||||||
|
let client = github_client()?;
|
||||||
|
let release: GithubRelease = client
|
||||||
|
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Fetching MeshCore release list")?
|
||||||
|
.error_for_status()
|
||||||
|
.context("MeshCore releases API error")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Parsing MeshCore release JSON")?;
|
||||||
|
Ok(vec![release.tag_name])
|
||||||
|
}
|
||||||
|
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before listing versions"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct GithubAsset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct GithubRelease {
|
||||||
|
tag_name: String,
|
||||||
|
assets: Vec<GithubAsset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a flash job in the background. Returns as soon as the job has been
|
||||||
|
/// registered and the listener released — callers poll `FlashJobHandle` via
|
||||||
|
/// `mesh.flash-status` for progress. Only one job may be in flight at a time.
|
||||||
|
pub async fn start_flash_job(
|
||||||
|
handle: &FlashJobHandle,
|
||||||
|
mesh_service: &Arc<RwLock<Option<MeshService>>>,
|
||||||
|
data_dir: PathBuf,
|
||||||
|
path: String,
|
||||||
|
board: FlashBoard,
|
||||||
|
family: DeviceType,
|
||||||
|
) -> Result<()> {
|
||||||
|
{
|
||||||
|
let existing = handle.read().await;
|
||||||
|
if let Some(job) = existing.as_ref() {
|
||||||
|
if !job.snapshot().await.done {
|
||||||
|
anyhow::bail!("A firmware flash is already in progress on this node");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let job = FlashJob::new(board, family, path.clone());
|
||||||
|
*handle.write().await = Some(Arc::clone(&job));
|
||||||
|
|
||||||
|
let bg_job = Arc::clone(&job);
|
||||||
|
let bg_service = Arc::clone(mesh_service);
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
// esptool/archy-rnodeconf need exclusive serial access — release
|
||||||
|
// the listener's hold on the port before touching it. This USED
|
||||||
|
// TO run synchronously in start_flash_job before the job was even
|
||||||
|
// spawned, blocking the RPC call itself on s.stop().await — a real
|
||||||
|
// 2026-07-23 incident: the mesh listener was mid a multi-candidate
|
||||||
|
// reconnect/probe sequence that doesn't check its shutdown signal
|
||||||
|
// between candidates, so stop() never returned. The HTTP request
|
||||||
|
// timed out client-side ("Operation failed"), while the job
|
||||||
|
// (already inserted into `handle`) was permanently wedged — nothing
|
||||||
|
// had been spawned yet to ever mark it done, so every later flash
|
||||||
|
// attempt failed with "already in progress" until a full restart.
|
||||||
|
// Now this runs inside the spawned task with its own bounded
|
||||||
|
// timeout, so the RPC call always returns immediately regardless,
|
||||||
|
// and a slow-to-stop listener fails the job cleanly instead of
|
||||||
|
// hanging everything downstream of it forever.
|
||||||
|
let stop_result = tokio::time::timeout(STOP_LISTENER_TIMEOUT, async {
|
||||||
|
let mut svc = bg_service.write().await;
|
||||||
|
if let Some(s) = svc.as_mut() {
|
||||||
|
s.stop().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if stop_result.is_err() {
|
||||||
|
let err = anyhow::anyhow!(
|
||||||
|
"Mesh listener did not release the serial port within {}s — it may still be mid a reconnect attempt. Try again once mesh.status shows the device idle, or restart the archipelago service if this persists.",
|
||||||
|
STOP_LISTENER_TIMEOUT.as_secs()
|
||||||
|
);
|
||||||
|
bg_job.push_log(format!("ERROR: {err:#}")).await;
|
||||||
|
bg_job.fail(&err).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belt-and-suspenders port-free check. `stop()` above should have
|
||||||
|
// fully released the port, but esptool/rnodeconf run as external
|
||||||
|
// subprocesses for minutes outside our own async runtime — if
|
||||||
|
// ANYTHING else still has it open (a racing probe, a not-yet-dropped
|
||||||
|
// fd from an aborted task, anything we haven't anticipated), handing
|
||||||
|
// the port to the flasher anyway risks exactly the corruption
|
||||||
|
// confirmed live 2026-07-23: esptool's "device disconnected or
|
||||||
|
// multiple access on port?" and rnodeconf's raw `OSError: [Errno 71]
|
||||||
|
// Protocol error` on an RTS ioctl are both textbook two-openers-on-
|
||||||
|
// one-fd symptoms. Verify by actually opening it ourselves — cheap,
|
||||||
|
// and definitive — before ever starting the flasher.
|
||||||
|
if let Err(e) = wait_for_port_free(&path).await {
|
||||||
|
bg_job.push_log(format!("ERROR: {e:#}")).await;
|
||||||
|
bg_job.fail(&e).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outer ceiling on top of run_flash's own internal timeouts —
|
||||||
|
// belt-and-suspenders so that no future hang (network, subprocess,
|
||||||
|
// anything) can ever wedge the single-flash-job guard permanently
|
||||||
|
// again the way a stuck download did on 2026-07-23 (every
|
||||||
|
// subsequent mesh.flash-device call failed with "already in
|
||||||
|
// progress" until the service was restarted). Generous enough that
|
||||||
|
// a legitimately slow multi-hundred-MB transfer still completes.
|
||||||
|
let result = match tokio::time::timeout(
|
||||||
|
MAX_JOB_DURATION,
|
||||||
|
run_flash(board, family, &data_dir, &path, &bg_job),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(inner) => inner,
|
||||||
|
Err(_) => Err(anyhow::anyhow!(
|
||||||
|
"Flash job exceeded the {}-minute ceiling — aborted",
|
||||||
|
MAX_JOB_DURATION.as_secs() / 60
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
let succeeded = result.is_ok();
|
||||||
|
|
||||||
|
match &result {
|
||||||
|
Ok(()) => {
|
||||||
|
bg_job
|
||||||
|
.push_log("Flash completed successfully".to_string())
|
||||||
|
.await;
|
||||||
|
bg_job.finish().await;
|
||||||
|
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// {:#} (alternate Display) walks the full anyhow context
|
||||||
|
// chain — plain {} / %e only prints the outermost .context()
|
||||||
|
// message, which made a real 2026-07-23 esptool failure
|
||||||
|
// undiagnosable from journalctl alone (just "esptool
|
||||||
|
// erase_flash failed", no actual esptool stderr).
|
||||||
|
warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed");
|
||||||
|
bg_job.push_log(format!("ERROR: {e:#}")).await;
|
||||||
|
bg_job.fail(e).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The board's firmware may now differ from whatever was pinned
|
||||||
|
// before — clear the pin either way so a later reconnect's strict
|
||||||
|
// auto-detect order picks up reality instead of getting wedged
|
||||||
|
// trying the old protocol first.
|
||||||
|
if let Ok(mut config) = super::load_config(&data_dir).await {
|
||||||
|
config.device_kind = None;
|
||||||
|
if let Err(e) = super::save_config(&data_dir, &config).await {
|
||||||
|
warn!(error = %e, "Failed to clear device_kind pin after flash");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !succeeded {
|
||||||
|
// Deliberately do NOT auto-restart the listener here. A failed
|
||||||
|
// flash means we can't vouch for the board's state — reopening
|
||||||
|
// the port immediately (esptool/rnodeconf's own reset sequence
|
||||||
|
// plus our open() toggling DTR/RTS again right after) risks
|
||||||
|
// hammering a marginal device with reconnect attempts. Confirmed
|
||||||
|
// live 2026-07-23: exactly this sequence left a real Heltec V3
|
||||||
|
// boot-looping for 5+ minutes after a failed flash. Leave mesh
|
||||||
|
// stopped; the user reconnects explicitly via the hot-swap
|
||||||
|
// modal/Mesh page once they've confirmed the board is alive.
|
||||||
|
warn!(
|
||||||
|
path = %path,
|
||||||
|
"Leaving mesh listener stopped after failed flash — reconnect manually once the board is confirmed responsive"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On success, give the board a moment to finish booting after the
|
||||||
|
// flash tool's own reset sequence before we start hammering it with
|
||||||
|
// connection attempts — same reasoning as above, just the
|
||||||
|
// lower-risk (successful-flash) side of it.
|
||||||
|
tokio::time::sleep(POST_FLASH_SETTLE_DELAY).await;
|
||||||
|
|
||||||
|
let mut svc = bg_service.write().await;
|
||||||
|
if let Some(s) = svc.as_mut() {
|
||||||
|
match super::load_config(&data_dir).await {
|
||||||
|
Ok(config) => {
|
||||||
|
// Only resume if mesh is actually still enabled per the
|
||||||
|
// CURRENT persisted config — confirmed live 2026-07-23:
|
||||||
|
// unconditionally forcing a restart here, regardless of
|
||||||
|
// `enabled`, overrode a user's own concurrent "disable
|
||||||
|
// mesh" toggle and left the listener running while
|
||||||
|
// config said disabled. That inconsistent state is what
|
||||||
|
// made a later legitimate "Keep As Is" click (which
|
||||||
|
// correctly tries to start on a false→true transition)
|
||||||
|
// fail with "already running" — the listener had already
|
||||||
|
// been force-started behind the config's back.
|
||||||
|
let should_run = config.enabled;
|
||||||
|
if let Err(e) = s.configure(config).await {
|
||||||
|
warn!(error = %e, "Failed to resume mesh listener after flash");
|
||||||
|
}
|
||||||
|
if should_run {
|
||||||
|
if let Err(e) = s.start() {
|
||||||
|
warn!(error = %e, "Failed to restart mesh listener after flash");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!(error = %e, "Failed to load mesh config after flash"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*job.abort_handle.write().await = Some(task.abort_handle());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_flash(
|
||||||
|
board: FlashBoard,
|
||||||
|
family: DeviceType,
|
||||||
|
data_dir: &Path,
|
||||||
|
path: &str,
|
||||||
|
job: &Arc<FlashJob>,
|
||||||
|
) -> Result<()> {
|
||||||
|
match family {
|
||||||
|
DeviceType::Meshtastic | DeviceType::Meshcore => {
|
||||||
|
let image = fetch_esptool_image(board, family, data_dir, job).await?;
|
||||||
|
esptool_erase_and_write(path, &image, job).await
|
||||||
|
}
|
||||||
|
DeviceType::Reticulum => {
|
||||||
|
let lora_region = super::load_config(data_dir)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|c| c.lora_region);
|
||||||
|
rnodeconf_autoinstall(path, board, lora_region.as_deref(), job).await
|
||||||
|
}
|
||||||
|
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before flashing"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MeshCore / Meshtastic: esptool ─────────────────────────────────────
|
||||||
|
|
||||||
|
async fn fetch_esptool_image(
|
||||||
|
board: FlashBoard,
|
||||||
|
family: DeviceType,
|
||||||
|
data_dir: &Path,
|
||||||
|
job: &Arc<FlashJob>,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let cache = firmware_cache_dir(data_dir);
|
||||||
|
tokio::fs::create_dir_all(&cache)
|
||||||
|
.await
|
||||||
|
.context("Creating firmware cache dir")?;
|
||||||
|
let client = github_client()?;
|
||||||
|
|
||||||
|
match family {
|
||||||
|
DeviceType::Meshtastic => fetch_meshtastic_image(&client, board, &cache, job).await,
|
||||||
|
DeviceType::Meshcore => fetch_meshcore_image(&client, board, &cache, job).await,
|
||||||
|
_ => anyhow::bail!("{family} is not flashed via esptool"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_meshtastic_image(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
board: FlashBoard,
|
||||||
|
cache: &Path,
|
||||||
|
job: &Arc<FlashJob>,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let release: GithubRelease = client
|
||||||
|
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
|
||||||
|
.timeout(METADATA_TIMEOUT)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Fetching Meshtastic release list")?
|
||||||
|
.error_for_status()
|
||||||
|
.context("Meshtastic releases API error")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Parsing Meshtastic release JSON")?;
|
||||||
|
|
||||||
|
// Meshtastic bundles all esp32s3 boards' images inside one per-platform
|
||||||
|
// zip rather than shipping per-board top-level assets — both Heltec V3
|
||||||
|
// and V4 are esp32s3, so this is the right zip for both (confirmed live
|
||||||
|
// against v2.7.26.54e0d8d).
|
||||||
|
let zip_asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name.starts_with("firmware-esp32s3-") && a.name.ends_with(".zip"))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No esp32s3 firmware zip in latest Meshtastic release"))?;
|
||||||
|
|
||||||
|
let version = zip_asset
|
||||||
|
.name
|
||||||
|
.strip_prefix("firmware-esp32s3-")
|
||||||
|
.and_then(|s| s.strip_suffix(".zip"))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Unexpected Meshtastic asset name: {}", zip_asset.name))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let zip_path = cache.join(&zip_asset.name);
|
||||||
|
if tokio::fs::metadata(&zip_path).await.is_err() {
|
||||||
|
download_to_file(client, &zip_asset.browser_download_url, &zip_path, job).await?;
|
||||||
|
} else {
|
||||||
|
job.push_log(format!("Using cached {}", zip_asset.name))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "*.factory.bin" is Meshtastic's full merged image (bootloader +
|
||||||
|
// partition table + app) meant to be written at offset 0x0 on a freshly
|
||||||
|
// erased chip — confirmed by inspecting the real zip's contents, as
|
||||||
|
// opposed to the plain "*.bin" OTA-update image which assumes an
|
||||||
|
// existing bootloader/partition table already on the chip.
|
||||||
|
let entry_name = format!("firmware-{}-{}.factory.bin", board.meshtastic_id(), version);
|
||||||
|
let out_path = cache.join(&entry_name);
|
||||||
|
if tokio::fs::metadata(&out_path).await.is_ok() {
|
||||||
|
return Ok(out_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
job.push_log(format!("Extracting {entry_name} from {}", zip_asset.name))
|
||||||
|
.await;
|
||||||
|
let zip_path_owned = zip_path.clone();
|
||||||
|
let entry_name_owned = entry_name.clone();
|
||||||
|
let out_path_owned = out_path.clone();
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
|
let file =
|
||||||
|
std::fs::File::open(&zip_path_owned).context("Opening downloaded firmware zip")?;
|
||||||
|
let mut archive = zip::ZipArchive::new(file).context("Reading firmware zip")?;
|
||||||
|
let mut entry = archive
|
||||||
|
.by_name(&entry_name_owned)
|
||||||
|
.with_context(|| format!("{entry_name_owned} not found in firmware zip"))?;
|
||||||
|
let mut out =
|
||||||
|
std::fs::File::create(&out_path_owned).context("Creating extracted firmware file")?;
|
||||||
|
std::io::copy(&mut entry, &mut out).context("Extracting firmware image")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("Firmware extraction task panicked")??;
|
||||||
|
|
||||||
|
Ok(out_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_meshcore_image(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
board: FlashBoard,
|
||||||
|
cache: &Path,
|
||||||
|
job: &Arc<FlashJob>,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let release: GithubRelease = client
|
||||||
|
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
|
||||||
|
.timeout(METADATA_TIMEOUT)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Fetching MeshCore release list")?
|
||||||
|
.error_for_status()
|
||||||
|
.context("MeshCore releases API error")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Parsing MeshCore release JSON")?;
|
||||||
|
|
||||||
|
// Upstream's casing differs between boards (Heltec_v3_... vs
|
||||||
|
// heltec_v4_...) — match case-insensitively on the exact per-board
|
||||||
|
// substring so V4 isn't accidentally matched by "heltec_v4_tft_..."
|
||||||
|
// variants (there's a "_tft_" in between, so a straight substring match
|
||||||
|
// on "heltec_v4_companion_radio_usb" is already safe).
|
||||||
|
let needle = match board {
|
||||||
|
FlashBoard::HeltecV3 => "heltec_v3_companion_radio_usb",
|
||||||
|
FlashBoard::HeltecV4 => "heltec_v4_companion_radio_usb",
|
||||||
|
};
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| {
|
||||||
|
let lower = a.name.to_lowercase();
|
||||||
|
lower.contains(needle) && lower.ends_with("-merged.bin")
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("No matching MeshCore image in release {}", release.tag_name)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let out_path = cache.join(&asset.name);
|
||||||
|
if tokio::fs::metadata(&out_path).await.is_ok() {
|
||||||
|
job.push_log(format!("Using cached {}", asset.name)).await;
|
||||||
|
return Ok(out_path);
|
||||||
|
}
|
||||||
|
download_to_file(client, &asset.browser_download_url, &out_path, job).await?;
|
||||||
|
Ok(out_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_to_file(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
dest: &Path,
|
||||||
|
job: &Arc<FlashJob>,
|
||||||
|
) -> Result<()> {
|
||||||
|
job.set_stage(FlashStage::Downloading).await;
|
||||||
|
// Bound only the wait for the response to *start* (headers) — NOT a
|
||||||
|
// request-level `.timeout()`, which would cap the whole body transfer
|
||||||
|
// again (the bug this replaced: a blanket 30s client timeout killed
|
||||||
|
// large downloads mid-stream). If the server never responds at all,
|
||||||
|
// this is what stops the job from hanging forever; the per-chunk stall
|
||||||
|
// timeout below is what guards the body once streaming starts. Without
|
||||||
|
// this, a server that accepts the TCP connection but never sends
|
||||||
|
// headers back hangs this call indefinitely — confirmed live
|
||||||
|
// 2026-07-23: a stuck `.send()` here wedged the single-flash-job guard
|
||||||
|
// for good, permanently blocking every subsequent flash attempt with
|
||||||
|
// "already in progress" until the service was restarted.
|
||||||
|
let resp = tokio::time::timeout(METADATA_TIMEOUT, client.get(url).send())
|
||||||
|
.await
|
||||||
|
.context("Firmware download server did not respond")?
|
||||||
|
.context("Starting firmware download")?
|
||||||
|
.error_for_status()
|
||||||
|
.context("Firmware download returned an error status")?;
|
||||||
|
let total = resp.content_length();
|
||||||
|
let tmp = dest.with_extension("part");
|
||||||
|
let mut file = tokio::fs::File::create(&tmp)
|
||||||
|
.await
|
||||||
|
.context("Creating firmware download file")?;
|
||||||
|
let mut stream = resp.bytes_stream();
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
loop {
|
||||||
|
let next = tokio::time::timeout(DOWNLOAD_STALL_TIMEOUT, stream.next())
|
||||||
|
.await
|
||||||
|
.context("Firmware download stalled")?;
|
||||||
|
let Some(chunk) = next else { break };
|
||||||
|
let chunk = chunk.context("Reading firmware download stream")?;
|
||||||
|
file.write_all(&chunk)
|
||||||
|
.await
|
||||||
|
.context("Writing firmware download")?;
|
||||||
|
downloaded += chunk.len() as u64;
|
||||||
|
if let Some(total) = total {
|
||||||
|
if total > 0 {
|
||||||
|
job.set_percent(((downloaded.saturating_mul(100)) / total) as u8)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.flush().await.ok();
|
||||||
|
tokio::fs::rename(&tmp, dest)
|
||||||
|
.await
|
||||||
|
.context("Finalizing firmware download")?;
|
||||||
|
job.push_log(format!(
|
||||||
|
"Downloaded {} ({downloaded} bytes)",
|
||||||
|
dest.display()
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both Heltec V3 and V4 are ESP32-S3 boards.
|
||||||
|
const ESPTOOL_CHIP: &str = "esp32s3";
|
||||||
|
|
||||||
|
/// esptool's auto-reset-into-bootloader handshake (toggling DTR/RTS in a
|
||||||
|
/// specific timed pattern) is well-known to be flaky on some CP2102/CH340
|
||||||
|
/// board+adapter combinations — esptool's own docs recommend retrying at a
|
||||||
|
/// lower baud rate when this happens. Rather than fail the whole job on the
|
||||||
|
/// first hiccup, retry once at a conservative baud before giving up.
|
||||||
|
const ESPTOOL_FALLBACK_BAUD: &str = "115200";
|
||||||
|
|
||||||
|
/// `write_flash --erase-all` erases the whole chip before writing, in one
|
||||||
|
/// esptool invocation. This needs the esp32s3 stub flasher loaded (see
|
||||||
|
/// esptool_global_args' doc comment) — without it, --erase-all hits the
|
||||||
|
/// exact same ROM limitation a standalone `erase_flash` does ("ESP32-S3 ROM
|
||||||
|
/// does not support function erase_flash", confirmed live 2026-07-23), since
|
||||||
|
/// esptool's --erase-all is implemented as the same full-chip-erase command,
|
||||||
|
/// not a per-sector loop.
|
||||||
|
async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>) -> Result<()> {
|
||||||
|
job.set_stage(FlashStage::Writing).await;
|
||||||
|
let image_str = image.to_string_lossy().to_string();
|
||||||
|
esptool_with_retry(
|
||||||
|
path,
|
||||||
|
&["write_flash", "--erase-all", "0x0", &image_str],
|
||||||
|
job,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("esptool write_flash failed")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// esptool's global flags (--chip/--port/--baud) MUST precede the subcommand
|
||||||
|
/// token (erase_flash/write_flash/...) — confirmed live 2026-07-23:
|
||||||
|
/// appending `--baud 115200` after the subcommand on the retry path
|
||||||
|
/// produced "esptool: error: unrecognized arguments: --baud 115200" every
|
||||||
|
/// time, so the fallback-baud retry never actually got a chance to run.
|
||||||
|
/// Building global args separately from subcommand args keeps this correct
|
||||||
|
/// by construction instead of relying on call-site ordering.
|
||||||
|
///
|
||||||
|
/// Normal stub-loader mode (no --no-stub) needs the esp32s3 stub flasher
|
||||||
|
/// blob at /usr/lib/python3/dist-packages/esptool/targets/stub_flasher/
|
||||||
|
/// stub_flasher_32s3.json — Debian's `esptool` package (4.7.0+dfsg-0.1)
|
||||||
|
/// ships without it (stripped for DFSG compliance: the prebuilt blob has no
|
||||||
|
/// buildable-from-source path Debian could verify), so scripts/self-update.sh
|
||||||
|
/// fetches the exact same file from the matching upstream esptool release
|
||||||
|
/// tag and installs it alongside the apt package (see the esptool install
|
||||||
|
/// step there). --no-stub (talk directly to the ROM bootloader, skip the
|
||||||
|
/// stub) was tried first and works for connecting, but the ROM bootloader
|
||||||
|
/// doesn't implement a full-chip-erase opcode at all — only the stub does —
|
||||||
|
/// so --no-stub broke our "always erase before write" default outright
|
||||||
|
/// rather than just being slower. Restoring the real stub file is the
|
||||||
|
/// correct fix, not routing around its absence.
|
||||||
|
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> {
|
||||||
|
let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path];
|
||||||
|
if let Some(b) = baud {
|
||||||
|
args.push("--baud");
|
||||||
|
args.push(b);
|
||||||
|
}
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc<FlashJob>) -> Result<()> {
|
||||||
|
let mut cmd = Command::new("esptool");
|
||||||
|
cmd.args(esptool_global_args(path, None));
|
||||||
|
cmd.args(subcommand);
|
||||||
|
match run_streamed(cmd, None, job).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(first_err) => {
|
||||||
|
job.push_log(format!(
|
||||||
|
"First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
let mut retry = Command::new("esptool");
|
||||||
|
retry.args(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD)));
|
||||||
|
retry.args(subcommand);
|
||||||
|
run_streamed(retry, None, job)
|
||||||
|
.await
|
||||||
|
.context(format!("retry also failed (first attempt: {first_err:#})"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reticulum/RNode: archy-rnodeconf ───────────────────────────────────
|
||||||
|
|
||||||
|
fn rnodeconf_bin() -> String {
|
||||||
|
std::env::var("ARCHY_RNODECONF_BIN")
|
||||||
|
.unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--autoinstall`'s "which board is this" step is interactive by design —
|
||||||
|
/// confirmed live against a real Heltec V4 (2026-07-23): even with a board
|
||||||
|
/// given on the command line, rnodeconf can't always tell V3 from V4 apart
|
||||||
|
/// (their bootstrap-time USB identity is often generic, same root cause as
|
||||||
|
/// `resolve_flash_board`'s doc comment), so it always asks. The full prompt
|
||||||
|
/// sequence observed for a Heltec board that already has *some* RNode
|
||||||
|
/// firmware installed (the common case — a truly blank chip likely skips
|
||||||
|
/// straight to the same "Device Selection" menu):
|
||||||
|
/// 1. numbered device-type menu → answer with the menu number
|
||||||
|
/// 2. "Hit enter to continue" → answer with a blank line
|
||||||
|
/// 3. numbered band menu → answer with the menu number
|
||||||
|
/// 4. "Is the above correct? [y/N]" → answer "y"
|
||||||
|
/// Feeding all four answers up front (rather than watching stdout for each
|
||||||
|
/// prompt text) works because the menu is always asked in this fixed order
|
||||||
|
/// for every board that needs (re)provisioning — verified by driving it
|
||||||
|
/// through an unprovisioned real V4 end-to-end (erase → flash → EEPROM
|
||||||
|
/// bootstrap → "Device signature validated" on the next probe).
|
||||||
|
fn rnodeconf_device_menu_number(board: FlashBoard) -> &'static str {
|
||||||
|
match board {
|
||||||
|
FlashBoard::HeltecV3 => "8",
|
||||||
|
FlashBoard::HeltecV4 => "9",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rnodeconf's band choice is a coarse RF-frontend bootstrap parameter
|
||||||
|
/// (868/915/923 MHz), not the final operating frequency — that's still
|
||||||
|
/// configured later via the daemon's interface config, same as today. This
|
||||||
|
/// is a best-effort mapping from the node's persisted Meshtastic-style
|
||||||
|
/// region code (see `mesh::meshtastic::region_name_to_code`) down to
|
||||||
|
/// rnodeconf's 3-way menu; regions with no exact 868/923 match fall back to
|
||||||
|
/// 915 MHz as the broadest-compatibility default.
|
||||||
|
fn rnodeconf_band_menu_number(lora_region: Option<&str>) -> &'static str {
|
||||||
|
match lora_region.map(|s| s.trim().to_uppercase()) {
|
||||||
|
Some(r) if r.contains("868") => "1",
|
||||||
|
Some(r) if r.contains("923") => "3",
|
||||||
|
_ => "2",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--autoinstall` fetches, erases, flashes, and bootstraps the EEPROM for
|
||||||
|
/// a detected board as one atomic step (confirmed via `archy-rnodeconf
|
||||||
|
/// --help` AND a real end-to-end flash on real hardware) — this is the
|
||||||
|
/// RNode-side equivalent of our "always erase before write" default, since
|
||||||
|
/// autoinstall doesn't try to preserve any existing on-device state.
|
||||||
|
async fn rnodeconf_autoinstall(
|
||||||
|
path: &str,
|
||||||
|
board: FlashBoard,
|
||||||
|
lora_region: Option<&str>,
|
||||||
|
job: &Arc<FlashJob>,
|
||||||
|
) -> Result<()> {
|
||||||
|
job.set_stage(FlashStage::Autoinstalling).await;
|
||||||
|
let bin = rnodeconf_bin();
|
||||||
|
let mut cmd = if Path::new(&bin).exists() {
|
||||||
|
Command::new(bin)
|
||||||
|
} else {
|
||||||
|
// Dev fallback if only a plain venv/system rnodeconf is on PATH.
|
||||||
|
Command::new("rnodeconf")
|
||||||
|
};
|
||||||
|
cmd.args(["--autoinstall", path]);
|
||||||
|
let stdin = format!(
|
||||||
|
"{}\n\n{}\ny\n",
|
||||||
|
rnodeconf_device_menu_number(board),
|
||||||
|
rnodeconf_band_menu_number(lora_region)
|
||||||
|
);
|
||||||
|
run_streamed(cmd, Some(stdin.into_bytes()), job)
|
||||||
|
.await
|
||||||
|
.context("archy-rnodeconf --autoinstall failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Subprocess streaming ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn percent_regex() -> &'static Regex {
|
||||||
|
static RE: OnceLock<Regex> = OnceLock::new();
|
||||||
|
RE.get_or_init(|| Regex::new(r"\((\d{1,3})\s*%\)").expect("valid regex"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_streamed(mut cmd: Command, stdin: Option<Vec<u8>>, job: &Arc<FlashJob>) -> Result<()> {
|
||||||
|
cmd.stdout(Stdio::piped());
|
||||||
|
cmd.stderr(Stdio::piped());
|
||||||
|
if stdin.is_some() {
|
||||||
|
cmd.stdin(Stdio::piped());
|
||||||
|
}
|
||||||
|
// Deliberately NOT kill_on_drop: an interrupted erase/write can leave
|
||||||
|
// the chip in a worse state than either finished or unstarted (see the
|
||||||
|
// cancellation-safety note in mesh flashing docs). The job is expected
|
||||||
|
// to run to completion or fail on its own.
|
||||||
|
let mut child = cmd.spawn().context("Failed to start subprocess")?;
|
||||||
|
|
||||||
|
if let Some(bytes) = stdin {
|
||||||
|
if let Some(mut child_stdin) = child.stdin.take() {
|
||||||
|
child_stdin
|
||||||
|
.write_all(&bytes)
|
||||||
|
.await
|
||||||
|
.context("Writing to subprocess stdin")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
if let Some(stdout) = child.stdout.take() {
|
||||||
|
let job = Arc::clone(job);
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(stdout).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
if let Some(cap) = percent_regex().captures(&line) {
|
||||||
|
if let Ok(pct) = cap[1].parse::<u8>() {
|
||||||
|
job.set_percent(pct).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
job.push_log(line).await;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(stderr) = child.stderr.take() {
|
||||||
|
let job = Arc::clone(job);
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(stderr).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
job.push_log(line).await;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child.wait().await.context("Waiting for subprocess")?;
|
||||||
|
for t in tasks {
|
||||||
|
let _ = t.await;
|
||||||
|
}
|
||||||
|
if !status.success() {
|
||||||
|
// Exit status alone isn't diagnosable — the actual esptool/rnodeconf
|
||||||
|
// stderr (already captured into job.log_tail by the reader tasks
|
||||||
|
// above) is what actually explains a failure. Confirmed live
|
||||||
|
// 2026-07-23: a bare "Command exited with exit status: 1" told us
|
||||||
|
// nothing when esptool's real error was sitting in the log tail the
|
||||||
|
// whole time, only visible via the UI's live poll, not journald.
|
||||||
|
let tail: Vec<String> = job
|
||||||
|
.snapshot()
|
||||||
|
.await
|
||||||
|
.log_tail
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(10)
|
||||||
|
.rev()
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
anyhow::bail!("Command exited with {status}\n{}", tail.join("\n"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -578,7 +578,7 @@ pub(super) async fn handle_identity_received(
|
|||||||
.insert(contact_id, shared_secret);
|
.insert(contact_id, shared_secret);
|
||||||
|
|
||||||
// Update peer record
|
// Update peer record
|
||||||
let peer = MeshPeer {
|
let mut peer = MeshPeer {
|
||||||
contact_id,
|
contact_id,
|
||||||
// .get(): a malformed DID shorter than the "did:key:" prefix must
|
// .get(): a malformed DID shorter than the "did:key:" prefix must
|
||||||
// not panic the listener on a radio-supplied string.
|
// not panic the listener on a radio-supplied string.
|
||||||
@ -607,6 +607,24 @@ pub(super) async fn handle_identity_received(
|
|||||||
let is_new = {
|
let is_new = {
|
||||||
let mut peers = state.peers.write().await;
|
let mut peers = state.peers.write().await;
|
||||||
let is_new = !peers.contains_key(&contact_id);
|
let is_new = !peers.contains_key(&contact_id);
|
||||||
|
if let Some(existing) = peers.get(&contact_id) {
|
||||||
|
// This id is shared with the federation-seeded row for the same
|
||||||
|
// node (that's the point — identity adverts MERGE, not duplicate).
|
||||||
|
// The wholesale insert below must not stomp the federation row's
|
||||||
|
// real node name with our synthetic "Archy-…" placeholder — with
|
||||||
|
// Reticulum re-emitting identity adverts every announce tick,
|
||||||
|
// that renamed every federated contact once a minute. Same for a
|
||||||
|
// known position: keep it rather than nulling it out.
|
||||||
|
if !existing.advert_name.trim().is_empty()
|
||||||
|
&& !existing.advert_name.starts_with("Archy-")
|
||||||
|
{
|
||||||
|
peer.advert_name = existing.advert_name.clone();
|
||||||
|
}
|
||||||
|
if peer.lat.is_none() {
|
||||||
|
peer.lat = existing.lat;
|
||||||
|
peer.lon = existing.lon;
|
||||||
|
}
|
||||||
|
}
|
||||||
peers.insert(contact_id, peer.clone());
|
peers.insert(contact_id, peer.clone());
|
||||||
is_new
|
is_new
|
||||||
};
|
};
|
||||||
|
|||||||
@ -87,6 +87,18 @@ const RECONNECT_DELAY_INIT: Duration = Duration::from_secs(5);
|
|||||||
/// Maximum reconnect delay (cap for exponential backoff).
|
/// Maximum reconnect delay (cap for exponential backoff).
|
||||||
const RECONNECT_DELAY_MAX: Duration = Duration::from_secs(60);
|
const RECONNECT_DELAY_MAX: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// Minimum time a session must run before we trust it enough to reset
|
||||||
|
/// backoff to the minimum. Without this gate, a device that connects then
|
||||||
|
/// fails again within a couple of seconds (e.g. mid-boot-loop) never backs
|
||||||
|
/// off — every retry immediately re-opens the port, which toggles DTR/RTS
|
||||||
|
/// (resets many ESP32 boards' MCU on native-USB and CP2102/CH340
|
||||||
|
/// auto-reset-circuit boards alike), turning a device that's merely
|
||||||
|
/// unstable into a self-sustaining boot loop that outlasts whatever
|
||||||
|
/// triggered the original instability. Confirmed live 2026-07-23: a Heltec
|
||||||
|
/// V3 stuck retrying every ~5-15s for 5+ minutes after a failed firmware
|
||||||
|
/// flash left it in a marginal state.
|
||||||
|
const STABLE_SESSION_THRESHOLD: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
/// Number of consecutive write failures before we consider the device dead
|
/// Number of consecutive write failures before we consider the device dead
|
||||||
/// and trigger a reconnection cycle.
|
/// and trigger a reconnection cycle.
|
||||||
const MAX_CONSECUTIVE_WRITE_FAILURES: u32 = 3;
|
const MAX_CONSECUTIVE_WRITE_FAILURES: u32 = 3;
|
||||||
@ -545,6 +557,10 @@ pub fn spawn_mesh_listener(
|
|||||||
let mut shutdown = shutdown;
|
let mut shutdown = shutdown;
|
||||||
let mut cmd_rx = cmd_rx;
|
let mut cmd_rx = cmd_rx;
|
||||||
let mut reconnect_delay = RECONNECT_DELAY_INIT;
|
let mut reconnect_delay = RECONNECT_DELAY_INIT;
|
||||||
|
// Mutable so a successful auto-detect can pin the firmware kind for
|
||||||
|
// the rest of this listener's lifetime — see the pin-on-first-success
|
||||||
|
// block below for why.
|
||||||
|
let mut device_kind = device_kind;
|
||||||
// Backlog #12 hot-swap re-binding: each run_mesh_session call already
|
// Backlog #12 hot-swap re-binding: each run_mesh_session call already
|
||||||
// builds a fresh device struct (contacts/current_region/etc. all
|
// builds a fresh device struct (contacts/current_region/etc. all
|
||||||
// start empty), so per-device session state is naturally isolated
|
// start empty), so per-device session state is naturally isolated
|
||||||
@ -560,6 +576,7 @@ pub fn spawn_mesh_listener(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let session_start = std::time::Instant::now();
|
||||||
match session::run_mesh_session(
|
match session::run_mesh_session(
|
||||||
&state,
|
&state,
|
||||||
&data_dir,
|
&data_dir,
|
||||||
@ -582,13 +599,14 @@ pub fn spawn_mesh_listener(
|
|||||||
{
|
{
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
info!("Mesh session ended cleanly");
|
info!("Mesh session ended cleanly");
|
||||||
// Session was established before ending — reset backoff
|
// Only trust a session that actually ran for a while —
|
||||||
reconnect_delay = RECONNECT_DELAY_INIT;
|
// see STABLE_SESSION_THRESHOLD's doc comment.
|
||||||
|
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
|
||||||
|
reconnect_delay = RECONNECT_DELAY_INIT;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Check if session was ever connected (vs failed to open)
|
if session_start.elapsed() >= STABLE_SESSION_THRESHOLD {
|
||||||
let was_connected = state.status.read().await.device_connected;
|
|
||||||
if was_connected {
|
|
||||||
reconnect_delay = RECONNECT_DELAY_INIT;
|
reconnect_delay = RECONNECT_DELAY_INIT;
|
||||||
}
|
}
|
||||||
error!("Mesh session error: {} (retry in {:?})", e, reconnect_delay);
|
error!("Mesh session error: {} (retry in {:?})", e, reconnect_delay);
|
||||||
@ -614,6 +632,45 @@ pub fn spawn_mesh_listener(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin the firmware kind after the first successful auto-detect.
|
||||||
|
// Confirmed live 2026-07-23: with device_kind left unpinned (e.g.
|
||||||
|
// after clearing a stale pin), EVERY reconnect re-runs the full
|
||||||
|
// Reticulum→Meshcore→Meshtastic auto-detect cascade — each
|
||||||
|
// candidate past the first does its own open() with the DTR/RTS
|
||||||
|
// reset both boards need, so a device correctly identified as
|
||||||
|
// Meshtastic still gets reset once for the failed Meshcore
|
||||||
|
// attempt before Meshtastic's own open() resets it again. That
|
||||||
|
// doubled the reset count on every single reconnect indefinitely,
|
||||||
|
// not just during initial detection. Once auto-detect has
|
||||||
|
// identified the device this listener is actually talking to,
|
||||||
|
// there's no reason to keep guessing on subsequent reconnects —
|
||||||
|
// pin it, both in this task's own loop (takes effect
|
||||||
|
// immediately) and on disk (survives a service restart). A
|
||||||
|
// genuine hot-swap to different firmware is still handled: the
|
||||||
|
// setup modal's `mesh.probe-device` always re-probes unpinned,
|
||||||
|
// and the flash flow already clears this pin on its own.
|
||||||
|
if device_kind.is_none() {
|
||||||
|
let detected = state.status.read().await.device_type;
|
||||||
|
if detected != super::types::DeviceType::Unknown {
|
||||||
|
device_kind = Some(detected);
|
||||||
|
match super::load_config(&data_dir).await {
|
||||||
|
Ok(mut cfg) if cfg.device_kind.is_none() => {
|
||||||
|
cfg.device_kind = Some(detected);
|
||||||
|
if let Err(e) = super::save_config(&data_dir, &cfg).await {
|
||||||
|
warn!("Failed to persist auto-detected device_kind: {}", e);
|
||||||
|
} else {
|
||||||
|
info!(
|
||||||
|
kind = %detected,
|
||||||
|
"Pinned auto-detected firmware kind to avoid repeated multi-protocol resets on reconnect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => warn!("Failed to load mesh config to persist device_kind: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update status to disconnected. device_type/firmware_version are
|
// Update status to disconnected. device_type/firmware_version are
|
||||||
// reset too — they were previously left holding the LAST radio's
|
// reset too — they were previously left holding the LAST radio's
|
||||||
// identity, so after a hot-swap the UI showed the old firmware
|
// identity, so after a hot-swap the UI showed the old firmware
|
||||||
|
|||||||
@ -269,11 +269,26 @@ async fn auto_detect_and_open(
|
|||||||
our_ed_pubkey_hex: &str,
|
our_ed_pubkey_hex: &str,
|
||||||
our_x25519_pubkey_hex: &str,
|
our_x25519_pubkey_hex: &str,
|
||||||
device_kind: Option<DeviceType>,
|
device_kind: Option<DeviceType>,
|
||||||
|
skip_path: Option<&str>,
|
||||||
|
advert_name: Option<&str>,
|
||||||
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||||
let paths = super::super::serial::detect_serial_devices().await;
|
let mut paths = super::super::serial::detect_serial_devices().await;
|
||||||
if paths.is_empty() {
|
// When falling back from a just-failed preferred path, don't probe that
|
||||||
anyhow::bail!("No serial devices found in /dev");
|
// same device again in the same cycle — every open() toggles DTR/RTS,
|
||||||
|
// which resets ESP32-family boards, and back-to-back re-probes are what
|
||||||
|
// keeps a mid-boot board from ever finishing its boot.
|
||||||
|
if let Some(skip) = skip_path {
|
||||||
|
let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| p.into());
|
||||||
|
let skip_canon = canon(skip);
|
||||||
|
paths.retain(|p| canon(p) != skip_canon);
|
||||||
}
|
}
|
||||||
|
if paths.is_empty() {
|
||||||
|
anyhow::bail!(match skip_path {
|
||||||
|
Some(skip) => format!("No serial devices found in /dev besides {skip}, which was already probed this cycle"),
|
||||||
|
None => "No serial devices found in /dev".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
info!(candidates = ?paths, "Auto-detect candidate ports for this attempt");
|
||||||
for path in &paths {
|
for path in &paths {
|
||||||
debug!(path = %path, "Probing for mesh radio device");
|
debug!(path = %path, "Probing for mesh radio device");
|
||||||
// Tried FIRST: `ReticulumLink::open()` gates its expensive daemon
|
// Tried FIRST: `ReticulumLink::open()` gates its expensive daemon
|
||||||
@ -291,6 +306,7 @@ async fn auto_detect_and_open(
|
|||||||
data_dir,
|
data_dir,
|
||||||
Some(our_ed_pubkey_hex),
|
Some(our_ed_pubkey_hex),
|
||||||
Some(our_x25519_pubkey_hex),
|
Some(our_x25519_pubkey_hex),
|
||||||
|
advert_name,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@ -359,6 +375,16 @@ pub struct DeviceProbe {
|
|||||||
pub max_contacts: Option<u16>,
|
pub max_contacts: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serializes serial-port open sequences between the listener's session
|
||||||
|
/// opens and the RPC probe (`mesh.probe-device`). Linux happily double-opens
|
||||||
|
/// a tty, and two concurrent handshakes corrupt each other into silence —
|
||||||
|
/// observed live on .116 (2026-07-26): the kiosk browser's hot-swap
|
||||||
|
/// auto-probe collided with the listener's cycle on every backoff window, so
|
||||||
|
/// neither ever succeeded, and each collision's open() DTR/RTS-reset the
|
||||||
|
/// board again. The probe's retry-across-idle-gaps heuristic (5f01ec31)
|
||||||
|
/// narrowed but could not close the race; this closes it.
|
||||||
|
static PORT_OPEN_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
|
|
||||||
/// Probe a serial port for a mesh radio WITHOUT provisioning it: identify the
|
/// Probe a serial port for a mesh radio WITHOUT provisioning it: identify the
|
||||||
/// firmware (same strict Reticulum→Meshcore→Meshtastic order as auto-detect,
|
/// firmware (same strict Reticulum→Meshcore→Meshtastic order as auto-detect,
|
||||||
/// for the same RNode-wedging reason) and read what's currently configured on
|
/// for the same RNode-wedging reason) and read what's currently configured on
|
||||||
@ -366,11 +392,10 @@ pub struct DeviceProbe {
|
|||||||
/// loop can pick the device up afterwards. Reticulum uses the bare KISS
|
/// loop can pick the device up afterwards. Reticulum uses the bare KISS
|
||||||
/// DETECT probe — no daemon spawn just to identify a stick.
|
/// DETECT probe — no daemon spawn just to identify a stick.
|
||||||
pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
|
pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
|
||||||
// The listener's reconnect loop may hold this port for ~10s of every
|
// Retries kept even with PORT_OPEN_LOCK closing the double-open race:
|
||||||
// backoff cycle, and Linux happily double-opens a tty — two concurrent
|
// the board may still be mid-boot from a previous open's DTR/RTS reset,
|
||||||
// handshakes corrupt each other into silence (observed on framework-pt:
|
// and a later attempt after a quiet gap can succeed where the first
|
||||||
// every firmware "failed" while the listener was mid-cycle). Retry across
|
// couldn't.
|
||||||
// the listener's idle gaps instead of failing on first collision.
|
|
||||||
let mut last_err = None;
|
let mut last_err = None;
|
||||||
for attempt in 0..3u32 {
|
for attempt in 0..3u32 {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
@ -385,6 +410,7 @@ pub(crate) async fn probe_device(path: &str) -> Result<DeviceProbe> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn probe_device_once(path: &str) -> Result<DeviceProbe> {
|
async fn probe_device_once(path: &str) -> Result<DeviceProbe> {
|
||||||
|
let _port_guard = PORT_OPEN_LOCK.lock().await;
|
||||||
if super::super::reticulum::probe_rnode(path).await.is_ok() {
|
if super::super::reticulum::probe_rnode(path).await.is_ok() {
|
||||||
return Ok(DeviceProbe {
|
return Ok(DeviceProbe {
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
@ -442,6 +468,7 @@ async fn open_preferred_path(
|
|||||||
our_ed_pubkey_hex: &str,
|
our_ed_pubkey_hex: &str,
|
||||||
our_x25519_pubkey_hex: &str,
|
our_x25519_pubkey_hex: &str,
|
||||||
device_kind: Option<DeviceType>,
|
device_kind: Option<DeviceType>,
|
||||||
|
advert_name: Option<&str>,
|
||||||
) -> Result<(MeshRadioDevice, DeviceInfo)> {
|
) -> Result<(MeshRadioDevice, DeviceInfo)> {
|
||||||
// Pinned: try only the configured firmware and surface its own error —
|
// Pinned: try only the configured firmware and surface its own error —
|
||||||
// never fall through to (and inject probe bytes into) another firmware's
|
// never fall through to (and inject probe bytes into) another firmware's
|
||||||
@ -474,6 +501,7 @@ async fn open_preferred_path(
|
|||||||
data_dir,
|
data_dir,
|
||||||
Some(our_ed_pubkey_hex),
|
Some(our_ed_pubkey_hex),
|
||||||
Some(our_x25519_pubkey_hex),
|
Some(our_x25519_pubkey_hex),
|
||||||
|
advert_name,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.context("Could not open preferred path as Reticulum")?;
|
.context("Could not open preferred path as Reticulum")?;
|
||||||
@ -487,40 +515,21 @@ async fn open_preferred_path(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reticulum first — see the matching comment on auto_detect_and_open:
|
// Unpinned: don't probe this path ourselves at all. Confirmed live
|
||||||
// its cheap probe_rnode gate fails in ~1s for non-RNode firmware, while
|
// 2026-07-23 — this function used to run its own Reticulum→Meshcore→
|
||||||
// trying Meshcore/Meshtastic first was observed leaving a real RNode
|
// Meshtastic sequence here, and the caller (run_mesh_session) falls
|
||||||
// board unresponsive by the time Reticulum's turn came.
|
// back to `auto_detect_and_open` on any error, which scans every
|
||||||
match ReticulumLink::open(
|
// candidate path (this one included) with the exact same three-protocol
|
||||||
path,
|
// sequence. With a single physical radio — the overwhelmingly common
|
||||||
data_dir,
|
// case — `path` here IS the one candidate `auto_detect_and_open` is
|
||||||
Some(our_ed_pubkey_hex),
|
// about to try, so every unpinned reconnect was resetting the board via
|
||||||
Some(our_x25519_pubkey_hex),
|
// Reticulum/Meshcore/Meshtastic's DTR/RTS toggle TWICE: once here, once
|
||||||
)
|
// again moments later in auto-detect. Bailing immediately (no port
|
||||||
.await
|
// access at all) means auto-detect's single pass is the only one that
|
||||||
{
|
// ever touches the port when nothing is pinned yet. (auto_detect_and_open
|
||||||
Ok(mut dev) => match dev.initialize().await {
|
// carries the advert_name threading from main, so nothing is lost.)
|
||||||
Ok(info) => return Ok((MeshRadioDevice::Reticulum(dev), info)),
|
let _ = advert_name;
|
||||||
Err(e) => {
|
anyhow::bail!("No device_kind pin — deferring to auto-detect for {path}")
|
||||||
debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Reticulum"),
|
|
||||||
}
|
|
||||||
match MeshcoreDevice::open(path).await {
|
|
||||||
Ok(mut dev) => match dev.initialize().await {
|
|
||||||
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
|
|
||||||
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
|
|
||||||
},
|
|
||||||
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
|
|
||||||
}
|
|
||||||
match MeshtasticDevice::open(path).await {
|
|
||||||
Ok(mut dev) => match dev.initialize().await {
|
|
||||||
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
|
|
||||||
Err(e) => Err(e).context("Preferred path is not a working Meshtastic device"),
|
|
||||||
},
|
|
||||||
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bring up a Reticulum daemon over plain TCP — no physical RNode, no
|
/// Bring up a Reticulum daemon over plain TCP — no physical RNode, no
|
||||||
@ -533,6 +542,7 @@ async fn open_reticulum_tcp(
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
our_ed_pubkey_hex: &str,
|
our_ed_pubkey_hex: &str,
|
||||||
our_x25519_pubkey_hex: &str,
|
our_x25519_pubkey_hex: &str,
|
||||||
|
advert_name: Option<&str>,
|
||||||
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||||
let mut dev = match cfg {
|
let mut dev = match cfg {
|
||||||
ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server(
|
ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server(
|
||||||
@ -540,6 +550,7 @@ async fn open_reticulum_tcp(
|
|||||||
data_dir,
|
data_dir,
|
||||||
Some(our_ed_pubkey_hex),
|
Some(our_ed_pubkey_hex),
|
||||||
Some(our_x25519_pubkey_hex),
|
Some(our_x25519_pubkey_hex),
|
||||||
|
advert_name,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.context("Could not open Reticulum TCP server interface")?,
|
.context("Could not open Reticulum TCP server interface")?,
|
||||||
@ -548,6 +559,7 @@ async fn open_reticulum_tcp(
|
|||||||
data_dir,
|
data_dir,
|
||||||
Some(our_ed_pubkey_hex),
|
Some(our_ed_pubkey_hex),
|
||||||
Some(our_x25519_pubkey_hex),
|
Some(our_x25519_pubkey_hex),
|
||||||
|
advert_name,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.context("Could not open Reticulum TCP client interface")?,
|
.context("Could not open Reticulum TCP client interface")?,
|
||||||
@ -963,41 +975,110 @@ pub(super) async fn run_mesh_session(
|
|||||||
// set, otherwise try the preferred serial path, falling back to
|
// set, otherwise try the preferred serial path, falling back to
|
||||||
// auto-detect. TCP mode is additive/dev-only; it never changes behavior
|
// auto-detect. TCP mode is additive/dev-only; it never changes behavior
|
||||||
// for existing serial/RNode deployments where `reticulum_tcp` is None.
|
// for existing serial/RNode deployments where `reticulum_tcp` is None.
|
||||||
let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp {
|
//
|
||||||
open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await?
|
// The name we present on the mesh: the operator's configured mesh name /
|
||||||
} else if let Some(path) = preferred_path {
|
// server name, falling back to a DID fragment. Computed BEFORE the open
|
||||||
match open_preferred_path(
|
// sequence because Reticulum needs it at daemon-spawn time — the RNS
|
||||||
path,
|
// announce carries it from the very first announce. (Meshcore/Meshtastic
|
||||||
data_dir,
|
// still receive it via set_advert_name after connect, below.)
|
||||||
our_ed_pubkey_hex,
|
let desired_advert_name: String = match server_name {
|
||||||
our_x25519_pubkey_hex,
|
// Meshcore firmware limits advert names — truncate to 20 chars.
|
||||||
device_kind,
|
Some(name) => name.chars().take(20).collect(),
|
||||||
)
|
None => format!(
|
||||||
.await
|
"Archy-{}",
|
||||||
{
|
our_did.chars().skip(8).take(8).collect::<String>()
|
||||||
Ok((dev, info)) => (path.to_string(), dev, info),
|
),
|
||||||
Err(e) => {
|
};
|
||||||
warn!(
|
|
||||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
// The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe
|
||||||
path, e
|
// can't interleave its own handshakes on the same tty (see the lock's
|
||||||
);
|
// doc comment). Held only until the device is opened, then released.
|
||||||
|
//
|
||||||
|
// The sequence is raced against the shutdown signal: probes/handshakes
|
||||||
|
// can take 10s+, and without this a stop() issued mid-probe (config
|
||||||
|
// change, disable, rename) always burned the full listener-shutdown
|
||||||
|
// timeout and ended in a hard abort — observed live on archi-dev-box
|
||||||
|
// 2026-07-28. Dropping the open future mid-probe is safe: it holds no
|
||||||
|
// session state yet and the port guard/serial handle close with it.
|
||||||
|
let open_fut = async {
|
||||||
|
let port_guard = PORT_OPEN_LOCK.lock().await;
|
||||||
|
let result = if let Some(tcp_cfg) = &reticulum_tcp {
|
||||||
|
open_reticulum_tcp(
|
||||||
|
tcp_cfg,
|
||||||
|
data_dir,
|
||||||
|
our_ed_pubkey_hex,
|
||||||
|
our_x25519_pubkey_hex,
|
||||||
|
Some(&desired_advert_name),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else if let Some(path) = preferred_path {
|
||||||
|
if device_kind.is_none() {
|
||||||
|
// Unpinned: open_preferred_path bails without ever touching
|
||||||
|
// the port, so the preferred path has NOT been probed this
|
||||||
|
// cycle — auto-detect must keep it as a candidate
|
||||||
|
// (skip_path=None). Passing Some(path) here excluded the only
|
||||||
|
// radio on single-device nodes and the mesh never came up.
|
||||||
auto_detect_and_open(
|
auto_detect_and_open(
|
||||||
data_dir,
|
data_dir,
|
||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
device_kind,
|
device_kind,
|
||||||
|
None,
|
||||||
|
Some(&desired_advert_name),
|
||||||
)
|
)
|
||||||
.await?
|
.await
|
||||||
|
} else {
|
||||||
|
match open_preferred_path(
|
||||||
|
path,
|
||||||
|
data_dir,
|
||||||
|
our_ed_pubkey_hex,
|
||||||
|
our_x25519_pubkey_hex,
|
||||||
|
device_kind,
|
||||||
|
Some(&desired_advert_name),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((dev, info)) => Ok((path.to_string(), dev, info)),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||||
|
path, e
|
||||||
|
);
|
||||||
|
auto_detect_and_open(
|
||||||
|
data_dir,
|
||||||
|
our_ed_pubkey_hex,
|
||||||
|
our_x25519_pubkey_hex,
|
||||||
|
device_kind,
|
||||||
|
Some(path),
|
||||||
|
Some(&desired_advert_name),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
auto_detect_and_open(
|
||||||
|
data_dir,
|
||||||
|
our_ed_pubkey_hex,
|
||||||
|
our_x25519_pubkey_hex,
|
||||||
|
device_kind,
|
||||||
|
None,
|
||||||
|
Some(&desired_advert_name),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
};
|
||||||
|
drop(port_guard);
|
||||||
|
result
|
||||||
|
};
|
||||||
|
let (device_path, mut device, device_info) = tokio::select! {
|
||||||
|
res = open_fut => res?,
|
||||||
|
_ = shutdown.changed() => {
|
||||||
|
if *shutdown.borrow() {
|
||||||
|
info!("Shutdown requested during device open — ending session");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
anyhow::bail!("shutdown signal changed during device open");
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
auto_detect_and_open(
|
|
||||||
data_dir,
|
|
||||||
our_ed_pubkey_hex,
|
|
||||||
our_x25519_pubkey_hex,
|
|
||||||
device_kind,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update status
|
// Update status
|
||||||
@ -1145,19 +1226,14 @@ pub(super) async fn run_mesh_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set advert name to the server's human-readable name (e.g. "ThinkPad"),
|
// Set advert name to the configured mesh/server name (computed above).
|
||||||
// falling back to the DID fragment if no name is configured. Skipped in
|
// Skipped in keep-as-is mode for radio-held names — the radio keeps the
|
||||||
// keep-as-is mode — the radio keeps the name it came with (already
|
// name it came with (already reflected in status from the connect
|
||||||
// reflected in status from the connect handshake).
|
// handshake). Reticulum is exempt from keep-as-is: its display name
|
||||||
if manage_radio {
|
// lives in OUR daemon (the RNode holds no name), so "keep as is" has
|
||||||
let advert_name = if let Some(name) = server_name {
|
// nothing to preserve and an unnamed node would be anonymous on RNS.
|
||||||
// Meshcore firmware limits advert names — truncate to 20 chars
|
if manage_radio || matches!(device, MeshRadioDevice::Reticulum(_)) {
|
||||||
name.chars().take(20).collect::<String>()
|
if let Err(e) = device.set_advert_name(&desired_advert_name).await {
|
||||||
} else {
|
|
||||||
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
|
|
||||||
format!("Archy-{}", short_did)
|
|
||||||
};
|
|
||||||
if let Err(e) = device.set_advert_name(&advert_name).await {
|
|
||||||
warn!("Failed to set advert name: {}", e);
|
warn!("Failed to set advert name: {}", e);
|
||||||
} else {
|
} else {
|
||||||
// Reflect the post-set name in MeshStatus too so the UI can filter
|
// Reflect the post-set name in MeshStatus too so the UI can filter
|
||||||
@ -1165,7 +1241,7 @@ pub(super) async fn run_mesh_session(
|
|||||||
// still carries whatever pre-set name the firmware reported and the
|
// still carries whatever pre-set name the firmware reported and the
|
||||||
// self-filter never matches.
|
// self-filter never matches.
|
||||||
let mut status = state.status.write().await;
|
let mut status = state.status.write().await;
|
||||||
status.self_advert_name = Some(advert_name.clone());
|
status.self_advert_name = Some(desired_advert_name.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1458,6 +1534,15 @@ async fn handle_send_command(
|
|||||||
} else {
|
} else {
|
||||||
*consecutive_write_failures = 0;
|
*consecutive_write_failures = 0;
|
||||||
}
|
}
|
||||||
|
// The self-advert alone is a no-op for discovery on Meshtastic
|
||||||
|
// (heartbeat + time carry no identity) — the NodeInfo broadcast
|
||||||
|
// is what makes peers learn/refresh us. want_response=true so
|
||||||
|
// neighbours answer with their own NodeInfo: the user pressed
|
||||||
|
// Broadcast to be seen AND to see who's out there. No-op on
|
||||||
|
// Meshcore/Reticulum, whose self-advert already carries identity.
|
||||||
|
if let Err(e) = device.send_nodeinfo_advert(true).await {
|
||||||
|
warn!("Failed to send NodeInfo advert: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MeshCommand::RebootRadio { seconds } => {
|
MeshCommand::RebootRadio { seconds } => {
|
||||||
if let Err(e) = device.reboot(seconds).await {
|
if let Err(e) = device.reboot(seconds).await {
|
||||||
|
|||||||
@ -217,11 +217,20 @@ impl MeshtasticDevice {
|
|||||||
path
|
path
|
||||||
))?;
|
))?;
|
||||||
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
|
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
|
||||||
// boards reset on a DTR/RTS transition, so deassert both and settle
|
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
|
||||||
// before the handshake below.
|
// auto-reset) reset on a DTR/RTS transition, so deassert both and
|
||||||
|
// settle before the handshake below. 300ms is nowhere near a real
|
||||||
|
// firmware boot time (LoRa radio init alone can take longer) —
|
||||||
|
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
|
||||||
|
// Meshtastic's open() doing this same reset, a single auto-detect
|
||||||
|
// cycle trying multiple protocols in sequence kept re-resetting the
|
||||||
|
// board before it ever finished booting from the PREVIOUS attempt's
|
||||||
|
// reset, on both a Heltec V3 and V4, regardless of firmware family —
|
||||||
|
// a self-sustaining "never finishes booting" loop with a boot-time
|
||||||
|
// root cause hiding behind what looked like a per-protocol failure.
|
||||||
let _ = port.set_dtr(false);
|
let _ = port.set_dtr(false);
|
||||||
let _ = port.set_rts(false);
|
let _ = port.set_rts(false);
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
tokio::time::sleep(Duration::from_millis(2000)).await;
|
||||||
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
|
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
pub mod alerts;
|
pub mod alerts;
|
||||||
pub mod bitcoin_relay;
|
pub mod bitcoin_relay;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
|
pub mod flash;
|
||||||
pub mod listener;
|
pub mod listener;
|
||||||
pub mod meshtastic;
|
pub mod meshtastic;
|
||||||
pub mod message_types;
|
pub mod message_types;
|
||||||
@ -38,6 +39,14 @@ use tokio::sync::watch;
|
|||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
||||||
|
|
||||||
|
/// How long `MeshService::stop()` waits for the listener task to notice its
|
||||||
|
/// shutdown signal and exit gracefully before force-aborting it. See
|
||||||
|
/// `stop()`'s doc comment for the real incident this guards against: without
|
||||||
|
/// a hard abort fallback, a slow-to-notice listener could be left running
|
||||||
|
/// forever, orphaned, racing a later independently-started listener on the
|
||||||
|
/// same serial port.
|
||||||
|
const LISTENER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
|
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
|
||||||
const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
|
const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
|
||||||
|
|
||||||
@ -469,6 +478,31 @@ impl Default for MeshConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a mesh config file has ever been written for this node — lets the
|
||||||
|
/// boot path distinguish "operator explicitly disabled mesh" (file exists,
|
||||||
|
/// enabled=false) from "never configured" (no file), which is the only case
|
||||||
|
/// radio auto-enable should touch.
|
||||||
|
pub fn config_file_exists(data_dir: &Path) -> bool {
|
||||||
|
data_dir.join(MESH_CONFIG_FILE).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `new` differs from `old` in any field a running mesh session
|
||||||
|
/// captured by value at spawn (device path/kind, advert name, region, PHY
|
||||||
|
/// params, channel, manage_radio, TCP interface) — i.e. when applying `new`
|
||||||
|
/// to a live service requires a listener restart. Fields the session reads
|
||||||
|
/// live from shared state (broadcast flags, assistant settings, steganography
|
||||||
|
/// mode, …) deliberately don't trigger a restart.
|
||||||
|
fn session_config_changed(old: &MeshConfig, new: &MeshConfig) -> bool {
|
||||||
|
old.device_path != new.device_path
|
||||||
|
|| old.device_kind != new.device_kind
|
||||||
|
|| old.advert_name != new.advert_name
|
||||||
|
|| old.lora_region != new.lora_region
|
||||||
|
|| old.lora_radio_params != new.lora_radio_params
|
||||||
|
|| old.channel_name != new.channel_name
|
||||||
|
|| old.manage_radio != new.manage_radio
|
||||||
|
|| old.reticulum_tcp != new.reticulum_tcp
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
|
pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
|
||||||
let path = data_dir.join(MESH_CONFIG_FILE);
|
let path = data_dir.join(MESH_CONFIG_FILE);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@ -734,10 +768,18 @@ impl MeshService {
|
|||||||
self.server_name = name;
|
self.server_name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the background mesh listener.
|
/// Start the background mesh listener. Idempotent: if the listener is
|
||||||
|
/// already running, this is a harmless no-op rather than an error —
|
||||||
|
/// confirmed live 2026-07-23, a real race between the flash job's own
|
||||||
|
/// post-flash restart and a concurrent user "Keep As Is" click (both
|
||||||
|
/// legitimately trying to ensure the listener is running) surfaced this
|
||||||
|
/// as a user-facing "Mesh listener already running" RPC error. Ensuring
|
||||||
|
/// the listener is running is the intent every caller actually has;
|
||||||
|
/// whichever caller's start() happens to win the race, the other
|
||||||
|
/// finding it already satisfied is success, not failure.
|
||||||
pub fn start(&mut self) -> Result<()> {
|
pub fn start(&mut self) -> Result<()> {
|
||||||
if self.listener_handle.is_some() {
|
if self.listener_handle.is_some() {
|
||||||
anyhow::bail!("Mesh listener already running");
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
@ -756,7 +798,14 @@ impl MeshService {
|
|||||||
self.our_ed_pubkey_hex.clone(),
|
self.our_ed_pubkey_hex.clone(),
|
||||||
self.our_x25519_secret,
|
self.our_x25519_secret,
|
||||||
self.our_x25519_pubkey_hex.clone(),
|
self.our_x25519_pubkey_hex.clone(),
|
||||||
self.server_name.clone(),
|
// The mesh-page "Name on the mesh" (config.advert_name) wins over
|
||||||
|
// the server name — it existed as write-only config with no reader
|
||||||
|
// until this line, which is why renaming on the Mesh page never
|
||||||
|
// changed anything on the air.
|
||||||
|
self.config
|
||||||
|
.advert_name
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.server_name.clone()),
|
||||||
self.config.lora_region.clone(),
|
self.config.lora_region.clone(),
|
||||||
self.config.lora_radio_params,
|
self.config.lora_radio_params,
|
||||||
self.config.channel_name.clone(),
|
self.config.channel_name.clone(),
|
||||||
@ -967,8 +1016,36 @@ impl MeshService {
|
|||||||
if let Some(tx) = self.shutdown_tx.take() {
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
let _ = tx.send(true);
|
let _ = tx.send(true);
|
||||||
}
|
}
|
||||||
if let Some(handle) = self.listener_handle.take() {
|
if let Some(mut handle) = self.listener_handle.take() {
|
||||||
let _ = handle.await;
|
// Bounded wait for graceful shutdown, with a hard abort as
|
||||||
|
// fallback — confirmed live 2026-07-23: a caller-side timeout
|
||||||
|
// wrapping stop() (mesh::flash's STOP_LISTENER_TIMEOUT) cancelled
|
||||||
|
// this await when the listener was slow to notice its shutdown
|
||||||
|
// signal (mid multi-candidate probe), but `.take()` above had
|
||||||
|
// already cleared `listener_handle` to None — so MeshService
|
||||||
|
// believed it was stopped while the task kept running, orphaned
|
||||||
|
// (dropping a JoinHandle does not abort the task it points to).
|
||||||
|
// A later start() then spawned a second, fully independent
|
||||||
|
// listener session racing the orphaned one on the same serial
|
||||||
|
// port — neither could ever get a clean response, so every
|
||||||
|
// mesh.configure/probe against that device failed indefinitely
|
||||||
|
// even though the device itself was fine.
|
||||||
|
//
|
||||||
|
// Awaiting `&mut handle` (not `handle` by value) is what makes
|
||||||
|
// the fallback possible: the Future is polled through the
|
||||||
|
// reference, so if the timeout fires, this task's own `handle`
|
||||||
|
// binding is still ours to call `.abort()` on afterward —
|
||||||
|
// unlike moving `handle` into the timeout future outright, which
|
||||||
|
// would drop (and thus orphan) it on timeout with nothing left
|
||||||
|
// to abort.
|
||||||
|
if tokio::time::timeout(LISTENER_SHUTDOWN_TIMEOUT, &mut handle)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
warn!("Mesh listener did not shut down gracefully in time — aborting it");
|
||||||
|
handle.abort();
|
||||||
|
let _ = handle.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(handle) = self.deadman_handle.take() {
|
if let Some(handle) = self.deadman_handle.take() {
|
||||||
handle.abort();
|
handle.abort();
|
||||||
@ -1019,19 +1096,27 @@ impl MeshService {
|
|||||||
self.state.peers.read().await.values().cloned().collect()
|
self.state.peers.read().await.values().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe a serial port for a mesh radio without provisioning or keeping
|
/// Refuse to probe the port the live session currently occupies (the
|
||||||
/// it — powers the hot-swap "device detected" modal's current-details
|
/// probe would steal the serial port from under the session); a
|
||||||
/// view. Refuses to probe the port the live session currently occupies
|
|
||||||
/// (the probe would steal the serial port from under the session); a
|
|
||||||
/// detected-but-not-connected port is fair game, accepting a benign race
|
/// detected-but-not-connected port is fair game, accepting a benign race
|
||||||
/// with the reconnect loop (whichever loses just retries).
|
/// with the reconnect loop (whichever loses just retries). Split out from
|
||||||
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> {
|
/// the actual probe on purpose — see `probe_device`'s doc comment.
|
||||||
|
pub async fn ensure_probe_allowed(&self, path: &str) -> Result<()> {
|
||||||
let status = self.state.status.read().await;
|
let status = self.state.status.read().await;
|
||||||
if status.device_connected && status.device_path.as_deref() == Some(path) {
|
if status.device_connected {
|
||||||
anyhow::bail!("{path} is the active mesh radio — already connected");
|
if let Some(active) = status.device_path.as_deref() {
|
||||||
|
// Compare canonical paths: /dev/mesh-radio is a symlink to the
|
||||||
|
// ttyUSB*/ttyACM* node, and a probe through the alias would
|
||||||
|
// still open the very tty the live session is holding.
|
||||||
|
let canon = |p: &str| {
|
||||||
|
std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p))
|
||||||
|
};
|
||||||
|
if canon(active) == canon(path) {
|
||||||
|
anyhow::bail!("{path} is the active mesh radio — already connected");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
drop(status);
|
Ok(())
|
||||||
listener::probe_device(path).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get message history.
|
/// Get message history.
|
||||||
@ -1078,7 +1163,32 @@ impl MeshService {
|
|||||||
let peer = peers
|
let peer = peers
|
||||||
.get(&contact_id)
|
.get(&contact_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
|
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
|
||||||
let pubkey_hex = peer
|
// Cross-transport twin resolution: callers frequently hold the
|
||||||
|
// FEDERATION twin's contact_id (the UI's merged conversation row),
|
||||||
|
// whose pubkey_hex is the Archipelago ed25519 key — NOT a radio
|
||||||
|
// routing key. Sending a Reticulum resource with that prefix fails
|
||||||
|
// with "Unknown Reticulum prefix" (observed live 2026-07-28,
|
||||||
|
// image-over-LoRa to a merged contact). Route via the radio twin —
|
||||||
|
// same arch identity, radio-range id — whose pubkey_hex is the
|
||||||
|
// actual over-the-air routing key (RNS dest hash / firmware key).
|
||||||
|
let radio_peer = if peer.contact_id >= FEDERATION_CONTACT_ID_BASE {
|
||||||
|
peer.arch_pubkey_hex
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|arch| {
|
||||||
|
peers.values().find(|p| {
|
||||||
|
p.contact_id < FEDERATION_CONTACT_ID_BASE
|
||||||
|
&& p.arch_pubkey_hex.as_deref() == Some(arch)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Peer is federation-only (no radio twin) — not reachable over the radio"
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
peer
|
||||||
|
};
|
||||||
|
let pubkey_hex = radio_peer
|
||||||
.pubkey_hex
|
.pubkey_hex
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
|
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
|
||||||
@ -1216,12 +1326,45 @@ impl MeshService {
|
|||||||
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
|
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
};
|
};
|
||||||
|
// Transport policy: LoRa first when it can actually carry the message,
|
||||||
|
// then FIPS, then Tor. A federation-synthetic id (what the UI's merged
|
||||||
|
// conversation holds) used to ALWAYS take the federation path, even
|
||||||
|
// when the very same node was sitting one LoRa hop away — so chats
|
||||||
|
// between two radio-equipped nodes silently rode FIPS/Tor. If the
|
||||||
|
// federation contact has a REACHABLE radio twin (same archipelago
|
||||||
|
// identity, radio-range id) and the payload fits the radio, skip the
|
||||||
|
// federation branch: the fall-through LoRa path twin-resolves the
|
||||||
|
// routing key via peer_dest_prefix.
|
||||||
|
let device_connected = self.state.status.read().await.device_connected;
|
||||||
|
let radio_twin_reachable =
|
||||||
|
is_federation_synthetic && !exceeds_lora && device_connected && {
|
||||||
|
let peers = self.state.peers.read().await;
|
||||||
|
peers
|
||||||
|
.get(&contact_id)
|
||||||
|
.and_then(|p| p.arch_pubkey_hex.clone())
|
||||||
|
.map(|arch| {
|
||||||
|
peers.values().any(|p| {
|
||||||
|
p.contact_id < FEDERATION_CONTACT_ID_BASE
|
||||||
|
&& p.reachable
|
||||||
|
&& p.arch_pubkey_hex.as_deref() == Some(arch.as_str())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
let mesh_only_mode = load_config(&self.data_dir)
|
let mesh_only_mode = load_config(&self.data_dir)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|cfg| cfg.mesh_only_mode)
|
.and_then(|cfg| cfg.mesh_only_mode)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
if radio_twin_reachable && !mesh_only_mode {
|
||||||
|
tracing::info!(
|
||||||
|
contact_id,
|
||||||
|
bytes = wire.len(),
|
||||||
|
"Radio-first routing: federation contact has a reachable radio twin — sending over LoRa"
|
||||||
|
);
|
||||||
|
}
|
||||||
if !mesh_only_mode
|
if !mesh_only_mode
|
||||||
|
&& !radio_twin_reachable
|
||||||
&& (is_federation_synthetic || exceeds_lora || radio_federated_unreachable)
|
&& (is_federation_synthetic || exceeds_lora || radio_federated_unreachable)
|
||||||
{
|
{
|
||||||
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
|
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
|
||||||
@ -2045,6 +2188,7 @@ impl MeshService {
|
|||||||
save_config(&self.data_dir, &config).await?;
|
save_config(&self.data_dir, &config).await?;
|
||||||
|
|
||||||
let was_enabled = self.config.enabled;
|
let was_enabled = self.config.enabled;
|
||||||
|
let needs_session_restart = session_config_changed(&self.config, &config);
|
||||||
self.config = config.clone();
|
self.config = config.clone();
|
||||||
|
|
||||||
// Update the status to reflect new config
|
// Update the status to reflect new config
|
||||||
@ -2069,11 +2213,31 @@ impl MeshService {
|
|||||||
status.firmware_version = None;
|
status.firmware_version = None;
|
||||||
status.self_node_id = None;
|
status.self_node_id = None;
|
||||||
status.peer_count = 0;
|
status.peer_count = 0;
|
||||||
|
} else if config.enabled && was_enabled && needs_session_restart {
|
||||||
|
info!("Mesh session config changed — restarting listener to apply");
|
||||||
|
self.stop().await;
|
||||||
|
self.start()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The service's current (last-applied) config.
|
||||||
|
pub fn config(&self) -> &MeshConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart the listener (if running) so it picks up out-of-band state a
|
||||||
|
/// spawn captured by value — currently the server name pushed by
|
||||||
|
/// `server.set-name`.
|
||||||
|
pub async fn restart_listener_if_running(&mut self) -> Result<()> {
|
||||||
|
if self.listener_handle.is_some() {
|
||||||
|
self.stop().await;
|
||||||
|
self.start()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get a reference to shared state (for RPC handlers).
|
/// Get a reference to shared state (for RPC handlers).
|
||||||
pub fn shared_state(&self) -> Arc<MeshState> {
|
pub fn shared_state(&self) -> Arc<MeshState> {
|
||||||
Arc::clone(&self.state)
|
Arc::clone(&self.state)
|
||||||
@ -2195,6 +2359,46 @@ async fn bitcoin_rpc_getblockheader_by_height(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_config_change_detection() {
|
||||||
|
let base = MeshConfig::default();
|
||||||
|
|
||||||
|
// Same config → no restart.
|
||||||
|
assert!(!session_config_changed(&base, &base.clone()));
|
||||||
|
|
||||||
|
// Every session-captured field individually triggers a restart.
|
||||||
|
let mut c = base.clone();
|
||||||
|
c.device_kind = Some(types::DeviceType::Reticulum);
|
||||||
|
assert!(session_config_changed(&base, &c));
|
||||||
|
|
||||||
|
let mut c = base.clone();
|
||||||
|
c.device_path = Some("/dev/ttyUSB0".into());
|
||||||
|
assert!(session_config_changed(&base, &c));
|
||||||
|
|
||||||
|
let mut c = base.clone();
|
||||||
|
c.advert_name = Some("RNode Shaza".into());
|
||||||
|
assert!(session_config_changed(&base, &c));
|
||||||
|
|
||||||
|
let mut c = base.clone();
|
||||||
|
c.manage_radio = !base.manage_radio;
|
||||||
|
assert!(session_config_changed(&base, &c));
|
||||||
|
|
||||||
|
let mut c = base.clone();
|
||||||
|
c.lora_region = Some("EU_868".into());
|
||||||
|
assert!(session_config_changed(&base, &c));
|
||||||
|
|
||||||
|
let mut c = base.clone();
|
||||||
|
c.channel_name = Some("private-net".into());
|
||||||
|
assert!(session_config_changed(&base, &c));
|
||||||
|
|
||||||
|
// Live-read fields must NOT force a session restart.
|
||||||
|
let mut c = base.clone();
|
||||||
|
c.broadcast_identity = !base.broadcast_identity;
|
||||||
|
c.announce_block_headers = !base.announce_block_headers;
|
||||||
|
c.assistant_enabled = !base.assistant_enabled;
|
||||||
|
assert!(!session_config_changed(&base, &c));
|
||||||
|
}
|
||||||
|
|
||||||
fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer {
|
fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer {
|
||||||
MeshPeer {
|
MeshPeer {
|
||||||
contact_id,
|
contact_id,
|
||||||
|
|||||||
@ -121,6 +121,7 @@ fn daemon_command(
|
|||||||
identity_key: &Path,
|
identity_key: &Path,
|
||||||
archy_ed_pubkey_hex: Option<&str>,
|
archy_ed_pubkey_hex: Option<&str>,
|
||||||
archy_x25519_pubkey_hex: Option<&str>,
|
archy_x25519_pubkey_hex: Option<&str>,
|
||||||
|
display_name: Option<&str>,
|
||||||
) -> Command {
|
) -> Command {
|
||||||
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
|
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
|
||||||
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
|
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
|
||||||
@ -153,12 +154,27 @@ fn daemon_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Archy nodes are RNS transport nodes: they relay traffic and rebroadcast
|
||||||
|
// announces, so archy nodes (and Sideband/NomadNet peers) beyond direct RF
|
||||||
|
// range discover and reach each other through any archy node in between.
|
||||||
|
// Edge-only operation (`enable_transport = no`) left every node an island
|
||||||
|
// limited to its own radio horizon.
|
||||||
|
cmd.arg("--enable-transport");
|
||||||
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
|
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
|
||||||
cmd.arg("--archy-ed-pubkey-hex")
|
cmd.arg("--archy-ed-pubkey-hex")
|
||||||
.arg(ed)
|
.arg(ed)
|
||||||
.arg("--archy-x25519-pubkey-hex")
|
.arg("--archy-x25519-pubkey-hex")
|
||||||
.arg(x);
|
.arg(x);
|
||||||
}
|
}
|
||||||
|
// The RNS-visible display name (what Sideband/NomadNet/other archy nodes
|
||||||
|
// show for us). Without this the daemon falls back to its argparse
|
||||||
|
// default and every archy node announces the same anonymous name.
|
||||||
|
if let Some(name) = display_name {
|
||||||
|
let name = name.trim();
|
||||||
|
if !name.is_empty() {
|
||||||
|
cmd.arg("--display-name").arg(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Run the daemon as its own process-group leader. The packaged binary is
|
// Run the daemon as its own process-group leader. The packaged binary is
|
||||||
// a PyInstaller one-file bootloader that forks the real Python process;
|
// a PyInstaller one-file bootloader that forks the real Python process;
|
||||||
// making it a group leader lets shutdown signal the WHOLE group so the
|
// making it a group leader lets shutdown signal the WHOLE group so the
|
||||||
@ -207,6 +223,10 @@ struct ReticulumPeer {
|
|||||||
/// `bind_federation_twins`, which those two transports rely on instead).
|
/// `bind_federation_twins`, which those two transports rely on instead).
|
||||||
arch_pubkey_hex: Option<String>,
|
arch_pubkey_hex: Option<String>,
|
||||||
reachable: bool,
|
reachable: bool,
|
||||||
|
/// Unix time of the last announce heard from this peer over the air.
|
||||||
|
/// In-memory only (a persisted value would be stale by definition) —
|
||||||
|
/// `0` after a restart until the peer re-announces.
|
||||||
|
last_advert_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key,
|
/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key,
|
||||||
@ -245,6 +265,11 @@ pub struct ReticulumLink {
|
|||||||
/// matching `resource_progress`/`resource_sent`/`resource_failed` events
|
/// matching `resource_progress`/`resource_sent`/`resource_failed` events
|
||||||
/// back to a log line; sends are fire-and-forget (see `send_resource`).
|
/// back to a log line; sends are fire-and-forget (see `send_resource`).
|
||||||
resource_id_counter: u64,
|
resource_id_counter: u64,
|
||||||
|
/// Set when the daemon's RPC socket closes or its process exits. Once
|
||||||
|
/// true, `try_recv_frame` returns an error so the session loop tears
|
||||||
|
/// down and the outer reconnect loop respawns the daemon — without this
|
||||||
|
/// a dead daemon was invisible until the 30-minute RX-stall watchdog.
|
||||||
|
daemon_gone: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReticulumLink {
|
impl ReticulumLink {
|
||||||
@ -269,6 +294,7 @@ impl ReticulumLink {
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
our_ed_pubkey_hex: Option<&str>,
|
our_ed_pubkey_hex: Option<&str>,
|
||||||
our_x25519_pubkey_hex: Option<&str>,
|
our_x25519_pubkey_hex: Option<&str>,
|
||||||
|
display_name: Option<&str>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
probe_rnode(path)
|
probe_rnode(path)
|
||||||
.await
|
.await
|
||||||
@ -278,6 +304,7 @@ impl ReticulumLink {
|
|||||||
data_dir,
|
data_dir,
|
||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
|
display_name,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -290,6 +317,7 @@ impl ReticulumLink {
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
our_ed_pubkey_hex: Option<&str>,
|
our_ed_pubkey_hex: Option<&str>,
|
||||||
our_x25519_pubkey_hex: Option<&str>,
|
our_x25519_pubkey_hex: Option<&str>,
|
||||||
|
display_name: Option<&str>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind);
|
let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind);
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
@ -302,6 +330,7 @@ impl ReticulumLink {
|
|||||||
data_dir,
|
data_dir,
|
||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
|
display_name,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -313,6 +342,7 @@ impl ReticulumLink {
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
our_ed_pubkey_hex: Option<&str>,
|
our_ed_pubkey_hex: Option<&str>,
|
||||||
our_x25519_pubkey_hex: Option<&str>,
|
our_x25519_pubkey_hex: Option<&str>,
|
||||||
|
display_name: Option<&str>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
!targets.is_empty(),
|
!targets.is_empty(),
|
||||||
@ -323,6 +353,7 @@ impl ReticulumLink {
|
|||||||
data_dir,
|
data_dir,
|
||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
|
display_name,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@ -332,6 +363,7 @@ impl ReticulumLink {
|
|||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
our_ed_pubkey_hex: Option<&str>,
|
our_ed_pubkey_hex: Option<&str>,
|
||||||
our_x25519_pubkey_hex: Option<&str>,
|
our_x25519_pubkey_hex: Option<&str>,
|
||||||
|
display_name: Option<&str>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// Keep the RPC socket under the archipelago-owned data dir (not the
|
// Keep the RPC socket under the archipelago-owned data dir (not the
|
||||||
// shared system temp dir) so its access is bounded by the same
|
// shared system temp dir) so its access is bounded by the same
|
||||||
@ -379,6 +411,7 @@ impl ReticulumLink {
|
|||||||
&identity_key,
|
&identity_key,
|
||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
|
display_name,
|
||||||
);
|
);
|
||||||
cmd.env("TMPDIR", &tmp_dir);
|
cmd.env("TMPDIR", &tmp_dir);
|
||||||
let child = cmd
|
let child = cmd
|
||||||
@ -450,6 +483,7 @@ impl ReticulumLink {
|
|||||||
peers_file: runtime_dir.join("peers.json"),
|
peers_file: runtime_dir.join("peers.json"),
|
||||||
inbound: std::collections::VecDeque::new(),
|
inbound: std::collections::VecDeque::new(),
|
||||||
resource_id_counter: 0,
|
resource_id_counter: 0,
|
||||||
|
daemon_gone: false,
|
||||||
};
|
};
|
||||||
link.load_persisted_peers();
|
link.load_persisted_peers();
|
||||||
Ok(link)
|
Ok(link)
|
||||||
@ -472,21 +506,53 @@ impl ReticulumLink {
|
|||||||
};
|
};
|
||||||
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
|
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
|
||||||
self.prefix_to_hash.insert(prefix, hash);
|
self.prefix_to_hash.insert(prefix, hash);
|
||||||
|
// Heal names persisted by pre-2026-07-28 builds, which could
|
||||||
|
// store a raw `ARCHY:…` identity blob as the display name (seen
|
||||||
|
// live on archi-dev-box). Blob-only announces assert no name, so
|
||||||
|
// nothing would ever overwrite it — swap in the placeholder.
|
||||||
|
let display_name = if p.display_name.starts_with("ARCHY:") {
|
||||||
|
format!("Reticulum {}", hex::encode(&hash[..4]))
|
||||||
|
} else {
|
||||||
|
p.display_name
|
||||||
|
};
|
||||||
self.peers.insert(
|
self.peers.insert(
|
||||||
hash,
|
hash,
|
||||||
ReticulumPeer {
|
ReticulumPeer {
|
||||||
dest_hash: hash,
|
dest_hash: hash,
|
||||||
display_name: p.display_name,
|
display_name,
|
||||||
arch_pubkey_hex: p.arch_pubkey_hex,
|
arch_pubkey_hex: p.arch_pubkey_hex,
|
||||||
// Reachability is a live property, not a persisted fact —
|
// Reachability is a live property, not a persisted fact —
|
||||||
// start conservative and let the first real event refresh it.
|
// start conservative and let the first real event refresh it.
|
||||||
reachable: false,
|
reachable: false,
|
||||||
|
last_advert_at: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
info!(count = self.peers.len(), "Loaded persisted Reticulum peers");
|
info!(count = self.peers.len(), "Loaded persisted Reticulum peers");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a caller-supplied 6-byte routing prefix to a full 16-byte RNS
|
||||||
|
/// destination hash. Two shapes arrive here depending on how the contact
|
||||||
|
/// record was born (observed live 2026-07-28, image-to-merged-contact):
|
||||||
|
/// 1. the peer's RNS dest-hash prefix (contacts created from an RNS
|
||||||
|
/// announce) — direct `prefix_to_hash` hit;
|
||||||
|
/// 2. the peer's Archipelago ed25519 pubkey prefix (radio twins bound
|
||||||
|
/// via the ARCHY announce blob store the arch key as `pubkey_hex`) —
|
||||||
|
/// no dest-hash match possible, so fall back to scanning peers whose
|
||||||
|
/// announce-bound `arch_pubkey_hex` starts with the prefix.
|
||||||
|
fn resolve_dest_hash(&self, prefix: &[u8; 6]) -> Option<[u8; 16]> {
|
||||||
|
if let Some(hash) = self.prefix_to_hash.get(prefix) {
|
||||||
|
return Some(*hash);
|
||||||
|
}
|
||||||
|
let hex_prefix = hex::encode(prefix);
|
||||||
|
self.peers.values().find_map(|p| {
|
||||||
|
p.arch_pubkey_hex
|
||||||
|
.as_deref()
|
||||||
|
.filter(|arch| arch.starts_with(&hex_prefix))
|
||||||
|
.map(|_| p.dest_hash)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort sync write of the current peer table — called after any
|
/// Best-effort sync write of the current peer table — called after any
|
||||||
/// insert that adds/renames a peer. Infrequent (announces/first-contact,
|
/// insert that adds/renames a peer. Infrequent (announces/first-contact,
|
||||||
/// not per-message) so a blocking write here is a fine trade for keeping
|
/// not per-message) so a blocking write here is a fine trade for keeping
|
||||||
@ -533,10 +599,12 @@ impl ReticulumLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
|
||||||
// The daemon's display_name is fixed at spawn time (CLI arg); changing
|
// Live rename: the daemon's `set_name` verb updates the LXMF delivery
|
||||||
// it live would require an RPC verb we haven't added. Track locally so
|
// destination's display_name and re-announces, so peers pick the new
|
||||||
// `advert_name()` reflects the caller's intent even though the
|
// name up on their next announce receipt. Also tracked locally so
|
||||||
// RNS-visible name doesn't change until the daemon restarts.
|
// `advert_name()` reflects it immediately.
|
||||||
|
self.send_rpc(serde_json::json!({"cmd": "set_name", "name": name}))
|
||||||
|
.await?;
|
||||||
self.display_name = Some(name.to_string());
|
self.display_name = Some(name.to_string());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -562,9 +630,7 @@ impl ReticulumLink {
|
|||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let dest_hash = self
|
let dest_hash = self
|
||||||
.prefix_to_hash
|
.resolve_dest_hash(dest_pubkey_prefix)
|
||||||
.get(dest_pubkey_prefix)
|
|
||||||
.copied()
|
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||||
@ -606,9 +672,7 @@ impl ReticulumLink {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
let dest_hash = self
|
let dest_hash = self
|
||||||
.prefix_to_hash
|
.resolve_dest_hash(dest_pubkey_prefix)
|
||||||
.get(dest_pubkey_prefix)
|
|
||||||
.copied()
|
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||||
@ -638,9 +702,7 @@ impl ReticulumLink {
|
|||||||
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
|
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
let dest_hash = self
|
let dest_hash = self
|
||||||
.prefix_to_hash
|
.resolve_dest_hash(dest_pubkey_prefix)
|
||||||
.get(dest_pubkey_prefix)
|
|
||||||
.copied()
|
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||||
@ -685,7 +747,7 @@ impl ReticulumLink {
|
|||||||
.map(|p| ParsedContact {
|
.map(|p| ParsedContact {
|
||||||
public_key_hex: hex::encode(p.dest_hash),
|
public_key_hex: hex::encode(p.dest_hash),
|
||||||
advert_name: p.display_name.clone(),
|
advert_name: p.display_name.clone(),
|
||||||
last_advert: 0,
|
last_advert: p.last_advert_at as u32,
|
||||||
// Deliberately not 1 ("friend"/meshcore type), so the
|
// Deliberately not 1 ("friend"/meshcore type), so the
|
||||||
// meshcore-only auto-heal `reset_contact_path` loop in
|
// meshcore-only auto-heal `reset_contact_path` loop in
|
||||||
// `refresh_contacts` (session.rs) skips these — RNS does its
|
// `refresh_contacts` (session.rs) skips these — RNS does its
|
||||||
@ -718,6 +780,12 @@ impl ReticulumLink {
|
|||||||
|
|
||||||
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
|
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
|
||||||
self.drain_events().await;
|
self.drain_events().await;
|
||||||
|
if self.daemon_gone {
|
||||||
|
// Surface the dead daemon as a hard error so run_mesh_session
|
||||||
|
// bails and the outer reconnect loop respawns it, instead of
|
||||||
|
// idling on an empty queue until the RX-stall watchdog fires.
|
||||||
|
anyhow::bail!("reticulum-daemon is gone (process exited or RPC socket closed)");
|
||||||
|
}
|
||||||
Ok(self.inbound.pop_front())
|
Ok(self.inbound.pop_front())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -743,6 +811,15 @@ impl ReticulumLink {
|
|||||||
/// Drain any buffered daemon events (non-blocking) and translate them into
|
/// Drain any buffered daemon events (non-blocking) and translate them into
|
||||||
/// peer-table updates / synthetic InboundFrames.
|
/// peer-table updates / synthetic InboundFrames.
|
||||||
async fn drain_events(&mut self) {
|
async fn drain_events(&mut self) {
|
||||||
|
// A daemon that died without closing the socket cleanly (SIGKILL,
|
||||||
|
// OOM) leaves the socket readable-with-EOF or just silent — poll the
|
||||||
|
// child's exit status too so death is never mistaken for quiet.
|
||||||
|
if !self.daemon_gone {
|
||||||
|
if let Ok(Some(status)) = self.child.try_wait() {
|
||||||
|
warn!(%status, "reticulum-daemon process exited");
|
||||||
|
self.daemon_gone = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
loop {
|
loop {
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
let read =
|
let read =
|
||||||
@ -750,10 +827,16 @@ impl ReticulumLink {
|
|||||||
.await;
|
.await;
|
||||||
let n = match read {
|
let n = match read {
|
||||||
Ok(Ok(n)) => n,
|
Ok(Ok(n)) => n,
|
||||||
_ => break, // timeout (no data) or read error — stop draining
|
Ok(Err(e)) => {
|
||||||
|
warn!("Reticulum daemon RPC read failed: {}", e);
|
||||||
|
self.daemon_gone = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(_) => break, // timeout — no data buffered
|
||||||
};
|
};
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
warn!("Reticulum daemon RPC connection closed");
|
warn!("Reticulum daemon RPC connection closed");
|
||||||
|
self.daemon_gone = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let Ok(ev) = serde_json::from_str::<Value>(line.trim()) else {
|
let Ok(ev) = serde_json::from_str::<Value>(line.trim()) else {
|
||||||
@ -775,6 +858,23 @@ impl ReticulumLink {
|
|||||||
};
|
};
|
||||||
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
|
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
|
||||||
self.prefix_to_hash.insert(prefix, hash);
|
self.prefix_to_hash.insert(prefix, hash);
|
||||||
|
// Current daemons decode the LXMF announce app_data themselves
|
||||||
|
// and hand us clean fields: `display_name` (LXMF-standard
|
||||||
|
// msgpack name, Sideband-interoperable) and `archy_blob` (the
|
||||||
|
// `ARCHY:n:` identity string, carried as an extra msgpack list
|
||||||
|
// element stock clients ignore). The raw `app_data` text path
|
||||||
|
// below remains for announces from pre-upgrade archy nodes,
|
||||||
|
// whose app_data was EITHER the blob OR a bare-utf8 name.
|
||||||
|
let explicit_name = ev
|
||||||
|
.get("display_name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let explicit_blob = ev
|
||||||
|
.get("archy_blob")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
let app_data_text = ev
|
let app_data_text = ev
|
||||||
.get("app_data")
|
.get("app_data")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@ -794,12 +894,19 @@ impl ReticulumLink {
|
|||||||
// now carry the same `arch_pubkey_hex`, instead of relying on
|
// now carry the same `arch_pubkey_hex`, instead of relying on
|
||||||
// `bind_federation_twins`'s advert_name matching, which never
|
// `bind_federation_twins`'s advert_name matching, which never
|
||||||
// matches here — see `display_name` below.
|
// matches here — see `display_name` below.
|
||||||
let parsed_identity = app_data_text
|
let legacy_identity = app_data_text
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(protocol::parse_identity_broadcast);
|
.and_then(protocol::parse_identity_broadcast);
|
||||||
let is_identity_blob = parsed_identity.is_some();
|
let is_legacy_blob = legacy_identity.is_some();
|
||||||
if is_identity_blob {
|
let identity_blob_text =
|
||||||
let text = app_data_text.clone().unwrap();
|
explicit_blob.or_else(|| app_data_text.clone().filter(|_| is_legacy_blob));
|
||||||
|
let parsed_identity = identity_blob_text
|
||||||
|
.as_deref()
|
||||||
|
.and_then(protocol::parse_identity_broadcast);
|
||||||
|
if let Some(text) = identity_blob_text
|
||||||
|
.as_deref()
|
||||||
|
.filter(|_| parsed_identity.is_some())
|
||||||
|
{
|
||||||
let mut data = Vec::with_capacity(7 + text.len());
|
let mut data = Vec::with_capacity(7 + text.len());
|
||||||
data.push(0); // channel index — unused by the identity path
|
data.push(0); // channel index — unused by the identity path
|
||||||
data.extend_from_slice(&prefix);
|
data.extend_from_slice(&prefix);
|
||||||
@ -812,23 +919,31 @@ impl ReticulumLink {
|
|||||||
}
|
}
|
||||||
let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
|
let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
|
||||||
|
|
||||||
let display_name = app_data_text
|
let announced_name =
|
||||||
.filter(|_| !is_identity_blob)
|
pick_announced_name(explicit_name, app_data_text, is_legacy_blob);
|
||||||
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4])));
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
self.peers
|
self.peers
|
||||||
.entry(hash)
|
.entry(hash)
|
||||||
.and_modify(|p| {
|
.and_modify(|p| {
|
||||||
p.display_name = display_name.clone();
|
if let Some(name) = announced_name.clone() {
|
||||||
|
p.display_name = name;
|
||||||
|
}
|
||||||
p.reachable = true;
|
p.reachable = true;
|
||||||
|
p.last_advert_at = now;
|
||||||
if arch_pubkey_hex.is_some() {
|
if arch_pubkey_hex.is_some() {
|
||||||
p.arch_pubkey_hex = arch_pubkey_hex.clone();
|
p.arch_pubkey_hex = arch_pubkey_hex.clone();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.or_insert(ReticulumPeer {
|
.or_insert_with(|| ReticulumPeer {
|
||||||
dest_hash: hash,
|
dest_hash: hash,
|
||||||
display_name,
|
display_name: announced_name
|
||||||
|
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4]))),
|
||||||
arch_pubkey_hex,
|
arch_pubkey_hex,
|
||||||
reachable: true,
|
reachable: true,
|
||||||
|
last_advert_at: now,
|
||||||
});
|
});
|
||||||
self.persist_peers();
|
self.persist_peers();
|
||||||
}
|
}
|
||||||
@ -844,16 +959,24 @@ impl ReticulumLink {
|
|||||||
// A peer that messages us without ever announcing still needs
|
// A peer that messages us without ever announcing still needs
|
||||||
// to survive a restart — give it a placeholder name (the real
|
// to survive a restart — give it a placeholder name (the real
|
||||||
// one, if any, arrives via a later "announce" and overwrites
|
// one, if any, arrives via a later "announce" and overwrites
|
||||||
// this) so its routing entry alone doesn't get lost.
|
// this) so its routing entry alone doesn't get lost. An
|
||||||
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
|
// existing entry is proof of life too: mark it reachable so a
|
||||||
{
|
// restart-restored (reachable=false) peer that DMs us doesn't
|
||||||
e.insert(ReticulumPeer {
|
// stay red-dotted until its next announce.
|
||||||
dest_hash: source_hash,
|
match self.peers.entry(source_hash) {
|
||||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
std::collections::hash_map::Entry::Vacant(e) => {
|
||||||
arch_pubkey_hex: None,
|
e.insert(ReticulumPeer {
|
||||||
reachable: true,
|
dest_hash: source_hash,
|
||||||
});
|
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||||
self.persist_peers();
|
arch_pubkey_hex: None,
|
||||||
|
reachable: true,
|
||||||
|
last_advert_at: 0,
|
||||||
|
});
|
||||||
|
self.persist_peers();
|
||||||
|
}
|
||||||
|
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||||
|
e.get_mut().reachable = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A stock LXMF client (Sideband/NomadNet — not an archy peer)
|
// A stock LXMF client (Sideband/NomadNet — not an archy peer)
|
||||||
@ -924,23 +1047,37 @@ impl ReticulumLink {
|
|||||||
.push_back(build_synthetic_frame(&prefix, &content));
|
.push_back(build_synthetic_frame(&prefix, &content));
|
||||||
}
|
}
|
||||||
Some("resource_recv") => {
|
Some("resource_recv") => {
|
||||||
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
|
let source_hex = ev
|
||||||
return;
|
.get("source_hash")
|
||||||
};
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
let Ok(source_hash) = parse_hash16(source_hex) else {
|
let Ok(source_hash) = parse_hash16(source_hex) else {
|
||||||
|
// An empty source_hash means the sender's link wasn't
|
||||||
|
// identified (pre-identify daemon build on the far end) —
|
||||||
|
// the blob is undeliverable without attribution, but say
|
||||||
|
// so instead of vanishing it.
|
||||||
|
warn!(
|
||||||
|
source = source_hex,
|
||||||
|
"Dropping inbound Reticulum resource without valid source identity"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
|
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
|
||||||
self.prefix_to_hash.insert(prefix, source_hash);
|
self.prefix_to_hash.insert(prefix, source_hash);
|
||||||
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
|
match self.peers.entry(source_hash) {
|
||||||
{
|
std::collections::hash_map::Entry::Vacant(e) => {
|
||||||
e.insert(ReticulumPeer {
|
e.insert(ReticulumPeer {
|
||||||
dest_hash: source_hash,
|
dest_hash: source_hash,
|
||||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||||
arch_pubkey_hex: None,
|
arch_pubkey_hex: None,
|
||||||
reachable: true,
|
reachable: true,
|
||||||
});
|
last_advert_at: 0,
|
||||||
self.persist_peers();
|
});
|
||||||
|
self.persist_peers();
|
||||||
|
}
|
||||||
|
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||||
|
e.get_mut().reachable = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
let Some(data) = ev
|
let Some(data) = ev
|
||||||
@ -1089,8 +1226,10 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
|
|||||||
// ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART
|
// ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART
|
||||||
// bridge chip) treat a DTR/RTS transition on open as a reset signal, the
|
// bridge chip) treat a DTR/RTS transition on open as a reset signal, the
|
||||||
// same mechanism esptool uses to force bootloader entry. Deassert both
|
// same mechanism esptool uses to force bootloader entry. Deassert both
|
||||||
// and let the board settle before writing the probe, or the reboot eats
|
// before writing the probe. Boards behind a USB-UART bridge (CP2102 on
|
||||||
// the DETECT_RESP window below.
|
// the Heltec V3) get the reset pulse from the open() itself, before we
|
||||||
|
// can deassert anything — that case is handled by the boot-settle retry
|
||||||
|
// below.
|
||||||
let _ = port.set_dtr(false);
|
let _ = port.set_dtr(false);
|
||||||
let _ = port.set_rts(false);
|
let _ = port.set_rts(false);
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
@ -1109,26 +1248,81 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
|
|||||||
0x00,
|
0x00,
|
||||||
KISS_FEND,
|
KISS_FEND,
|
||||||
];
|
];
|
||||||
|
// Attempt 1: probe immediately. A board that did NOT reset on open (it
|
||||||
|
// was already up — e.g. a re-probe of a running RNode) answers in well
|
||||||
|
// under a second, so the fast path stays fast.
|
||||||
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
|
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
|
||||||
.await
|
.await
|
||||||
.context("RNode probe write timed out")?
|
.context("RNode probe write timed out")?
|
||||||
.context("RNode probe write failed")?;
|
.context("RNode probe write failed")?;
|
||||||
|
|
||||||
let mut buf = [0u8; 256];
|
let mut buf = [0u8; 256];
|
||||||
let mut seen = Vec::new();
|
let mut seen = Vec::new();
|
||||||
let deadline = tokio::time::Instant::now() + PROBE_READ_TIMEOUT;
|
if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await {
|
||||||
while tokio::time::Instant::now() < deadline {
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// No DETECT_RESP. If the open() power-cycled the board (verified live on
|
||||||
|
// a Heltec V3 RNode behind a CP2102: the ESP32 spends ~2.5-3s in boot
|
||||||
|
// ROM + app init and silently eats anything written meanwhile, so the
|
||||||
|
// first probe lands in the void), wait for its boot chatter to go quiet
|
||||||
|
// and probe once more with a fresh response window.
|
||||||
|
const BOOT_QUIET_WINDOW: Duration = Duration::from_millis(800);
|
||||||
|
const BOOT_SETTLE_MAX: Duration = Duration::from_secs(6);
|
||||||
|
let settle_deadline = tokio::time::Instant::now() + BOOT_SETTLE_MAX;
|
||||||
|
let mut last_data = tokio::time::Instant::now();
|
||||||
|
while tokio::time::Instant::now() < settle_deadline {
|
||||||
match tokio::time::timeout(Duration::from_millis(150), port.read(&mut buf)).await {
|
match tokio::time::timeout(Duration::from_millis(150), port.read(&mut buf)).await {
|
||||||
Ok(Ok(n)) if n > 0 => {
|
Ok(Ok(n)) if n > 0 => {
|
||||||
seen.extend_from_slice(&buf[..n]);
|
seen.extend_from_slice(&buf[..n]);
|
||||||
|
// A late DETECT_RESP to the first write still counts.
|
||||||
if contains_detect_resp(&seen) {
|
if contains_detect_resp(&seen) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
last_data = tokio::time::Instant::now();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if last_data.elapsed() >= BOOT_QUIET_WINDOW {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
|
||||||
|
.await
|
||||||
|
.context("RNode probe rewrite timed out")?
|
||||||
|
.context("RNode probe rewrite failed")?;
|
||||||
|
seen.clear();
|
||||||
|
if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
anyhow::bail!(
|
||||||
|
"No RNode DETECT_RESP within {:?} (incl. post-boot-settle retry)",
|
||||||
|
PROBE_READ_TIMEOUT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read from `port` for up to `window`, accumulating into `seen`; true once
|
||||||
|
/// the KISS DETECT_RESP sequence shows up anywhere in the stream.
|
||||||
|
async fn await_detect_resp(
|
||||||
|
port: &serial2_tokio::SerialPort,
|
||||||
|
buf: &mut [u8],
|
||||||
|
seen: &mut Vec<u8>,
|
||||||
|
window: Duration,
|
||||||
|
) -> bool {
|
||||||
|
let deadline = tokio::time::Instant::now() + window;
|
||||||
|
while tokio::time::Instant::now() < deadline {
|
||||||
|
match tokio::time::timeout(Duration::from_millis(150), port.read(buf)).await {
|
||||||
|
Ok(Ok(n)) if n > 0 => {
|
||||||
|
seen.extend_from_slice(&buf[..n]);
|
||||||
|
if contains_detect_resp(seen) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => continue,
|
_ => continue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
anyhow::bail!("No RNode DETECT_RESP within {:?}", PROBE_READ_TIMEOUT)
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look for the `[FEND, CMD_DETECT, DETECT_RESP]` sequence anywhere in the
|
/// Look for the `[FEND, CMD_DETECT, DETECT_RESP]` sequence anywhere in the
|
||||||
@ -1138,6 +1332,33 @@ fn contains_detect_resp(buf: &[u8]) -> bool {
|
|||||||
.any(|w| w == [KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP])
|
.any(|w| w == [KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The display name an announce actually asserted, if any.
|
||||||
|
///
|
||||||
|
/// Precedence: the daemon-decoded LXMF display name (`display_name` event
|
||||||
|
/// field), then — legacy peers only — bare-utf8 app_data that wasn't an
|
||||||
|
/// identity blob. The bare-utf8 fallback must actually look like text:
|
||||||
|
/// lossy-decoded msgpack (a new-format announce whose name the daemon failed
|
||||||
|
/// to decode) is full of U+FFFD/control chars and would otherwise become a
|
||||||
|
/// mojibake display name. `None` (e.g. a blob-only legacy announce) means
|
||||||
|
/// "no name asserted" and must NOT stomp a previously-learned name.
|
||||||
|
fn pick_announced_name(
|
||||||
|
explicit_name: Option<String>,
|
||||||
|
app_data_text: Option<String>,
|
||||||
|
is_legacy_blob: bool,
|
||||||
|
) -> Option<String> {
|
||||||
|
explicit_name
|
||||||
|
// A legacy blob-only announce utf8-decodes cleanly, so LXMF's
|
||||||
|
// display_name_from_app_data hands the daemon the ENTIRE `ARCHY:…`
|
||||||
|
// string as a "name" — seen live from a pre-upgrade Framework PT.
|
||||||
|
// An identity blob is never a display name.
|
||||||
|
.filter(|s| !s.starts_with("ARCHY:"))
|
||||||
|
.or_else(|| {
|
||||||
|
app_data_text
|
||||||
|
.filter(|_| !is_legacy_blob)
|
||||||
|
.filter(|s| !s.chars().any(|c| c.is_control() || c == '\u{FFFD}'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for ReticulumLink {
|
impl Drop for ReticulumLink {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
// Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`).
|
// Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`).
|
||||||
@ -1154,6 +1375,40 @@ impl Drop for ReticulumLink {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn announced_name_precedence() {
|
||||||
|
// Daemon-decoded LXMF name always wins.
|
||||||
|
assert_eq!(
|
||||||
|
pick_announced_name(
|
||||||
|
Some("RNode Shaza".into()),
|
||||||
|
Some("ARCHY:2:aa:bb".into()),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
Some("RNode Shaza".to_string())
|
||||||
|
);
|
||||||
|
// Legacy bare-utf8 name (old daemon, no explicit field).
|
||||||
|
assert_eq!(
|
||||||
|
pick_announced_name(None, Some("zaza".into()), false),
|
||||||
|
Some("zaza".to_string())
|
||||||
|
);
|
||||||
|
// Legacy blob-only announce asserts NO name (must not stomp).
|
||||||
|
assert_eq!(
|
||||||
|
pick_announced_name(None, Some("ARCHY:2:aa:bb".into()), true),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Lossy-decoded msgpack must not become a mojibake name.
|
||||||
|
assert_eq!(
|
||||||
|
pick_announced_name(None, Some("\u{FFFD}\u{FFFD}Shaza\u{FFFD}".into()), false),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pick_announced_name(None, Some("has\u{1}ctl".into()), false),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Nothing at all.
|
||||||
|
assert_eq!(pick_announced_name(None, None, false), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_resp_found_in_kiss_stream() {
|
fn detect_resp_found_in_kiss_stream() {
|
||||||
let stream = [
|
let stream = [
|
||||||
|
|||||||
@ -58,11 +58,20 @@ impl MeshcoreDevice {
|
|||||||
path
|
path
|
||||||
))?;
|
))?;
|
||||||
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
|
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
|
||||||
// boards reset on a DTR/RTS transition, so deassert both and settle
|
// boards (and CP2102/CH340-bridged boards wired for Arduino-style
|
||||||
// before the handshake below.
|
// auto-reset) reset on a DTR/RTS transition, so deassert both and
|
||||||
|
// settle before the handshake below. 300ms is nowhere near a real
|
||||||
|
// firmware boot time (LoRa radio init alone can take longer) —
|
||||||
|
// confirmed live 2026-07-23: with every one of Reticulum/Meshcore/
|
||||||
|
// Meshtastic's open() doing this same reset, a single auto-detect
|
||||||
|
// cycle trying multiple protocols in sequence kept re-resetting the
|
||||||
|
// board before it ever finished booting from the PREVIOUS attempt's
|
||||||
|
// reset, on both a Heltec V3 and V4, regardless of firmware family —
|
||||||
|
// a self-sustaining "never finishes booting" loop with a boot-time
|
||||||
|
// root cause hiding behind what looked like a per-protocol failure.
|
||||||
let _ = port.set_dtr(false);
|
let _ = port.set_dtr(false);
|
||||||
let _ = port.set_rts(false);
|
let _ = port.set_rts(false);
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
tokio::time::sleep(Duration::from_millis(2000)).await;
|
||||||
|
|
||||||
info!(path = %path, baud = BAUD_RATE, "Opened serial port");
|
info!(path = %path, baud = BAUD_RATE, "Opened serial port");
|
||||||
|
|
||||||
@ -547,14 +556,40 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
|
|||||||
|
|
||||||
/// Scan for serial devices that could be Meshcore radios.
|
/// Scan for serial devices that could be Meshcore radios.
|
||||||
/// Returns paths to existing serial device files.
|
/// Returns paths to existing serial device files.
|
||||||
|
///
|
||||||
|
/// Dedupes by canonical (symlink-resolved) target: `/dev/mesh-radio` is a
|
||||||
|
/// stable udev symlink to whatever `/dev/ttyUSB*`/`/dev/ttyACM*` node the
|
||||||
|
/// primary radio currently enumerates as, so both names always pointed at
|
||||||
|
/// the same candidate list entry and both passed this scan — confirmed live
|
||||||
|
/// 2026-07-23, this made an already-connected, working radio (connected via
|
||||||
|
/// its `/dev/mesh-radio` alias) simultaneously appear as a second, separate
|
||||||
|
/// "detected but unclaimed" device under its raw `/dev/ttyUSBn` name. The
|
||||||
|
/// hot-swap UI's active-session guard compares path strings, so it didn't
|
||||||
|
/// recognize the two aliases as the same port, showed the "device detected"
|
||||||
|
/// modal for a radio that was already set up, and probing it there opened
|
||||||
|
/// (and DTR/RTS-reset) the exact port the live session was mid-conversation
|
||||||
|
/// with — a continuous, UI-driven reset loop that only ran while that view
|
||||||
|
/// was open (matches the reported "stops when I leave, resumes when I come
|
||||||
|
/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the
|
||||||
|
/// dedup and is what's reported when both alias and target are present.
|
||||||
|
/// (Independently re-discovered and fixed on main 2026-07-26 — both sides
|
||||||
|
/// of the 2026-07-28 merge carried an equivalent implementation.)
|
||||||
pub async fn detect_serial_devices() -> Vec<String> {
|
pub async fn detect_serial_devices() -> Vec<String> {
|
||||||
let mut devices = Vec::new();
|
let mut devices = Vec::new();
|
||||||
|
let mut seen_real_paths = std::collections::HashSet::new();
|
||||||
for path in SERIAL_CANDIDATES {
|
for path in SERIAL_CANDIDATES {
|
||||||
if tokio::fs::metadata(path).await.is_ok() {
|
if tokio::fs::metadata(path).await.is_ok() {
|
||||||
if likely_non_mesh_serial_device(path) {
|
if likely_non_mesh_serial_device(path) {
|
||||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let real_path = tokio::fs::canonicalize(path)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from(path));
|
||||||
|
if !seen_real_paths.insert(real_path.clone()) {
|
||||||
|
debug!(path = %path, real_path = %real_path.display(), "Skipping duplicate alias for an already-listed device");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
devices.push(path.to_string());
|
devices.push(path.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -585,12 +620,23 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
|
|||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for path in detect_serial_devices().await {
|
for path in detect_serial_devices().await {
|
||||||
let usb = usb_info_for_tty(&path).await;
|
let usb = usb_info_for_tty(&path).await;
|
||||||
let plugged_at = tokio::fs::metadata(&path)
|
// Birth time (btime), falling back to inode-change time (ctime) —
|
||||||
.await
|
// NOT mtime: a tty node's mtime bumps on every open()/write, so with
|
||||||
.ok()
|
// mtime here each probe/session open minted a "new" plugged_at, the
|
||||||
.and_then(|m| m.modified().ok())
|
// UI's (path, plugged_at) dismissal key never matched again, and the
|
||||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
// setup modal re-fired forever on a device that never left the port
|
||||||
.map(|d| d.as_secs());
|
// (observed live on archi-dev-box 2026-07-28). btime/ctime only
|
||||||
|
// change when udev (re)creates/chowns the node — i.e. on real plugs.
|
||||||
|
let plugged_at = tokio::fs::metadata(&path).await.ok().and_then(|m| {
|
||||||
|
m.created()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.or_else(|| {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
u64::try_from(m.ctime()).ok()
|
||||||
|
})
|
||||||
|
});
|
||||||
out.push(DetectedDeviceInfo {
|
out.push(DetectedDeviceInfo {
|
||||||
path,
|
path,
|
||||||
vid: usb.0,
|
vid: usb.0,
|
||||||
|
|||||||
@ -134,6 +134,7 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
|
|||||||
for onion in &unique_onions {
|
for onion in &unique_onions {
|
||||||
let fips_npub = crate::federation::fips_npub_for_onion(data_dir, onion).await;
|
let fips_npub = crate::federation::fips_npub_for_onion(data_dir, onion).await;
|
||||||
match sync_single_peer(
|
match sync_single_peer(
|
||||||
|
data_dir,
|
||||||
fips_npub.as_deref(),
|
fips_npub.as_deref(),
|
||||||
&store,
|
&store,
|
||||||
onion,
|
onion,
|
||||||
@ -173,6 +174,7 @@ pub async fn sync_with_peers(data_dir: &Path, peer_onions: &[String]) -> Result<
|
|||||||
/// Sync with a single peer: pull their messages and push ours.
|
/// Sync with a single peer: pull their messages and push ours.
|
||||||
/// Each HTTP call picks FIPS when a npub is known, otherwise Tor.
|
/// Each HTTP call picks FIPS when a npub is known, otherwise Tor.
|
||||||
async fn sync_single_peer(
|
async fn sync_single_peer(
|
||||||
|
data_dir: &Path,
|
||||||
fips_npub: Option<&str>,
|
fips_npub: Option<&str>,
|
||||||
store: &crate::network::dwn_store::DwnStore,
|
store: &crate::network::dwn_store::DwnStore,
|
||||||
onion: &str,
|
onion: &str,
|
||||||
@ -186,6 +188,8 @@ async fn sync_single_peer(
|
|||||||
let (health_resp, _) = PeerRequest::new(fips_npub, onion, "/dwn/health")
|
let (health_resp, _) = PeerRequest::new(fips_npub, onion, "/dwn/health")
|
||||||
.service(crate::settings::transport::PeerService::Federation)
|
.service(crate::settings::transport::PeerService::Federation)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
|
.record_transport(data_dir)
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
.context("Peer DWN unreachable")?;
|
.context("Peer DWN unreachable")?;
|
||||||
@ -211,6 +215,8 @@ async fn sync_single_peer(
|
|||||||
let (pull_res, _) = PeerRequest::new(fips_npub, onion, "/dwn")
|
let (pull_res, _) = PeerRequest::new(fips_npub, onion, "/dwn")
|
||||||
.service(crate::settings::transport::PeerService::Federation)
|
.service(crate::settings::transport::PeerService::Federation)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
|
.record_transport(data_dir)
|
||||||
.send_json(&pull_body)
|
.send_json(&pull_body)
|
||||||
.await
|
.await
|
||||||
.context("Failed to query peer DWN")?;
|
.context("Failed to query peer DWN")?;
|
||||||
@ -269,6 +275,8 @@ async fn sync_single_peer(
|
|||||||
match PeerRequest::new(fips_npub, onion, "/dwn")
|
match PeerRequest::new(fips_npub, onion, "/dwn")
|
||||||
.service(crate::settings::transport::PeerService::Federation)
|
.service(crate::settings::transport::PeerService::Federation)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
|
.record_transport(data_dir)
|
||||||
.send_json(&push_body)
|
.send_json(&push_body)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@ -342,6 +342,9 @@ pub async fn send_to_peer(
|
|||||||
signing_key: Option<&ed25519_dalek::SigningKey>,
|
signing_key: Option<&ed25519_dalek::SigningKey>,
|
||||||
recipient_pubkey: Option<&str>,
|
recipient_pubkey: Option<&str>,
|
||||||
from_name: 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<()> {
|
) -> Result<()> {
|
||||||
validate_onion(onion)?;
|
validate_onion(onion)?;
|
||||||
|
|
||||||
@ -370,24 +373,24 @@ pub async fn send_to_peer(
|
|||||||
body["from_name"] = serde_json::Value::String(name.to_string());
|
body["from_name"] = serde_json::Value::String(name.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (resp, transport) =
|
let mut req =
|
||||||
crate::fips::dial::PeerRequest::new(fips_npub, onion, "/archipelago/node-message")
|
crate::fips::dial::PeerRequest::new(fips_npub, onion, "/archipelago/node-message")
|
||||||
.service(crate::settings::transport::PeerService::Messaging)
|
.service(crate::settings::transport::PeerService::Messaging)
|
||||||
.timeout(std::time::Duration::from_secs(60))
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
.send_json(&body)
|
.fips_timeout(std::time::Duration::from_secs(8));
|
||||||
.await
|
if let Some(dir) = record_data_dir {
|
||||||
.map_err(|e| {
|
req = req.record_transport(dir);
|
||||||
let msg = e.to_string();
|
}
|
||||||
if msg.contains("connection refused") || msg.contains("Connection refused") {
|
let (resp, transport) = req.send_json(&body).await.map_err(|e| {
|
||||||
anyhow::anyhow!(
|
let msg = e.to_string();
|
||||||
"Peer unreachable. Check Tor (127.0.0.1:9050) and FIPS daemon status."
|
if msg.contains("connection refused") || msg.contains("Connection refused") {
|
||||||
)
|
anyhow::anyhow!("Peer unreachable. Check Tor (127.0.0.1:9050) and FIPS daemon status.")
|
||||||
} else if msg.contains("timeout") || msg.contains("timed out") {
|
} else if msg.contains("timeout") || msg.contains("timed out") {
|
||||||
anyhow::anyhow!("Connection timed out. The peer may be offline.")
|
anyhow::anyhow!("Connection timed out. The peer may be offline.")
|
||||||
} else {
|
} else {
|
||||||
anyhow::anyhow!("Failed to send: {}", msg)
|
anyhow::anyhow!("Failed to send: {}", msg)
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
@ -410,6 +413,7 @@ 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
|
// circuit that hasn't answered /health in 12s is "offline" for UI
|
||||||
// purposes; the old 30s made the Connected Nodes probes crawl.
|
// purposes; the old 30s made the Connected Nodes probes crawl.
|
||||||
.timeout(std::time::Duration::from_secs(12))
|
.timeout(std::time::Duration::from_secs(12))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(4))
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@ -294,8 +294,13 @@ impl Server {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Auto-enable mesh if a radio is detected and no config exists yet
|
// Auto-enable mesh if a radio is detected and no config exists
|
||||||
if !mesh_config.enabled {
|
// yet. Only on a genuinely missing config: an existing file
|
||||||
|
// with enabled=false is an explicit operator decision (e.g.
|
||||||
|
// via mesh.configure) and force-re-enabling it on every boot
|
||||||
|
// made "disable mesh" impossible on any node with a radio
|
||||||
|
// plugged in.
|
||||||
|
if !mesh_config.enabled && !crate::mesh::config_file_exists(&data_dir) {
|
||||||
let devices = crate::mesh::detect_devices().await;
|
let devices = crate::mesh::detect_devices().await;
|
||||||
if !devices.is_empty() {
|
if !devices.is_empty() {
|
||||||
info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices);
|
info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices);
|
||||||
@ -433,8 +438,18 @@ impl Server {
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
// LAN transport (mDNS discovery)
|
// LAN transport (mDNS discovery). Advertise our FIPS npub in
|
||||||
let mut lan = crate::transport::lan::LanTransport::new(&did, &pubkey_hex, 5678);
|
// 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,
|
||||||
|
);
|
||||||
match lan.start(registry.clone()) {
|
match lan.start(registry.clone()) {
|
||||||
Ok(()) => info!("📡 LAN transport (mDNS) started"),
|
Ok(()) => info!("📡 LAN transport (mDNS) started"),
|
||||||
Err(e) => debug!("LAN transport init (non-fatal): {}", e),
|
Err(e) => debug!("LAN transport init (non-fatal): {}", e),
|
||||||
@ -753,12 +768,25 @@ impl Server {
|
|||||||
// (often flaky) global anchor's spanning tree to route to each
|
// (often flaky) global anchor's spanning tree to route to each
|
||||||
// other. For every peer the registry knows both a LAN address
|
// other. For every peer the registry knows both a LAN address
|
||||||
// AND a FIPS npub for, dial it on its FIPS UDP transport port
|
// AND a FIPS npub for, dial it on its FIPS UDP transport port
|
||||||
// (8668) at its LAN IP. This is FIPS's own transport over the
|
// at its LAN IP. This is FIPS's own transport over the
|
||||||
// LAN — NOT Tailscale, NOT the HTTP/LAN messaging port. Pure
|
// LAN — NOT Tailscale, NOT the HTTP/LAN messaging port. Pure
|
||||||
// FIPS. `fipsctl connect` is idempotent, so re-applying every
|
// FIPS. `fipsctl connect` is idempotent, so re-applying every
|
||||||
// tick just keeps the direct link warm; unknown/remote peers
|
// tick just keeps the direct link warm; unknown/remote peers
|
||||||
// (no LAN address) are left to the anchor as before.
|
// (no LAN address) are left to the anchor as before.
|
||||||
if let Some(reg) = fips_peer_registry.as_ref() {
|
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(®.all_peers().await);
|
let direct = crate::fips::anchors::lan_fips_anchors(®.all_peers().await);
|
||||||
if !direct.is_empty() {
|
if !direct.is_empty() {
|
||||||
let _ = crate::fips::anchors::apply(&direct).await;
|
let _ = crate::fips::anchors::apply(&direct).await;
|
||||||
@ -891,7 +919,7 @@ impl Server {
|
|||||||
// Post-onboarding auto-activation for archipelago-fips. Runs once
|
// Post-onboarding auto-activation for archipelago-fips. Runs once
|
||||||
// at startup: if fips_key is on disk, install /etc/fips/fips.yaml
|
// at startup: if fips_key is on disk, install /etc/fips/fips.yaml
|
||||||
// (schema-refreshed) and start the service. This removes the
|
// (schema-refreshed) and start the service. This removes the
|
||||||
// need for a user-facing "Activate" button — the node comes up
|
// need for a user-facing manual Start button — the node comes up
|
||||||
// with FIPS running whenever the seed has been onboarded. Also
|
// with FIPS running whenever the seed has been onboarded. Also
|
||||||
// self-heals legacy raw-byte fips.key files (load_fips_keys
|
// self-heals legacy raw-byte fips.key files (load_fips_keys
|
||||||
// rewrites them as bech32 nsec the first time they're read).
|
// rewrites them as bech32 nsec the first time they're read).
|
||||||
@ -940,14 +968,16 @@ impl Server {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
|
let unit = crate::fips::service::activation_unit().await;
|
||||||
|
if let Err(e) = crate::fips::service::activate(unit).await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"archipelago-fips activate failed on startup: {} — user can retry via fips.install RPC",
|
"FIPS activate failed on startup via {}: {} — user can retry via fips.install RPC",
|
||||||
|
unit,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tracing::info!("archipelago-fips auto-activated on startup");
|
tracing::info!("FIPS auto-activated on startup via {}", unit);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -988,8 +1018,55 @@ impl Server {
|
|||||||
main_addr,
|
main_addr,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// The mesh is IPv6-only: a phone reaching the node over its fips0
|
||||||
|
// ULA lands on port 80 over v6, where a 0.0.0.0 listener never
|
||||||
|
// answers — the UI was structurally unreachable over the mesh
|
||||||
|
// (RST -> ERR_CONNECTION_ABORTED, confirmed 2026-07-26: v4:80 = 200,
|
||||||
|
// v6:80 = refused). Mirror an IPv4-any main listener with a
|
||||||
|
// V6ONLY [::] socket on the same port — v6-only so it coexists
|
||||||
|
// with the v4 listener regardless of net.ipv6.bindv6only.
|
||||||
|
let v4_any_port = match main_addr {
|
||||||
|
SocketAddr::V4(v4) if v4.ip().is_unspecified() => Some(v4.port()),
|
||||||
|
_ => 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);
|
||||||
|
match bind_v6_only(v6_addr) {
|
||||||
|
Ok(listener) => {
|
||||||
|
info!("IPv6 web listener bound {} (mesh ULA reachable)", v6_addr);
|
||||||
|
Some(tokio::spawn(accept_loop(
|
||||||
|
self.api_handler.clone(),
|
||||||
|
listener,
|
||||||
|
active_connections.clone(),
|
||||||
|
false, // same semantics as the main listener
|
||||||
|
tx.subscribe(),
|
||||||
|
v6_addr,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"IPv6 web listener bind {} failed: {} — UI stays v4-only",
|
||||||
|
v6_addr, e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Peer listener: late-binding so we don't need an archipelago
|
// Peer listener: late-binding so we don't need an archipelago
|
||||||
// restart when fips0 comes up after onboarding.
|
// restart when fips0 comes up after onboarding.
|
||||||
|
// App UIs over the mesh: rootless podman's port forwarder binds
|
||||||
|
// 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.
|
||||||
|
let relay_task = tokio::spawn(app_port_v6_relay_loop(tx.subscribe()));
|
||||||
|
|
||||||
let peer_task = tokio::spawn(peer_late_bind_loop(
|
let peer_task = tokio::spawn(peer_late_bind_loop(
|
||||||
self.api_handler.clone(),
|
self.api_handler.clone(),
|
||||||
active_connections.clone(),
|
active_connections.clone(),
|
||||||
@ -1012,6 +1089,10 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = main_task.await;
|
let _ = main_task.await;
|
||||||
|
if let Some(t) = v6_task {
|
||||||
|
let _ = t.await;
|
||||||
|
}
|
||||||
|
relay_task.abort();
|
||||||
let _ = peer_task.await;
|
let _ = peer_task.await;
|
||||||
|
|
||||||
info!("Shutdown complete");
|
info!("Shutdown complete");
|
||||||
@ -1019,6 +1100,100 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bind a V6ONLY `[::]` TCP listener. V6ONLY is set explicitly so the
|
||||||
|
/// socket never claims the IPv4 side (which the main listener owns) —
|
||||||
|
/// without it, Linux hosts with `net.ipv6.bindv6only=0` would fail with
|
||||||
|
/// EADDRINUSE.
|
||||||
|
fn bind_v6_only(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
|
||||||
|
let socket = socket2::Socket::new(
|
||||||
|
socket2::Domain::IPV6,
|
||||||
|
socket2::Type::STREAM,
|
||||||
|
Some(socket2::Protocol::TCP),
|
||||||
|
)?;
|
||||||
|
socket.set_only_v6(true)?;
|
||||||
|
socket.set_reuse_address(true)?;
|
||||||
|
socket.set_nonblocking(true)?;
|
||||||
|
socket.bind(&addr.into())?;
|
||||||
|
socket.listen(1024)?;
|
||||||
|
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.
|
||||||
|
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();
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// ONLY bridge a port that a running app already answers on
|
||||||
|
// over IPv4. Binding [::]:port for an app that isn't
|
||||||
|
// installed is actively harmful: it makes that app's
|
||||||
|
// later install hit "address already in use", and the
|
||||||
|
// install's port-free step (`fuser -k <port>/tcp`) then
|
||||||
|
// kills THIS daemon, which holds the port — the exact
|
||||||
|
// cause of installs failing + apps vanishing on
|
||||||
|
// framework-pt 2026-07-27. No v4 listener → skip; the
|
||||||
|
// next rescan picks it up once the app is up.
|
||||||
|
let v4_up = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(300),
|
||||||
|
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|r| r.ok())
|
||||||
|
.is_some();
|
||||||
|
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 Ok(listener) = bind_v6_only(addr) else { continue };
|
||||||
|
bridged.insert(port);
|
||||||
|
debug!("v6 relay bridging [{fips_ip}]:{port} -> 127.0.0.1:{port}");
|
||||||
|
let mut rx = shutdown_rx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
accepted = listener.accept() => {
|
||||||
|
let Ok((mut inbound, _)) = accepted else { break };
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let Ok(mut outbound) = tokio::net::TcpStream::connect(
|
||||||
|
("127.0.0.1", port),
|
||||||
|
)
|
||||||
|
.await else { return };
|
||||||
|
let _ = tokio::io::copy_bidirectional(
|
||||||
|
&mut inbound,
|
||||||
|
&mut outbound,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ = rx.changed() => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = shutdown_rx.changed() => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Poll every 30s for `fips0`'s ULA; when it appears, bind the peer
|
/// Poll every 30s for `fips0`'s ULA; when it appears, bind the peer
|
||||||
/// listener and run the normal accept loop. If the bind fails (port
|
/// listener and run the normal accept loop. If the bind fails (port
|
||||||
/// already taken, permissions), log and keep retrying. Returns on
|
/// already taken, permissions), log and keep retrying. Returns on
|
||||||
@ -1047,18 +1222,36 @@ async fn peer_late_bind_loop(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
info!("FIPS peer listener bound {}", addr);
|
info!("FIPS peer listener bound {}", addr);
|
||||||
// Once bound, serve until shutdown fires. accept_loop
|
// Serve until shutdown, a persistent accept failure, or a
|
||||||
// returns on shutdown, which also ends this outer loop.
|
// fips0 ULA change. The listener must be REBINDABLE: a
|
||||||
accept_loop(
|
// daemon re-key tears fips0 down and brings it back with a
|
||||||
handler,
|
// (possibly different) ULA, and the old one-shot bind left
|
||||||
listener,
|
// the node inbound-dead over FIPS until process restart.
|
||||||
active_connections,
|
tokio::select! {
|
||||||
true, // peer listener: apply path filter
|
_ = accept_loop(
|
||||||
shutdown_rx,
|
handler.clone(),
|
||||||
addr,
|
listener,
|
||||||
)
|
active_connections.clone(),
|
||||||
.await;
|
true, // peer listener: apply path filter
|
||||||
return;
|
shutdown_rx.clone(),
|
||||||
|
addr,
|
||||||
|
) => {
|
||||||
|
if *shutdown_rx.borrow() { return; }
|
||||||
|
warn!("FIPS peer accept loop ended — rebinding");
|
||||||
|
}
|
||||||
|
_ = async {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||||
|
if crate::fips::iface::fips0_ula() != Some(ip) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} => {
|
||||||
|
info!("fips0 ULA changed — rebinding FIPS peer listener");
|
||||||
|
// Dropping the select arm cancels accept_loop and
|
||||||
|
// frees the socket; the outer loop rebinds fresh.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ = shutdown_rx.changed() => {
|
_ = shutdown_rx.changed() => {
|
||||||
if *shutdown_rx.borrow() { return; }
|
if *shutdown_rx.borrow() { return; }
|
||||||
@ -1094,6 +1287,12 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
|||||||
)
|
)
|
||||||
// Prefix-matched content endpoints (peer file browse + fetch)
|
// Prefix-matched content endpoints (peer file browse + fetch)
|
||||||
|| path.starts_with("/content/")
|
|| 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(
|
async fn accept_loop(
|
||||||
@ -1104,22 +1303,70 @@ async fn accept_loop(
|
|||||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
local_addr: SocketAddr,
|
local_addr: SocketAddr,
|
||||||
) {
|
) {
|
||||||
|
// Consecutive accept-error tracking: a fips0 teardown/re-key leaves the
|
||||||
|
// peer listener's socket permanently broken — `continue`-ing forever
|
||||||
|
// made the node inbound-dead over FIPS until process restart. After a
|
||||||
|
// burst of consecutive errors the peer accept loop returns so its
|
||||||
|
// caller (peer_late_bind_loop) can rebind on the current ULA.
|
||||||
|
let mut consecutive_errors: u32 = 0;
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
result = listener.accept() => {
|
result = listener.accept() => {
|
||||||
let (stream, peer_addr) = match result {
|
let (stream, peer_addr) = match result {
|
||||||
Ok(c) => c,
|
Ok(c) => { consecutive_errors = 0; c }
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("{} accept error: {}", local_addr, e);
|
error!("{} accept error: {}", local_addr, e);
|
||||||
|
consecutive_errors += 1;
|
||||||
|
if peer_only && consecutive_errors >= 10 {
|
||||||
|
warn!("{} accept failing persistently — returning for rebind", local_addr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Don't hot-loop on a dead socket.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let handler = handler.clone();
|
let handler = handler.clone();
|
||||||
let permit = active_connections.clone().acquire_owned().await;
|
// NEVER park the accept loop on the connection budget.
|
||||||
|
// `acquire_owned().await` here froze accept() entirely when
|
||||||
|
// permits drained — and permits drained because half-open
|
||||||
|
// clients and hung upstreams held them forever (the .228
|
||||||
|
// session-flapping / CLOSE-WAIT `inode: 0` signature). Shed
|
||||||
|
// load instead: accept, answer 503, close.
|
||||||
|
let permit = match active_connections.clone().try_acquire_owned() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => {
|
||||||
|
warn!(
|
||||||
|
"{} connection budget exhausted — shedding {}",
|
||||||
|
local_addr, peer_addr
|
||||||
|
);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let mut stream = stream;
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
stream.write_all(
|
||||||
|
b"HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = stream.shutdown().await;
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
|
// Set when a request carries an Upgrade header (websocket):
|
||||||
|
// upgraded connections are legitimately long-lived and are
|
||||||
|
// exempt from the non-upgraded connection deadline below.
|
||||||
|
let upgraded = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let upgraded_flag = upgraded.clone();
|
||||||
let service = service_fn(move |mut req: hyper::Request<hyper::Body>| {
|
let service = service_fn(move |mut req: hyper::Request<hyper::Body>| {
|
||||||
let handler = handler.clone();
|
let handler = handler.clone();
|
||||||
|
if req.headers().contains_key(hyper::header::UPGRADE) {
|
||||||
|
upgraded_flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
async move {
|
async move {
|
||||||
// Record the TCP peer so rate limiting only trusts
|
// Record the TCP peer so rate limiting only trusts
|
||||||
// forwarded headers on loopback (nginx) connections.
|
// forwarded headers on loopback (nginx) connections.
|
||||||
@ -1138,13 +1385,48 @@ async fn accept_loop(
|
|||||||
.map_err(|e| std::io::Error::other(format!("{}", e)))
|
.map_err(|e| std::io::Error::other(format!("{}", e)))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if let Err(e) = Http::new()
|
// header_read_timeout: a client that connects and never
|
||||||
|
// sends a request (slowloris / half-open) is dropped
|
||||||
|
// instead of holding a permit until the heat death of the
|
||||||
|
// node. Long RPCs are safe — the clock only covers header
|
||||||
|
// read.
|
||||||
|
let conn = Http::new()
|
||||||
.http1_keep_alive(false)
|
.http1_keep_alive(false)
|
||||||
|
.http1_header_read_timeout(std::time::Duration::from_secs(30))
|
||||||
.serve_connection(stream, service)
|
.serve_connection(stream, service)
|
||||||
.with_upgrades()
|
.with_upgrades();
|
||||||
.await
|
tokio::pin!(conn);
|
||||||
{
|
// Deadline watchdog for NON-upgraded connections. With
|
||||||
error!("Error serving connection from {}: {}", peer_addr, e);
|
// keep-alive off a plain connection serves one exchange;
|
||||||
|
// 15 min bounds even the slowest legitimate RPC/stream
|
||||||
|
// while guaranteeing a hung upstream can't hold a permit
|
||||||
|
// forever. Upgraded (websocket) connections are exempt.
|
||||||
|
const NON_UPGRADED_DEADLINE: std::time::Duration =
|
||||||
|
std::time::Duration::from_secs(900);
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let watchdog = async {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||||
|
if !upgraded.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
&& started.elapsed() >= NON_UPGRADED_DEADLINE
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::select! {
|
||||||
|
r = &mut conn => {
|
||||||
|
if let Err(e) = r {
|
||||||
|
error!("Error serving connection from {}: {}", peer_addr, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = watchdog => {
|
||||||
|
warn!(
|
||||||
|
"connection from {} exceeded {}s without completing or upgrading — dropping",
|
||||||
|
peer_addr,
|
||||||
|
NON_UPGRADED_DEADLINE.as_secs()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1797,10 +2079,25 @@ mod merge_tests {
|
|||||||
);
|
);
|
||||||
assert!(is_peer_allowed_path("/rpc/v1"));
|
assert!(is_peer_allowed_path("/rpc/v1"));
|
||||||
assert!(is_peer_allowed_path("/health"));
|
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).
|
// 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("/contention"), "must not prefix-leak");
|
||||||
assert!(!is_peer_allowed_path("/"));
|
assert!(!is_peer_allowed_path("/"));
|
||||||
assert!(!is_peer_allowed_path("/rpc/v2"));
|
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]
|
#[test]
|
||||||
|
|||||||
@ -24,17 +24,27 @@ pub struct LanTransport {
|
|||||||
our_did: String,
|
our_did: String,
|
||||||
our_pubkey_hex: String,
|
our_pubkey_hex: String,
|
||||||
our_port: u16,
|
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>,
|
daemon: Option<ServiceDaemon>,
|
||||||
available: AtomicBool,
|
available: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LanTransport {
|
impl LanTransport {
|
||||||
/// Create a new LAN transport. Does not start discovery yet.
|
/// Create a new LAN transport. Does not start discovery yet.
|
||||||
pub fn new(our_did: &str, our_pubkey_hex: &str, port: u16) -> Self {
|
pub fn new(
|
||||||
|
our_did: &str,
|
||||||
|
our_pubkey_hex: &str,
|
||||||
|
port: u16,
|
||||||
|
our_fips_npub: Option<String>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
our_did: our_did.to_string(),
|
our_did: our_did.to_string(),
|
||||||
our_pubkey_hex: our_pubkey_hex.to_string(),
|
our_pubkey_hex: our_pubkey_hex.to_string(),
|
||||||
our_port: port,
|
our_port: port,
|
||||||
|
our_fips_npub,
|
||||||
daemon: None,
|
daemon: None,
|
||||||
available: AtomicBool::new(false),
|
available: AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
@ -47,11 +57,14 @@ impl LanTransport {
|
|||||||
|
|
||||||
// Advertise our service
|
// Advertise our service
|
||||||
let hostname = format!("archy-{}.local.", &self.our_pubkey_hex[..8]);
|
let hostname = format!("archy-{}.local.", &self.our_pubkey_hex[..8]);
|
||||||
let properties = vec![
|
let mut properties = vec![
|
||||||
("did".to_string(), self.our_did.clone()),
|
("did".to_string(), self.our_did.clone()),
|
||||||
("pubkey".to_string(), self.our_pubkey_hex.clone()),
|
("pubkey".to_string(), self.our_pubkey_hex.clone()),
|
||||||
("version".to_string(), "0.1.0".to_string()),
|
("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(
|
let service_info = ServiceInfo::new(
|
||||||
SERVICE_TYPE,
|
SERVICE_TYPE,
|
||||||
@ -93,6 +106,11 @@ impl LanTransport {
|
|||||||
.map(|v| v.val_str().to_string());
|
.map(|v| v.val_str().to_string());
|
||||||
let addresses = info.get_addresses();
|
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(did), Some(pubkey)) = (did, pubkey) {
|
||||||
if let Some(scoped_ip) = addresses.iter().next() {
|
if let Some(scoped_ip) = addresses.iter().next() {
|
||||||
let ip: std::net::IpAddr = match scoped_ip.to_string().parse() {
|
let ip: std::net::IpAddr = match scoped_ip.to_string().parse() {
|
||||||
@ -106,6 +124,9 @@ impl LanTransport {
|
|||||||
.await;
|
.await;
|
||||||
registry_clone.set_lan_address(&did, socket_addr).await;
|
registry_clone.set_lan_address(&did, socket_addr).await;
|
||||||
registry_clone.set_name(&did, info.get_fullname()).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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,7 +106,7 @@ pub enum PeerSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Unified peer record with per-transport capabilities.
|
/// Unified peer record with per-transport capabilities.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct PeerRecord {
|
pub struct PeerRecord {
|
||||||
pub did: String,
|
pub did: String,
|
||||||
pub pubkey_hex: String,
|
pub pubkey_hex: String,
|
||||||
|
|||||||
@ -1868,13 +1868,38 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
|||||||
// UI before systemd kills us. --no-block makes sure systemctl doesn't
|
// UI before systemd kills us. --no-block makes sure systemctl doesn't
|
||||||
// try to wait for the current service (us) to exit cleanly before
|
// try to wait for the current service (us) to exit cleanly before
|
||||||
// starting the new process — it would deadlock otherwise.
|
// starting the new process — it would deadlock otherwise.
|
||||||
tokio::spawn(async {
|
// PID1-owned timer transient: submit NOW (synchronously, while we are
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
// definitely alive), fire in 2s from systemd itself. The old approach —
|
||||||
// systemctl talks to PID 1 over D-Bus — doesn't need the host
|
// tokio sleep + `systemd-run --wait -- systemctl --no-block restart` —
|
||||||
// mount namespace, but routing through host_sudo keeps the
|
// ran as a child of the process being stopped; on v1.7.114->115 the
|
||||||
// apply flow's sudo calls uniform.
|
// stop landed but the start never fired and the node sat dead all
|
||||||
let _ = host_sudo(&["systemctl", "--no-block", "restart", "archipelago"]).await;
|
// night. A timer unit owned by PID1 cannot be killed by our own death,
|
||||||
});
|
// and Restart=always on the unit is the second net.
|
||||||
|
let submitted = tokio::process::Command::new("sudo")
|
||||||
|
.args([
|
||||||
|
"systemd-run",
|
||||||
|
"--collect",
|
||||||
|
"--on-active=2",
|
||||||
|
"--timer-property=AccuracySec=100ms",
|
||||||
|
"--",
|
||||||
|
"systemctl",
|
||||||
|
"restart",
|
||||||
|
"archipelago",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.await;
|
||||||
|
match submitted {
|
||||||
|
Ok(st) if st.success() => {}
|
||||||
|
other => {
|
||||||
|
tracing::warn!(
|
||||||
|
"detached restart submission failed ({other:?}) — falling back to in-process restart"
|
||||||
|
);
|
||||||
|
tokio::spawn(async {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
let _ = host_sudo(&["systemctl", "--no-block", "restart", "archipelago"]).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -632,11 +632,30 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
|||||||
// Mark original proofs as spent
|
// Mark original proofs as spent
|
||||||
wallet.mark_spent(&indices);
|
wallet.mark_spent(&indices);
|
||||||
|
|
||||||
// Separate send proofs from change proofs
|
// Separate send proofs from change proofs BY COUNT, not membership:
|
||||||
let (send, change): (Vec<_>, Vec<_>) = swap_result
|
// partition(contains) put EVERY proof whose denomination appeared in
|
||||||
.new_proofs
|
// send_denoms into the token — when change shared a denomination with
|
||||||
.into_iter()
|
// the send (worst case: spending from a proof worth exactly 2× the
|
||||||
.partition(|p| send_denoms.contains(&p.amount));
|
// amount, send [n] + change [n]), the change proofs rode along and the
|
||||||
|
// receiver was credited double. Consume exactly one proof per needed
|
||||||
|
// send denomination; everything else is change.
|
||||||
|
let mut send_needed = send_denoms.clone();
|
||||||
|
let mut send: Vec<Proof> = Vec::new();
|
||||||
|
let mut change: Vec<Proof> = Vec::new();
|
||||||
|
for p in swap_result.new_proofs {
|
||||||
|
if let Some(pos) = send_needed.iter().position(|&d| d == p.amount) {
|
||||||
|
send_needed.swap_remove(pos);
|
||||||
|
send.push(p);
|
||||||
|
} else {
|
||||||
|
change.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !send_needed.is_empty() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Mint swap returned incomplete send denominations (missing {:?})",
|
||||||
|
send_needed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Add change proofs back to wallet
|
// Add change proofs back to wallet
|
||||||
if !change.is_empty() {
|
if !change.is_empty() {
|
||||||
|
|||||||
9
demo/content/README.md
Normal file
9
demo/content/README.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# 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.
|
||||||
300
docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md
Normal file
300
docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
# 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.5–3.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.
|
||||||
108
docs/LICENSE-COMPLIANCE-AUDIT.md
Normal file
108
docs/LICENSE-COMPLIANCE-AUDIT.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# 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.
|
||||||
288
docs/OPEN-SOURCE-READINESS-PLAN.md
Normal file
288
docs/OPEN-SOURCE-READINESS-PLAN.md
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
# Open-Source Readiness Plan — Archipelago public launch
|
||||||
|
|
||||||
|
> Working plan, 2026-07-27. Source of truth for the pre-open-source cleanup.
|
||||||
|
> A second agent is working the same goal concurrently — before executing any phase,
|
||||||
|
> diff against `git log` since `7e8d3314` and skip/merge what's already done.
|
||||||
|
> (Session plan file: `~/.claude/plans/resilient-moseying-reef.md`.)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The repo goes public in a few days, targeting bitcoin/bitcoin-level polish. Three deep
|
||||||
|
exploration passes (docs/structure, code health, secrets sweep) found the repo is
|
||||||
|
fundamentally strong — README, `apps/` manifest examples, ADRs, the bats lifecycle gate,
|
||||||
|
1,104 Rust tests — but has hard blockers: **two live Anthropic API keys committed in
|
||||||
|
tracked files**, node passwords in 7 tracked files, no LICENSE (README links a 404),
|
||||||
|
5.5 GB `.git` (re-committed 27 MB APKs), ~290 hardcoded references to the private Gitea
|
||||||
|
registry `146.59.87.168:3000` that make every app image unpullable for outsiders, and
|
||||||
|
~28 internal AI-session/tracker docs mixed into `docs/`.
|
||||||
|
|
||||||
|
**Decisions made by the user:**
|
||||||
|
1. **Fresh-history publish** — new public repo with a clean initial commit; private repo keeps full history.
|
||||||
|
2. **Registry: domain + parameterize** — real domain in front of the existing registry; host configurable everywhere.
|
||||||
|
3. **Deep code cleanup** — orphan crates, dead_code lifts, clippy trims, legacy fallback deletion (sequenced, cut-line-friendly).
|
||||||
|
4. **Internal docs: sanitize and keep public** — scrub creds/IPs/hostnames but publish plans/trackers for transparency.
|
||||||
|
|
||||||
|
**Invariant throughout:** the single-node production gate (`tests/lifecycle/run-gate.sh`)
|
||||||
|
is GREEN and must stay green. Re-run after any orchestrator/lifecycle change (Phase E
|
||||||
|
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)
|
||||||
|
|
||||||
|
Treat all of these as already compromised; rotate even though we're doing fresh-history:
|
||||||
|
|
||||||
|
- **Anthropic API key #1**: `image-recipe/_archived/build-auto-installer-iso.sh:2837` (the "intentional alpha" ISO key). Revoke + reissue; move the live key OUT of source into a build-time secret/env (`ISO_ANTHROPIC_API_KEY`), keep the alpha-baking behavior if desired but never the literal in git.
|
||||||
|
- **Anthropic API key #2**: `scripts/setup-aiui-server.sh:28` — a *different* live key, not covered by the documented alpha exception. Revoke; parameterize the script.
|
||||||
|
- **The shared node SSH/sudo/UI password** (two variants) — in 7 tracked files + 24+ commits. Rotate fleet-wide (user task).
|
||||||
|
- **Gitea `ai` account password + 2 Gitea tokens** — embedded in `.git/config` remote URLs (not tracked, but leaks in any directory copy/tarball). Rotate; switch remotes to credential-helper storage instead of URL-embedded creds.
|
||||||
|
|
||||||
|
## Phase 1 — Secrets & sanitization of tracked files
|
||||||
|
|
||||||
|
1. Strip the password/credential lines from the 7 files:
|
||||||
|
`docs/PRODUCTION-MASTER-PLAN.md` (lines ~428–429, 454–457, 483, 521–528, 886 — the fleet cred table),
|
||||||
|
`docs/archive/SESSION-1.8.0-OTA-PROGRESS.md`, `docs/archive/HANDOVER-2026-07-02-iso-feedback.md`,
|
||||||
|
`docs/bitcoin-version-bulletproof-rollout.md`, `tests/production-quality/TRACKER.md`,
|
||||||
|
`tests/multinode/meshtastic.sh:26`, `neode-ui/test-openwrt.mjs:4` (→ env var).
|
||||||
|
2. `.gitea/workflows/post-install-tests.yml` — remove `sshpass -p '…'` + default target IP; use secrets/vars.
|
||||||
|
3. Sanitize infra identifiers repo-wide (in the *sanitize-and-keep* docs and scripts):
|
||||||
|
replace Tailscale IPs (17 unique, 14 files), LAN IPs (`192.168.1.x`, 93 files), hostnames
|
||||||
|
(`tx1138`, `shorty-s`, `archy-x250`, `archy-dev-pa`) with placeholders like `<node-a>` /
|
||||||
|
`NODE_IP`. Key script targets: `scripts/deploy-config-defaults.sh`, `scripts/deploy-tailscale.sh`,
|
||||||
|
`docs/operations-runbook.md` (opens with real node IPs), `docs/developer-guide.md`, `docs/api-reference.md`, `docs/hotfix-process.md`.
|
||||||
|
4. Fix the audit tool that let this happen: `scripts/audit-secrets.sh:28` — remove `\.md$` and
|
||||||
|
bare `test` from ALLOW_PATTERNS; add `sk-ant-` and password-table patterns; scan all
|
||||||
|
tracked files not just `*.env`. Run it clean as a Phase-1 exit check.
|
||||||
|
5. `.gitignore` additions: `.claude/`, `*.key`, `*.pem`, `id_rsa*`, `*.sqlite`, `*.db`
|
||||||
|
(`.claude/settings.local.json` with creds is currently only ignored by a machine-global rule).
|
||||||
|
6. Product-security note to raise (not fix now): `password123` is a shipped default (auth.rs, en.json, user-walkthrough) — file a public issue for forced first-run password change if not already enforced.
|
||||||
|
|
||||||
|
## Phase 2 — Repo restructure: deletions, binaries, layout
|
||||||
|
|
||||||
|
Delete (each its own commit):
|
||||||
|
- `loop/` (AI overnight harness w/ node SSH lines), `.agents/`, `.codex`, `.githooks/pre-push`
|
||||||
|
(the hook that re-commits the 27 MB APK — root cause of the 5.5 GB history).
|
||||||
|
- `indeedhub/` submodule + `.gitmodules` entry (points at private HTTP Gitea, breaks `--recursive`
|
||||||
|
clones); `indeedhub-demo/` (single Dockerfile — merge or drop).
|
||||||
|
- `RELEASE-NOTES-v1.0.0.md` (superseded by CHANGELOG), `neode-ui/docs/GAMEPAD-NAV-MAP.md` (duplicate of `docs/GAMEPAD-NAV.md`).
|
||||||
|
- Stray generated HTML: `docs/container-architecture.html` (311 KB), `docs/archive/architecture-review.html`, `docs/archive/lora-functionality.html`.
|
||||||
|
- `Android/local.properties` from tracking (local absolute path); remove `Android/app/debug.keystore` (standard practice).
|
||||||
|
|
||||||
|
Move out of git (→ release assets on the Releases page, referenced by URL):
|
||||||
|
- `neode-ui/public/packages/archipelago-companion.apk` (27 MB), `wireguard.apk` (17 MB), `atob.s9pk` (23 MB).
|
||||||
|
- `Android/archipelago-0.3.0-debug.apk.zip` (16 MB, stale).
|
||||||
|
- `demo/content/music/*` + heavy `demo/aiui/assets` (~261 MB, third-party/unclear-licence media — MUST not ship publicly regardless of size).
|
||||||
|
- `neode-ui/dev-dist/` (generated Workbox output) → gitignore.
|
||||||
|
|
||||||
|
Rename/fix the naming lie: `image-recipe/_archived/` contains the *production* ISO builder
|
||||||
|
(`build-auto-installer-iso.sh`, referenced by `.gitea/workflows/build-iso.yml`). Move live
|
||||||
|
files up into `image-recipe/`, delete the genuinely archived rest.
|
||||||
|
|
||||||
|
## Phase 3 — Registry domain + parameterization (functional blocker)
|
||||||
|
|
||||||
|
Infra (user assists: DNS + TLS):
|
||||||
|
- Put a domain (e.g. `registry.archipelago-os.org` / `git.archipelago-os.org`) with HTTPS in
|
||||||
|
front of the existing Gitea on vps2. OTA download URLs move from plain HTTP to HTTPS.
|
||||||
|
|
||||||
|
Repo changes:
|
||||||
|
- Introduce a single source of truth for the registry host (e.g. `REGISTRY_HOST` in
|
||||||
|
`scripts/lib/` + a default in the orchestrator config). Replace `146.59.87.168:3000` in:
|
||||||
|
all 56 `apps/*/manifest.yml`, `app-catalog/catalog.json`, `releases/manifest.json`,
|
||||||
|
`release-manifest.json`, the 11 scripts (`self-update.sh`, `create-release.sh`,
|
||||||
|
`generate-app-catalog.sh`, `validate-app-manifest.sh`, `first-boot-containers.sh`, …),
|
||||||
|
both `demo-images.yml` workflows, `demo-deploy/.env.example`, and the Android sources
|
||||||
|
(`FipsPreferences.kt`, `PartyScreen.kt`).
|
||||||
|
- Because the catalog is signed: regenerate + re-sign + republish the app catalog after the
|
||||||
|
manifest host change (catalog-overlay supremacy — disk edits don't apply otherwise).
|
||||||
|
Signing needs the user's mnemonic → schedule one ceremony after manifests are final.
|
||||||
|
- Verify: fresh machine with no LAN/tailnet access can `podman pull` one app image via the
|
||||||
|
domain and the gate node still installs apps after the re-signed catalog lands.
|
||||||
|
|
||||||
|
## Phase 4 — Documentation overhaul
|
||||||
|
|
||||||
|
### 4a. Community/legal files (missing today)
|
||||||
|
- `LICENSE` — MIT (matches existing README badge). Add `[workspace.package] license` +
|
||||||
|
`license.workspace = true` in the 5 member Cargo.tomls (also see Phase A4).
|
||||||
|
- `SECURITY.md` — disclosure address, PGP key, supported-versions; cite the March 2026 audit (`docs/archive/security-code-audit-2026-03.md`).
|
||||||
|
- `CODE_OF_CONDUCT.md` — Contributor Covenant (CONTRIBUTING.md already links to it, 404 today).
|
||||||
|
- `CONTRIBUTING.md` edits: Gitea→GitHub fork flow, remove private deploy instructions, absorb
|
||||||
|
the public-worthy CLAUDE.md invariants (rootless podman, manifest-driven, secrets model,
|
||||||
|
non-destructive migrations), versioning policy note for the `-alpha` scheme.
|
||||||
|
- `CLAUDE.md` — rewrite: keep invariants/build-verify (public-worthy), remove status banner,
|
||||||
|
node numbers, `gitea-ai` push mechanics, MEMORY references (those move to private notes).
|
||||||
|
|
||||||
|
### 4b. New developer docs (the three real gaps for app developers)
|
||||||
|
1. **`docs/quadlet-compilation.md`** — how a manifest becomes a Quadlet/systemd unit: naming,
|
||||||
|
`systemctl --user` lifecycle, where units land, how to inspect/debug one. (Source:
|
||||||
|
`core/archipelago/src/container/quadlet*.rs`, prod_orchestrator.)
|
||||||
|
2. **`docs/container-lifecycle.md`** — the 30 s level-triggered reconciler, install/adopt/
|
||||||
|
restart/uninstall state machine, health checks, crash recovery. (Replaces the plan-shaped
|
||||||
|
`docs/bulletproof-containers.md` as the current description; salvage its content.)
|
||||||
|
3. **`docs/secrets.md`** — `generated_secrets` declaration → materialisation by
|
||||||
|
`container::secrets` (0600, rootless) → injection; what developers must never do.
|
||||||
|
- Also: make every example in `docs/app-developer-guide.md` + `apps/*/manifest.yml` copy-paste
|
||||||
|
work against the new public registry host; add an end-to-end "write your first app" walkthrough
|
||||||
|
that a stranger can follow with only the public repo + an Archipelago node.
|
||||||
|
|
||||||
|
### 4c. Sanitize-and-keep internal docs (user's transparency choice)
|
||||||
|
- Keep, after Phase-1 scrubbing: `docs/PRODUCTION-MASTER-PLAN.md`, `docs/UNIFIED-TASK-TRACKER.md`,
|
||||||
|
`docs/1.8.0-RELEASE-HARDENING-PLAN.md`, `docs/RETICULUM-TRANSPORT-PROGRESS.md`, HANDOFF-*, test
|
||||||
|
plans, `docs/archive/*` — but **move all session/handoff/tracker material under
|
||||||
|
`docs/history/`** (extending the existing honest `docs/archive/README.md` pattern) so the
|
||||||
|
top-level `docs/` reads as current reference only. Add a banner to each: "historical working
|
||||||
|
document, sanitized; not maintained."
|
||||||
|
- Remove dangling agent-memory references in tracked docs (`docs/bulletproof-containers.md`,
|
||||||
|
`docs/RETICULUM-TRANSPORT-PROGRESS.md`, `docs/registry-manifest-design.md`,
|
||||||
|
`docs/bitcoin-multi-version-design.md` progress block).
|
||||||
|
- De-status the 14 design docs (strip "Status/RESUME POINT" headers into a one-line status
|
||||||
|
field; e.g. `docs/APP-PACKAGING-MIGRATION-PLAN.md` → public app-platform design doc).
|
||||||
|
- Extract North-Star narrative from PRODUCTION-MASTER-PLAN into `docs/ROADMAP.md`; extract
|
||||||
|
the "run the gate ON the node" philosophy from `docs/multinode-testing-plan.md` into
|
||||||
|
`tests/lifecycle/TESTING.md`.
|
||||||
|
- Add `docs/README.md` index (bitcoin/bitcoin `doc/` style): Getting started / Architecture /
|
||||||
|
App development / Operations / Design docs (ADRs) / History.
|
||||||
|
- README fixes: LICENSE link becomes real, Documentation table repointed at the reorganized
|
||||||
|
docs, remove "Deploy to a Test Node" private-LAN section, point Contributing at
|
||||||
|
CONTRIBUTING.md only.
|
||||||
|
|
||||||
|
## Phase 5 — Deep code cleanup (ordered zero-risk → highest-risk; cut-line after any commit)
|
||||||
|
|
||||||
|
### A. Zero-risk deletions & metadata (S each, own commits)
|
||||||
|
- **A1** Delete orphan non-compiling StartOS crates: `core/models`, `core/helpers`,
|
||||||
|
`core/js-engine` (incl. 2 committed `JS_SNAPSHOT.*.bin`), `core/container-init` (~4,100 LOC,
|
||||||
|
zero references). Verify: `cargo build --workspace && cargo test --all-features`.
|
||||||
|
- **A2** Delete unreferenced Vue components: `neode-ui/src/components/{AppSwitcher,EmptyState,SkeletonCard}.vue`. Verify: `npm run type-check && npm run build`.
|
||||||
|
- **A3** Fix `.gitignore` lockfile lines (7: `Cargo.lock`, 15: `package-lock.json`) — lockfiles are intentionally tracked; the rules are misleading and swallow future lockfiles.
|
||||||
|
- **A4** LICENSE + Cargo license fields (see 4a). Verify with `cargo metadata`.
|
||||||
|
- **A5** `core/rust-toolchain.toml` pinning `1.95.0`; align `.github/workflows/ci.yml` (remove explicit `toolchain: stable` input so the file wins). Upgrades become deliberate PRs.
|
||||||
|
- **A6** `core/rustfmt.toml` codifying **defaults only** (`edition = "2021"` + comment) — do NOT add style options days before launch (whole-tree reformat churn). Verify `cargo fmt --all -- --check` yields no diff.
|
||||||
|
|
||||||
|
### B. CI guards (zero runtime risk)
|
||||||
|
- **B1** Enable vitest in CI: run `cd neode-ui && npm run test` locally; fix trivial failures, `.skip`+issue flaky ones; add step to the frontend job. Playwright → tracked issue only (needs browsers + mock backend orchestration).
|
||||||
|
- **B2** Raw podman/systemctl **ratchet, not migration**: the 132 raw `Command::new("podman"/"systemctl")` sites use subcommands the `core/container/src/podman_client.rs` wrapper doesn't expose (network/inspect/ps/port), 43 sites are in gate-critical `install.rs`, and the prod path intentionally uses Quadlet+systemctl. Add `scripts/ci/raw-podman-ratchet.sh` (count vs committed baseline, fail on increase) as a CI step + tracked issue for wrapper API design.
|
||||||
|
|
||||||
|
### C. Clippy suppression trim (`core/archipelago/src/main.rs:8-18`, per-lint commits)
|
||||||
|
- Remove cheaply: `assertions_on_constants`, `drop_non_drop`, `wildcard_in_or_patterns`, `doc_lazy_continuation`, `enum_variant_names` (targeted allows on serde enums — never rename wire variants).
|
||||||
|
- Own careful commit: `unused_io_amount` — a **correctness** lint; fix sites with `read_exact`/`write_all` or documented targeted allows (`mesh/serial.rs:456,496` has raw partial reads; serial framing may be intentional). Full test suite + gate after.
|
||||||
|
- Keep crate-wide with justifying comment: `too_many_arguments`, `type_complexity`; attempt `ptr_arg` (`&Vec<T>`→`&[T]`, mechanical) if time allows — first to cut.
|
||||||
|
- Verify each: `cargo clippy --all-targets --all-features -- -D warnings && cargo test --all-features`.
|
||||||
|
|
||||||
|
### D. dead_code lift — Tiers 1–2 pre-launch, Tier 3 → commented allows + issues
|
||||||
|
Per-module procedure (one file per commit): remove `#![allow(dead_code)]` → `cargo check
|
||||||
|
--all-targets --all-features` → triage each warning: (a) genuinely dead → delete;
|
||||||
|
(b) future-feature/protocol-mandated → targeted `#[allow(dead_code)] // TODO(#NNN): …`;
|
||||||
|
(c) missing wiring → keep + targeted allow + issue (don't fix wiring in this workstream) →
|
||||||
|
clippy `-D warnings` + tests → commit.
|
||||||
|
- **Tier 1 (small/leaf, S each):** `swarm/seed_advert.rs`, `transport/{mesh_transport,lan,chunking,delta}.rs`, `mesh/{crypto,alerts,types,outbox}.rs`, `streaming/mod.rs`, `wallet/mod.rs`.
|
||||||
|
- **Tier 2 (M each):** `fips/{mod,iface,dial}.rs` (41 external refs → little residual deadness), `mesh/{x3dh,ratchet,steganography,message_types}.rs` — for crypto files bias to (b) with roadmap comments (unused crypto attracts auditor noise; every kept item needs its why).
|
||||||
|
- **Tier 3 (defer, riskiest):** `mesh/{mod,reticulum,protocol,serial,bitcoin_relay}.rs`, `transport/mod.rs` — change each blanket allow to `#![allow(dead_code)] // Hardware-mesh surface partially wired; triage tracked in #NNN`.
|
||||||
|
- Optional S/M win: move `prod_orchestrator.rs`'s 5,034-line `#[cfg(test)]` module to a sibling file via `#[path]` (pure move, halves the 6,291-line file).
|
||||||
|
|
||||||
|
### E. stacks.rs legacy fallbacks (highest risk — LAST, evidence-gated)
|
||||||
|
Legacy installers for immich/btcpay/mempool/indeedhub (`core/archipelago/src/api/rpc/package/stacks.rs:838/1047/1267/1498`, ~1,000 LOC with hardcoded registry IPs) fire only on "unknown app_id, zero members installed", logging `INSTALL ORCH SKIP` (stacks.rs:673). Netbird already uses the hard-error replacement (stacks.rs:1898-1920).
|
||||||
|
1. Run the full gate on the node; grep install logs for `INSTALL ORCH SKIP`.
|
||||||
|
2. Zero SKIPs → replace each legacy body with the netbird-style hard error (keep orchestrator call + `adopt_stack_if_exists`; satisfies migrations-never-destroy-data). Re-run gate; any red → revert + issue.
|
||||||
|
3. Any SKIP → don't delete; issue: "deploy manifests fleet-wide, then delete legacy installers".
|
||||||
|
|
||||||
|
### Explicitly deferred → public tracked issues at launch
|
||||||
|
PodmanClient API extension + call-site migration; god-module splits (`install.rs`, `update.rs`, `mesh/mod.rs`); Playwright in CI; Tier-3 dead_code triage; `password123` default hardening.
|
||||||
|
|
||||||
|
## Phase 6 — Fresh-history publish
|
||||||
|
|
||||||
|
1. Freeze: all phases merged on internal `main`, gate green, catalog re-signed.
|
||||||
|
2. Build the public tree: `git archive`-style export of HEAD (never copy `.git/` — it holds
|
||||||
|
credentialed remotes) → new repo, single initial commit ("Initial public release, vX.Y.Z"),
|
||||||
|
optionally preserving CHANGELOG.md as the human-readable history.
|
||||||
|
3. Pre-publish gate on the export: `scripts/audit-secrets.sh` (fixed version) clean; grep-zero for
|
||||||
|
`sk-ant-`, rotated-password strings, `146.59.87.168`, tailnet `100.` IPs, `192.168.1.`,
|
||||||
|
internal hostnames; `du -sh .git` sanity (< ~100 MB); fresh `git clone` + `cd core && cargo build`
|
||||||
|
+ `cd neode-ui && npm ci && npm run build` on a clean machine/container; one app image pull
|
||||||
|
from the public domain.
|
||||||
|
4. Publish to GitHub; enable issue templates (already present in `.github/`); file the deferred-work
|
||||||
|
issues (from Phase 5's issue list) as the initial public issue set — honest and gives contributors entry points.
|
||||||
|
5. Internal repo remains the private full-history remote; decide sync direction post-launch
|
||||||
|
(recommend: public repo becomes canonical, private keeps only ops/infra notes).
|
||||||
|
|
||||||
|
## Verification (end-to-end)
|
||||||
|
|
||||||
|
- `tests/lifecycle/run-gate.sh` green on the node after Phases 3 + 5E (and after any lifecycle-touching commit).
|
||||||
|
- CI green on every phase commit: `cargo fmt --check`, `clippy -D warnings`, `cargo test --all-features`, frontend type-check + build + (new) vitest.
|
||||||
|
- Phase-6 clean-machine clone/build/pull test is the final acceptance test — it simulates the first outside developer.
|
||||||
|
- Docs acceptance: a reader following `docs/app-developer-guide.md` + the new quadlet/lifecycle/secrets docs can build and install an app manifest without any private infra.
|
||||||
|
|
||||||
|
## Sequencing / cut-line
|
||||||
|
|
||||||
|
Order: 0 → 1 → 2 → (3 ∥ 4) → 5 (A→E) → 6. Phases 0–2 are non-negotiable security; Phase 3 is the
|
||||||
|
functional blocker; Phase 4 is the developer-experience payload; Phase 5 can be cut after any
|
||||||
|
commit (minimum viable: A1–A6, B1–B2, unused_io_amount fix); Phase 6 last. If the timeline
|
||||||
|
compresses, Tier-2 dead_code and Phase E move to public issues — everything else holds.
|
||||||
@ -12,6 +12,33 @@ Full plan: `.claude/plans/enchanted-strolling-rocket.md`. Memory pointer:
|
|||||||
avoiding `mesh/listener/session.rs` transport plumbing + `mesh/mod.rs` routing, which this work
|
avoiding `mesh/listener/session.rs` transport plumbing + `mesh/mod.rs` routing, which this work
|
||||||
owns. Stay out of `meshtastic.rs`/`protocol.rs` to avoid collisions.
|
owns. Stay out of `meshtastic.rs`/`protocol.rs` to avoid collisions.
|
||||||
|
|
||||||
|
## Checkpoint 2026-07-28 — RNode connect + names FIXED, live-verified E2E (read this first)
|
||||||
|
|
||||||
|
The fleet reflash back to RNode firmware exposed a stack of bugs that made Reticulum
|
||||||
|
unusable on CP2102-bridged boards (Heltec V3 etc.) and left every archy node nameless on
|
||||||
|
RNS. All fixed in `a8c4694c` (backend) + `3f76b496` (UI), live-verified on archi-dev-box
|
||||||
|
and archy-x250-dev with a real RNode-to-RNode LXMF message (`transport: "reticulum"` in
|
||||||
|
mesh-messages) plus a cross-transport reply:
|
||||||
|
|
||||||
|
1. **probe_rnode boot race** — serial open pulses DTR/RTS via the USB-UART bridge → ESP32
|
||||||
|
power-cycles → KISS DETECT written 300ms later is eaten during ~2.5-3s of boot. Fix:
|
||||||
|
immediate probe (fast path) + drain-until-quiet boot settle + second DETECT window.
|
||||||
|
2. **configure() was a no-op on a running listener** (only enable/disable restarted it) —
|
||||||
|
the setup modal's apply/keep-as-is and every rename did nothing until process restart.
|
||||||
|
3. **Name propagation** — `config.advert_name` had no reader; `server.set-name` never
|
||||||
|
reached mesh; daemon display name fixed at spawn to the "Archy" default; the ARCHY:2
|
||||||
|
announce blob REPLACED the LXMF name. Now: announces carry msgpack
|
||||||
|
`[name, stamp_cost, sf, ARCHY-blob]` (Sideband-compatible, blob invisible to stock
|
||||||
|
clients), daemon has a `set_name` verb, renames bounce the session live.
|
||||||
|
4. **Daemon-death detection** (was invisible up to the 30-min RX-stall watchdog),
|
||||||
|
**modal re-trigger loop** (plugged_at used tty mtime → bumps on every open; now
|
||||||
|
btime/ctime), **ARCHY:2 federation-name clobber**, **mesh.refresh RPC** (Refresh button
|
||||||
|
now actually re-queries the radio), **Meshtastic mesh.broadcast now sends NodeInfo**.
|
||||||
|
|
||||||
|
Still open here: legacy-format peers (old fleet builds) show as `Reticulum <hex4>` until
|
||||||
|
they OTA; RNode RF params still daemon-hardcoded (EU-868 869.525/125k/SF8/CR5); Phase 4
|
||||||
|
multi-radio; duty-cycle guard.
|
||||||
|
|
||||||
## Status at a glance
|
## Status at a glance
|
||||||
|
|
||||||
| Phase | What | Status |
|
| Phase | What | Status |
|
||||||
|
|||||||
@ -15,6 +15,26 @@ those are marked ✅ below with the commit that did it, so we stop re-litigating
|
|||||||
|
|
||||||
## Tier 0 — Quick / mechanical, no blockers
|
## Tier 0 — Quick / mechanical, no blockers
|
||||||
|
|
||||||
|
- [ ] **Ship the lightning payment false-failure fix in the next release** (fixed
|
||||||
|
on main 2026-07-27, needs OTA). Slow multi-hop payments (>15s) surfaced as
|
||||||
|
"Payment failed" while LND settled them in the background — the shared LND
|
||||||
|
REST client's 15s timeout aborted the synchronous `/v1/channels/transactions`
|
||||||
|
wait. Now: payinvoice decodes the invoice first for its payment hash, waits
|
||||||
|
up to 120s on a dedicated client, returns `status: "pending"` (never a
|
||||||
|
failure) on timeout, and the new `lnd.paymentstatus` RPC + frontend
|
||||||
|
`payLightningInvoice()` helper poll to a real terminal state (all 5 UI call
|
||||||
|
sites migrated). Verify on Framework PT with a real multi-hop payment.
|
||||||
|
- [ ] **Show the app version on the companion mobile-app banner in the app store
|
||||||
|
and on its install/pairing modal** (user request 2026-07-27) — so it's
|
||||||
|
obvious at a glance whether the node is serving the latest APK build.
|
||||||
|
- [ ] **Optimise the companion QR scan — quicker + better** (user request
|
||||||
|
2026-07-27; deferred to a later session on purpose). The pairing/scan QR
|
||||||
|
flow works (user-verified on-device 2026-07-27) but should get faster and
|
||||||
|
smoother: quicker camera start + decode (scan resolution/framerate,
|
||||||
|
continuous autofocus), more forgiving in low light / at an angle, and
|
||||||
|
snappier feedback once the code locks. Touch the native-scan path from
|
||||||
|
PR #104 and the in-app scan modal together so both benefit.
|
||||||
|
|
||||||
- [ ] **Update `tests/lifecycle/TESTING.md`'s stale Release Gates checklist** (lines
|
- [ ] **Update `tests/lifecycle/TESTING.md`'s stale Release Gates checklist** (lines
|
||||||
289–296) — several boxes are unchecked but actually true now:
|
289–296) — several boxes are unchecked but actually true now:
|
||||||
- #1 bitcoin-stops: covered by `tests/lifecycle/bats/bitcoin-knots.bats` stop/restart
|
- #1 bitcoin-stops: covered by `tests/lifecycle/bats/bitcoin-knots.bats` stop/restart
|
||||||
|
|||||||
@ -162,7 +162,8 @@ All endpoints use JSON-RPC over HTTP POST to `/rpc/v1`.
|
|||||||
| `lnd.openchannel` | `{ pubkey: string, amount: number }` | `{ funding_txid: string }` | Yes |
|
| `lnd.openchannel` | `{ pubkey: string, amount: number }` | `{ funding_txid: string }` | Yes |
|
||||||
| `lnd.closechannel` | `{ channel_point: string }` | `{ closing_txid: string }` | Yes |
|
| `lnd.closechannel` | `{ channel_point: string }` | `{ closing_txid: string }` | Yes |
|
||||||
| `lnd.newaddress` | — | `{ address: string }` | Yes |
|
| `lnd.newaddress` | — | `{ address: string }` | Yes |
|
||||||
| `lnd.sendcoins` | `{ addr: string, amount: number }` | `{ txid: string }` | Yes |
|
| `lnd.sendcoins` | `{ addr: string, amount?: number, send_all?: bool, target_conf?: number, sat_per_vbyte?: number }` | `{ txid: string }` | Yes |
|
||||||
|
| `lnd.estimatefee` | `{ addr: string, amount: number, target_conf?: number }` | `{ fee_sat: number, sat_per_vbyte: number }` | Yes |
|
||||||
| `lnd.createinvoice` | `{ amount: number, memo?: string }` | `{ payment_request: string }` | Yes |
|
| `lnd.createinvoice` | `{ amount: number, memo?: string }` | `{ payment_request: string }` | Yes |
|
||||||
| `lnd.payinvoice` | `{ payment_request: string }` | `{ preimage: string }` | Yes |
|
| `lnd.payinvoice` | `{ payment_request: string }` | `{ preimage: string }` | Yes |
|
||||||
| `lnd.create-psbt` | `{ outputs: object, ... }` | `{ psbt: string }` | Yes |
|
| `lnd.create-psbt` | `{ outputs: object, ... }` | `{ psbt: string }` | Yes |
|
||||||
|
|||||||
@ -146,7 +146,7 @@ app:
|
|||||||
path: /health
|
path: /health
|
||||||
interfaces:
|
interfaces:
|
||||||
main:
|
main:
|
||||||
type: web
|
type: ui
|
||||||
port: 8090
|
port: 8090
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -110,6 +110,61 @@ lands].
|
|||||||
2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic).
|
2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic).
|
||||||
3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves.
|
3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves.
|
||||||
|
|
||||||
|
## H. LoRa radio firmware flashing (Heltec V3/V4, new — extends Section E)
|
||||||
|
|
||||||
|
Full v1 scope is 3 firmware families × 2 boards (6 cells); mark each cell
|
||||||
|
tested on real hardware vs. code-reviewed only as this is run.
|
||||||
|
|
||||||
|
1. ❑ From the hot-swap modal's step 1 (device already probed), press
|
||||||
|
**Flash Firmware…** → new step shows firmware-family + board pickers and
|
||||||
|
the erase-confirmation checkbox; "Erase & Flash Now" stays disabled until
|
||||||
|
family, board, AND the checkbox are all set.
|
||||||
|
2. ❑ Confirm what's currently on the test stick via the existing probe
|
||||||
|
BEFORE flashing it — don't flash the only known-good device without a
|
||||||
|
fallback board on hand.
|
||||||
|
3. ❑ Prefer a spare Heltec V3/V4 for the first destructive erase+flash run;
|
||||||
|
only exercise a primary/in-use stick once the flow is proven safe.
|
||||||
|
4. ❑ MeshCore → Heltec V3: erase + write completes, progress bar and log
|
||||||
|
tail update live, ends at "Flash complete".
|
||||||
|
5. ❑ Meshtastic → Heltec V3: same, using the extracted `*.factory.bin` from
|
||||||
|
the esp32s3 release zip.
|
||||||
|
6. ❑ Reticulum/RNode → Heltec V3: `archy-rnodeconf --autoinstall` path
|
||||||
|
completes (no raw esptool erase/write step for this family — see
|
||||||
|
`mesh/flash.rs` doc comment).
|
||||||
|
7. ❑ Repeat 4-6 against a Heltec V4. Confirmed 2026-07-23 on real hardware:
|
||||||
|
V4 uses the ESP32-S3's native-USB JTAG/serial peripheral (vid:pid
|
||||||
|
303a:1001, generic to every native-USB ESP32-S3 board, not V4-specific)
|
||||||
|
— so unlike V3's CP2102 bridge chip, V4 is permanently NOT auto-matchable
|
||||||
|
by vid:pid. Board auto-detect should fail closed for it every time
|
||||||
|
(manual board selection required, "couldn't confirm automatically"
|
||||||
|
warning shown) — this is expected steady-state behavior, not a gap to
|
||||||
|
close later.
|
||||||
|
8. ❑ After a successful flash, the modal automatically re-probes and shows
|
||||||
|
the NEW firmware's badge/details — same as unplugging and replugging
|
||||||
|
(Section E item 3), but without physically touching the cable.
|
||||||
|
9. ❑ Deliberately test a failure path once (disconnect the board mid-write,
|
||||||
|
or point at a bad cached asset) — confirm the error surfaces in the
|
||||||
|
progress log AND that `docs/troubleshooting.md`'s "LoRa radio firmware
|
||||||
|
flash failed" recovery steps (BOOT+RST bootloader entry, manual esptool/
|
||||||
|
rnodeconf command) actually get the board back to a flashable state.
|
||||||
|
10. ❑ Cancel button only appears (and only works) while still in the
|
||||||
|
"Downloading firmware…" stage — once erasing/writing starts, no cancel
|
||||||
|
affordance is offered.
|
||||||
|
11. ❑ **Boot-loop regression (2026-07-23 incident)**: after a *failed* flash
|
||||||
|
(e.g. kill network access mid-download to force a failure), confirm the
|
||||||
|
mesh listener does NOT auto-resume — `journalctl -u archipelago` should
|
||||||
|
show a single `Leaving mesh listener stopped after failed flash` line
|
||||||
|
and then go quiet for that device, not a repeating `mesh::serial:
|
||||||
|
Opened serial port... Starting Meshcore handshake` cycle every few
|
||||||
|
seconds. Reconnect manually via the hot-swap modal afterward and confirm
|
||||||
|
it connects normally (the board itself should be untouched — the
|
||||||
|
download fails before esptool/rnodeconf ever runs).
|
||||||
|
12. ❑ Separately, force a device to flap connected/disconnected a few times
|
||||||
|
in under 20s each (e.g. a marginal USB connection) and confirm
|
||||||
|
`reconnect_delay` in the logs actually escalates (5s → 10s → 20s → ...)
|
||||||
|
rather than resetting to 5s on every attempt — see
|
||||||
|
`STABLE_SESSION_THRESHOLD` in `mesh/listener/mod.rs`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
After this passes: fold the batch + other agent's work into the next release
|
After this passes: fold the batch + other agent's work into the next release
|
||||||
|
|||||||
@ -74,9 +74,10 @@ archy/
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- **macOS** (development machine): Node.js 20+, npm
|
- Node.js 20+ and npm for frontend development.
|
||||||
- **Linux server** (`192.168.1.228`): Rust toolchain, Podman, Nginx, Debian 13
|
- Rust stable for backend development.
|
||||||
- SSH key: `~/.ssh/archipelago-deploy`
|
- Linux with Podman, systemd, and Nginx for host integration work.
|
||||||
|
- Debian 13 is the target runtime for release validation.
|
||||||
|
|
||||||
### Local Frontend Development
|
### Local Frontend Development
|
||||||
|
|
||||||
@ -86,17 +87,18 @@ npm install
|
|||||||
npm start # Vite dev server on :8100, mock backend on :5959
|
npm start # Vite dev server on :8100, mock backend on :5959
|
||||||
```
|
```
|
||||||
|
|
||||||
The dev server at `http://localhost:8100` uses a mock backend. Login with `password123`.
|
The dev server at `http://localhost:8100` uses a mock backend.
|
||||||
|
|
||||||
### Deploying Changes
|
### Deploying Changes
|
||||||
|
|
||||||
**Never build Rust on macOS.** The deploy script rsyncs source to the Linux server and builds there.
|
Release and host-integration builds should run on Linux. The deploy script rsyncs
|
||||||
|
source to a configured Linux target and builds there.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Deploy to live server (builds backend + frontend, restarts services)
|
# Deploy to the configured primary target (builds backend + frontend, restarts services)
|
||||||
./scripts/deploy-to-target.sh --live
|
./scripts/deploy-to-target.sh --live
|
||||||
|
|
||||||
# Deploy to both servers
|
# Deploy to both configured targets
|
||||||
./scripts/deploy-to-target.sh --both
|
./scripts/deploy-to-target.sh --both
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -114,14 +116,16 @@ The deploy script:
|
|||||||
# Frontend tests
|
# Frontend tests
|
||||||
cd neode-ui && npm test
|
cd neode-ui && npm test
|
||||||
|
|
||||||
# Backend tests (on dev server via SSH)
|
# Backend tests
|
||||||
ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228 \
|
cd core && cargo test --all-features
|
||||||
"cd ~/archy/core && cargo test --all-features"
|
|
||||||
|
|
||||||
# Both
|
# Both
|
||||||
./scripts/run-tests.sh
|
./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
|
## Adding a New RPC Endpoint
|
||||||
|
|
||||||
### 1. Create the Handler
|
### 1. Create the Handler
|
||||||
@ -200,7 +204,7 @@ async myAction(params: { name: string }): Promise<{ ok: boolean; result: string
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/deploy-to-target.sh --live
|
./scripts/deploy-to-target.sh --live
|
||||||
curl -X POST http://192.168.1.228/rpc/v1 \
|
curl -X POST http://<node-host>/rpc/v1 \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-b "archipelago_session=YOUR_SESSION" \
|
-b "archipelago_session=YOUR_SESSION" \
|
||||||
-d '{"method":"mymodule.action","params":{"name":"test"}}'
|
-d '{"method":"mymodule.action","params":{"name":"test"}}'
|
||||||
@ -309,5 +313,5 @@ mod tests {
|
|||||||
2. Make changes following the standards above
|
2. Make changes following the standards above
|
||||||
3. Test locally: `cd neode-ui && npm test`
|
3. Test locally: `cd neode-ui && npm test`
|
||||||
4. Deploy to dev server: `./scripts/deploy-to-target.sh --live`
|
4. Deploy to dev server: `./scripts/deploy-to-target.sh --live`
|
||||||
5. Verify at `http://192.168.1.228`
|
5. Verify on your configured development target
|
||||||
6. Commit with conventional format: `feat: add my feature`
|
6. Commit with conventional format: `feat: add my feature`
|
||||||
|
|||||||
252
docs/nostr-git-source-hosting.md
Normal file
252
docs/nostr-git-source-hosting.md
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
# 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?
|
||||||
@ -443,6 +443,86 @@ free -h
|
|||||||
- Check Nginx WebSocket proxy config: `/etc/nginx/sites-available/archipelago` must include `proxy_set_header Upgrade $http_upgrade`
|
- Check Nginx WebSocket proxy config: `/etc/nginx/sites-available/archipelago` must include `proxy_set_header Upgrade $http_upgrade`
|
||||||
- If on WiFi, try wired Ethernet for more stable connectivity
|
- If on WiFi, try wired Ethernet for more stable connectivity
|
||||||
|
|
||||||
|
### 21. LoRa radio firmware flash failed / board unresponsive
|
||||||
|
|
||||||
|
**Symptoms**: The "Erase & Flash Now" flow in the mesh hot-swap modal reports
|
||||||
|
an error, or the radio no longer enumerates as a serial device after a flash
|
||||||
|
attempt.
|
||||||
|
|
||||||
|
**Diagnosis**:
|
||||||
|
```bash
|
||||||
|
# Poll the flash job's last-known stage/error directly
|
||||||
|
curl -s http://localhost:5678/rpc/v1 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"method":"mesh.flash-status","params":{}}'
|
||||||
|
|
||||||
|
# Confirm the board is still enumerating at all
|
||||||
|
ls -la /dev/ttyUSB* /dev/ttyACM* /dev/mesh-radio 2>&1
|
||||||
|
|
||||||
|
# esptool/rnodeconf binaries present?
|
||||||
|
which esptool; ls -la /usr/local/bin/archy-rnodeconf
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
- A failure during `erasing`/`writing` (MeshCore/Meshtastic) or
|
||||||
|
`autoinstalling` (Reticulum) can leave the chip erased or half-written —
|
||||||
|
this is expected risk of the "always erase first" default, not a bug.
|
||||||
|
- Heltec V3/V4 boards can be forced back into bootloader mode manually: hold
|
||||||
|
**BOOT**, tap **RST**, then release **BOOT** — this puts the chip in a
|
||||||
|
state esptool can always talk to, regardless of what firmware (if any) is
|
||||||
|
currently on it.
|
||||||
|
- With the board in bootloader mode, a manual recovery flash can be run
|
||||||
|
directly over SSH without the UI:
|
||||||
|
```bash
|
||||||
|
esptool --chip esp32s3 --port /dev/ttyACM0 erase_flash
|
||||||
|
esptool --chip esp32s3 --port /dev/ttyACM0 write_flash 0x0 <known-good-image.bin>
|
||||||
|
```
|
||||||
|
- For Reticulum/RNode boards, the equivalent manual recovery is
|
||||||
|
`archy-rnodeconf /dev/ttyACM0 --autoinstall` (or `/usr/local/bin/archy-rnodeconf`
|
||||||
|
if it's not on `PATH`) — it re-runs the same fetch+erase+flash+bootstrap
|
||||||
|
sequence the UI triggers.
|
||||||
|
- If `esptool`/`archy-rnodeconf` are missing entirely, they should have been
|
||||||
|
installed by the last `self-update.sh` run — check
|
||||||
|
`sudo journalctl -u archipelago-update` for install failures, or install
|
||||||
|
`esptool` via `sudo apt-get install esptool` directly.
|
||||||
|
- Once a fresh image is confirmed written, unplug/replug the radio (or wait
|
||||||
|
for the next detection poll) — the hot-swap modal re-probes automatically
|
||||||
|
and shows whatever firmware is actually on the board now.
|
||||||
|
|
||||||
|
**Known incident (2026-07-23) — reconnect storm / device boot-loop after a
|
||||||
|
failed flash**: a real Heltec V3 got stuck cycling "connect → partial
|
||||||
|
handshake → drop" every 5-15s for 5+ minutes after a `mesh.flash-device`
|
||||||
|
attempt failed with `Reading firmware download stream`. Root cause was two
|
||||||
|
compounding issues, both now fixed:
|
||||||
|
1. `spawn_mesh_listener`'s reconnect backoff (`core/archipelago/src/mesh/listener/mod.rs`)
|
||||||
|
reset to its 5s minimum any time the prior session had been `device_connected`
|
||||||
|
at all, even for under a second — so a device that connects-then-drops
|
||||||
|
repeatedly never actually backed off. Every retry's `open()` toggles
|
||||||
|
DTR/RTS, which resets many ESP32 boards' MCU (native-USB *and*
|
||||||
|
CP2102/CH340 auto-reset-circuit boards), so the aggressive retries were
|
||||||
|
themselves *causing* the boot loop, not just observing one. Fixed by only
|
||||||
|
resetting backoff when a session ran for at least `STABLE_SESSION_THRESHOLD`
|
||||||
|
(20s) — see that constant's doc comment.
|
||||||
|
2. `mesh::flash::start_flash_job`'s post-completion handler auto-resumed the
|
||||||
|
listener unconditionally, even after a *failed* flash, immediately
|
||||||
|
re-entering the reconnect loop above with no cooldown. Fixed: on failure
|
||||||
|
the listener is now deliberately left stopped (reconnect manually via the
|
||||||
|
UI once the board is confirmed alive); on success there's a 5s settle
|
||||||
|
delay before resuming, so the board finishes booting from the flash
|
||||||
|
tool's own reset before Archipelago starts probing it again.
|
||||||
|
3. Separately, the download itself was failing because `mesh::flash`'s HTTP
|
||||||
|
client had a blanket 30s request timeout that covered the *entire*
|
||||||
|
download (including streaming a 170MB Meshtastic zip), not just
|
||||||
|
connection setup — fixed with a per-chunk stall timeout instead of a
|
||||||
|
fixed total-transfer cap.
|
||||||
|
|
||||||
|
If this symptom recurs (rapid repeating `mesh::serial: Opened serial
|
||||||
|
port... Starting Meshcore handshake` lines in `journalctl -u archipelago`
|
||||||
|
without a `LoRa firmware flash` job in progress), it's a NEW instance of the
|
||||||
|
same class of bug, not the one above — check whether backoff is actually
|
||||||
|
escalating (`Mesh session error: ... (retry in Xs)` — X should grow past 5s
|
||||||
|
within a few cycles) before assuming it's flashing-related.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## General Maintenance
|
## General Maintenance
|
||||||
|
|||||||
@ -365,6 +365,11 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
openssl \
|
openssl \
|
||||||
chrony \
|
chrony \
|
||||||
|
iputils-ping \
|
||||||
|
esptool \
|
||||||
|
python3-venv \
|
||||||
|
binutils \
|
||||||
|
libpython3.13 \
|
||||||
locales \
|
locales \
|
||||||
console-setup \
|
console-setup \
|
||||||
keyboard-configuration \
|
keyboard-configuration \
|
||||||
@ -2834,7 +2839,7 @@ After=network.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
Environment=ANTHROPIC_API_KEY=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
|
Environment=ANTHROPIC_API_KEY=
|
||||||
ExecStart=/usr/bin/python3 /opt/archipelago/claude-api-proxy.py
|
ExecStart=/usr/bin/python3 /opt/archipelago/claude-api-proxy.py
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@ -83,7 +83,7 @@ QEMU_ARGS=(
|
|||||||
|
|
||||||
# Display mode
|
# Display mode
|
||||||
if [ "$NOGRAPHIC" = true ]; then
|
if [ "$NOGRAPHIC" = true ]; then
|
||||||
QEMU_ARGS+=(-nographic -append "console=ttyS0")
|
QEMU_ARGS+=(-display none)
|
||||||
else
|
else
|
||||||
QEMU_ARGS+=(-vga virtio -display default)
|
QEMU_ARGS+=(-vga virtio -display default)
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -31,7 +31,13 @@ ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:a
|
|||||||
# "-" so a missing/failed guard can never block the service itself.
|
# "-" so a missing/failed guard can never block the service itself.
|
||||||
ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh
|
ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh
|
||||||
ExecStart=/usr/local/bin/archipelago
|
ExecStart=/usr/local/bin/archipelago
|
||||||
Restart=on-failure
|
# always (not on-failure): the OTA restart path once stopped the daemon
|
||||||
|
# cleanly and the queued start never fired (framework-pt, v1.7.114->115,
|
||||||
|
# 2026-07-26) — the node sat dead all night behind "server starting up".
|
||||||
|
# Restart=always self-heals any lost start job; an explicit
|
||||||
|
# `systemctl stop` is still honored (systemd never auto-restarts after
|
||||||
|
# a manual stop).
|
||||||
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
WatchdogSec=300
|
WatchdogSec=300
|
||||||
TimeoutStartSec=300
|
TimeoutStartSec=300
|
||||||
|
|||||||
39
neode-ui/THIRD-PARTY-LICENSES.md
Normal file
39
neode-ui/THIRD-PARTY-LICENSES.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# 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.
|
||||||
@ -3453,12 +3453,26 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12345678901234567890123456789012345678901234567890abcdef12', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, status: 'active', channel_point: randomHex(32) + ':1', peer_alias: 'WalletOfSatoshi' },
|
{ chan_id: '840921088114689', remote_pubkey: '03abcdef12345678901234567890123456789012345678901234567890abcdef12', capacity: 2000000, local_balance: 1200000, remote_balance: 800000, active: true, status: 'active', channel_point: randomHex(32) + ':1', peer_alias: 'WalletOfSatoshi' },
|
||||||
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98765432109876543210987654321098765432109876543210fedcba98', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Voltage' },
|
{ chan_id: '840921088114690', remote_pubkey: '02fedcba98765432109876543210987654321098765432109876543210fedcba98', capacity: 10000000, local_balance: 4500000, remote_balance: 5500000, active: true, status: 'active', channel_point: randomHex(32) + ':0', peer_alias: 'Voltage' },
|
||||||
{ chan_id: '840921088114691', remote_pubkey: '03456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: false, status: 'inactive', channel_point: randomHex(32) + ':0', peer_alias: 'Kraken' },
|
{ chan_id: '840921088114691', remote_pubkey: '03456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123', capacity: 3000000, local_balance: 100000, remote_balance: 2900000, active: false, status: 'inactive', channel_point: randomHex(32) + ':0', peer_alias: 'Kraken' },
|
||||||
|
{ chan_id: '', remote_pubkey: '028d98b9969fbed53784a36617eb489a59ab6dc9b9d77fcdca9ff55307cd98e3c4', capacity: 500000, local_balance: 500000, remote_balance: 0, active: false, status: 'pending_open', channel_point: randomHex(32) + ':0', peer_alias: 'ACINQ' },
|
||||||
|
{ chan_id: '', remote_pubkey: '02c16cca44562b590dd279c942200bdccfd4f990c3a69fad620c10ef2f8228eaff', capacity: 800000, local_balance: 350000, remote_balance: 450000, active: false, status: 'closing', channel_point: randomHex(32) + ':0', closing_txid: randomHex(32), peer_alias: 'Bitrefill' },
|
||||||
]
|
]
|
||||||
return res.json({
|
return res.json({
|
||||||
result: {
|
result: {
|
||||||
channels,
|
channels,
|
||||||
total_outbound: channels.reduce((s, c) => s + c.local_balance, 0),
|
// Totals sum open channels only, matching the real backend.
|
||||||
total_inbound: channels.reduce((s, c) => s + c.remote_balance, 0),
|
total_outbound: channels.filter(c => ['active', 'inactive'].includes(c.status)).reduce((s, c) => s + c.local_balance, 0),
|
||||||
|
total_inbound: channels.filter(c => ['active', 'inactive'].includes(c.status)).reduce((s, c) => s + c.remote_balance, 0),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'lnd.closedchannels': {
|
||||||
|
return res.json({
|
||||||
|
result: {
|
||||||
|
channels: [
|
||||||
|
{ chan_id: '840921088110001', remote_pubkey: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f', capacity: 1000000, settled_balance: 612000, close_type: 'COOPERATIVE_CLOSE', closing_tx_hash: randomHex(32), channel_point: randomHex(32) + ':0', close_height: 903112 },
|
||||||
|
{ chan_id: '840921088110002', remote_pubkey: '0298f6074a454a1f5345cb2a7c6f9fce206cd0bf675d177cdbf0ca7508dd28852f', capacity: 250000, settled_balance: 0, close_type: 'REMOTE_FORCE_CLOSE', closing_tx_hash: randomHex(32), channel_point: randomHex(32) + ':1', close_height: 897540 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -3518,6 +3532,13 @@ app.post('/rpc/v1', (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'lnd.estimatefee': {
|
||||||
|
// ~141 vB P2WPKH spend at a rate that scales with urgency
|
||||||
|
const target = params?.target_conf || 6
|
||||||
|
const rate = target <= 1 ? 22 : target <= 6 ? 8 : 2
|
||||||
|
return res.json({ result: { fee_sat: rate * 141, sat_per_vbyte: rate } })
|
||||||
|
}
|
||||||
|
|
||||||
case 'lnd.decodepayreq': {
|
case 'lnd.decodepayreq': {
|
||||||
return res.json({
|
return res.json({
|
||||||
result: {
|
result: {
|
||||||
|
|||||||
60
neode-ui/package-lock.json
generated
60
neode-ui/package-lock.json
generated
@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.7.112-alpha",
|
"version": "1.7.115-alpha",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.7.112-alpha",
|
"version": "1.7.115-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@scure/bip39": "^2.2.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
@ -149,6 +150,7 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@ -1810,6 +1812,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@ -1833,6 +1836,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@ -2882,6 +2886,18 @@
|
|||||||
"url": "https://opencollective.com/js-sdsl"
|
"url": "https://opencollective.com/js-sdsl"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@ -3538,6 +3554,28 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@scure/base": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scure/bip39": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.2.0",
|
||||||
|
"@scure/base": "2.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
|
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
|
||||||
"version": "3.0.0-pre1",
|
"version": "3.0.0-pre1",
|
||||||
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
|
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
|
||||||
@ -3885,6 +3923,7 @@
|
|||||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/geojson": "*"
|
"@types/geojson": "*"
|
||||||
}
|
}
|
||||||
@ -3934,6 +3973,7 @@
|
|||||||
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
"integrity": "sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cac": "^6.7.14",
|
"cac": "^6.7.14",
|
||||||
"colorette": "^2.0.20",
|
"colorette": "^2.0.20",
|
||||||
@ -4434,6 +4474,7 @@
|
|||||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@ -4919,6 +4960,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@ -5937,6 +5979,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@ -8129,6 +8172,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@ -8178,6 +8222,7 @@
|
|||||||
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssstyle": "^4.1.0",
|
"cssstyle": "^4.1.0",
|
||||||
"data-urls": "^5.0.0",
|
"data-urls": "^5.0.0",
|
||||||
@ -8280,7 +8325,8 @@
|
|||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@ -9036,6 +9082,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@ -9697,6 +9744,7 @@
|
|||||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@ -10952,6 +11000,7 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -11207,6 +11256,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@ -11448,6 +11498,7 @@
|
|||||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@ -11610,6 +11661,7 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -11623,6 +11675,7 @@
|
|||||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/expect": "3.2.4",
|
"@vitest/expect": "3.2.4",
|
||||||
@ -11715,6 +11768,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
|
||||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.30",
|
"@vue/compiler-dom": "3.5.30",
|
||||||
"@vue/compiler-sfc": "3.5.30",
|
"@vue/compiler-sfc": "3.5.30",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.112-alpha",
|
"version": "1.7.116-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
@ -24,6 +24,7 @@
|
|||||||
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
|
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@scure/bip39": "^2.2.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user