Compare commits
226 Commits
v1.7.99-al
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12e7990b10 | ||
|
|
f392670e2a | ||
|
|
a57ae388ec | ||
|
|
fbfeeeb0f5 | ||
|
|
b4531bb4fc | ||
|
|
2ac0711f8e | ||
|
|
a91814641e | ||
|
|
c2c4b5af7d | ||
|
|
daf750688d | ||
|
|
4b7cbf2b5e | ||
|
|
df9d3a55be | ||
|
|
7b0748c868 | ||
|
|
810127fd3e | ||
|
|
067002b04b | ||
|
|
20f762cb2c | ||
|
|
11155055aa | ||
|
|
f4f45c1a09 | ||
|
|
ed1352d3a3 | ||
|
|
095a76cd20 | ||
|
|
3c7c04a662 | ||
|
|
11038cdcc9 | ||
|
|
169ff2e2cd | ||
|
|
da20f67462 | ||
|
|
6aa74c7386 | ||
|
|
3cea7dd6c5 | ||
|
|
d7c6f8c348 | ||
|
|
83344b9f3a | ||
|
|
05c22b6085 | ||
|
|
6734947c3e | ||
|
|
4519dbf04f | ||
|
|
a38c9d5f29 | ||
|
|
f9a6ae3f32 | ||
|
|
fd3a4ee4ef | ||
|
|
38d2bbf570 | ||
|
|
a90fea80ed | ||
|
|
389e602097 | ||
|
|
5677f9cca1 | ||
|
|
fc64b422e7 | ||
|
|
07b9b5a3aa | ||
|
|
ac59771560 | ||
|
|
d1f9e9ce88 | ||
|
|
58847fc3d7 | ||
|
|
a3e09eab57 | ||
|
|
43934eefa5 | ||
|
|
80146f4476 | ||
|
|
9f17ba6867 | ||
|
|
67426c0d41 | ||
|
|
b7d9210784 | ||
|
|
292a2650df | ||
|
|
71cc9ac46a | ||
|
|
2ebcd8f9a8 | ||
|
|
3515344800 | ||
|
|
670ebb0666 | ||
|
|
0a8db9044f | ||
|
|
43e700498b | ||
|
|
89d397bb74 | ||
|
|
41e7f500f8 | ||
|
|
a721532f55 | ||
|
|
80f49cac1c | ||
|
|
2d8ade629b | ||
|
|
0406af522c | ||
|
|
57a69257c4 | ||
|
|
d1cd42c821 | ||
|
|
3e3016f2bd | ||
|
|
7d89b4d8b2 | ||
|
|
15f65428b8 | ||
|
|
36015a19fe | ||
|
|
e57514b690 | ||
|
|
4346007d37 | ||
|
|
44f7af2017 | ||
|
|
9670af62b6 | ||
|
|
a8b9b0f5e8 | ||
|
|
3c36cf1c40 | ||
|
|
c4cd5fdc90 | ||
|
|
ccb594fb85 | ||
|
|
deff380191 | ||
|
|
5c43e12782 | ||
|
|
e825bbed73 | ||
|
|
0dd19f0721 | ||
|
|
ae47897601 | ||
|
|
256d354048 | ||
|
|
2a249b8a48 | ||
|
|
a7c7c44843 | ||
|
|
2afd18c6de | ||
|
|
6511754545 | ||
|
|
92d7f52dd6 | ||
|
|
57a013bc66 | ||
|
|
0f05f73a23 | ||
|
|
c8acc84506 | ||
|
|
8355453a7e | ||
|
|
98f4fa44a8 | ||
|
|
22b05de6d9 | ||
|
|
5b75310e0b | ||
|
|
27299ea687 | ||
|
|
7efebb4a8c | ||
|
|
445f08a5c1 | ||
|
|
892ff083c4 | ||
|
|
1b7335f4ac | ||
|
|
c991e61a8f | ||
|
|
8893055810 | ||
|
|
b99c4a604f | ||
|
|
cf5f6d021a | ||
|
|
53b8e47f1d | ||
|
|
a0f70b3949 | ||
|
|
f4727bfdb3 | ||
|
|
452f05d849 | ||
|
|
4cc808c73e | ||
|
|
de7d3d83dc | ||
|
|
76b23adcc0 | ||
|
|
c9341baa35 | ||
|
|
79c3769542 | ||
|
|
47a5148865 | ||
|
|
df2ae3d7d8 | ||
|
|
3f411c1d10 | ||
|
|
4d0c2d6717 | ||
|
|
2cffa79d9d | ||
|
|
b090235b04 | ||
|
|
2715f2d847 | ||
|
|
6e49ce6f88 | ||
|
|
760a32bccf | ||
|
|
29cd167894 | ||
|
|
2dad64b2ee | ||
|
|
470e3c649a | ||
|
|
a111d79a05 | ||
|
|
47026fae30 | ||
|
|
d6fa262d69 | ||
|
|
e2a012d086 | ||
|
|
e4d3f94913 | ||
|
|
ff78b31212 | ||
|
|
fdb465f8ac | ||
|
|
ff8f11b87e | ||
|
|
b73084dbb0 | ||
|
|
84031e6209 | ||
|
|
9c45f718a2 | ||
|
|
8bdc857911 | ||
|
|
d2f7c4abf3 | ||
|
|
b1eea8c053 | ||
|
|
b94b61f640 | ||
|
|
ccb5b7ca39 | ||
|
|
955c54b713 | ||
|
|
4c1a4e5976 | ||
|
|
b0b54a96fa | ||
|
|
f0c6b79d1a | ||
|
|
b1f175b927 | ||
|
|
c548705147 | ||
|
|
f160e0c404 | ||
|
|
d5ef45731a | ||
|
|
0860dfacc7 | ||
|
|
9e6c5370fc | ||
|
|
011081d180 | ||
|
|
7bfbe8fe40 | ||
|
|
220666d3a9 | ||
|
|
192238cbb8 | ||
|
|
03a4ee1b30 | ||
|
|
db7d424bff | ||
|
|
b0c9bd2a0c | ||
|
|
63b98599e8 | ||
|
|
409543c41e | ||
|
|
d59cf6d299 | ||
|
|
12f54e390d | ||
|
|
242baf5deb | ||
|
|
0ab160b5c3 | ||
|
|
a6957a48f7 | ||
|
|
2761f0d70f | ||
|
|
a8c668ee0a | ||
|
|
8f06d88fbf | ||
|
|
b3633ec525 | ||
|
|
f92e442bfc | ||
|
|
5f7e8dca80 | ||
|
|
d00d1b20d7 | ||
|
|
b00c5247f5 | ||
|
|
e39e0370e2 | ||
|
|
3b9eb35a37 | ||
|
|
011f6559e1 | ||
|
|
979e6525b7 | ||
|
|
af816c61a5 | ||
|
|
63611a4453 | ||
|
|
7831e68d13 | ||
|
|
0f2e6f6aaf | ||
|
|
5afe9e4aec | ||
|
|
857dc66240 | ||
|
|
75f7020e3e | ||
|
|
75666cdc31 | ||
|
|
8977ea92e8 | ||
|
|
ca38f5d8f4 | ||
|
|
d72cb57545 | ||
|
|
dc2cdca549 | ||
|
|
ee01ab9427 | ||
|
|
cebbde7bde | ||
|
|
a0b80dd27d | ||
|
|
839da80e0b | ||
|
|
f0e9343d74 | ||
|
|
bf6d98195e | ||
|
|
846b2d9646 | ||
|
|
6df776b25a | ||
|
|
1074f89c47 | ||
|
|
726cc132af | ||
|
|
078c1793a9 | ||
|
|
b83e2c2f37 | ||
|
|
a2fa57456d | ||
|
|
64937df8a2 | ||
|
|
6527e66c07 | ||
|
|
07b611d07d | ||
|
|
dcedf9582a | ||
|
|
f2c420d9c0 | ||
|
|
68cd1c120a | ||
|
|
993f30456f | ||
|
|
aa95e42383 | ||
|
|
75e470bfa4 | ||
|
|
0ac67f5092 | ||
|
|
837cc02812 | ||
|
|
1bce694ebb | ||
|
|
c4855526fe | ||
|
|
298595069d | ||
|
|
f636c5d505 | ||
|
|
0f43870e6c | ||
|
|
d1fbcd9b0a | ||
|
|
b5a9deb815 | ||
|
|
d0ca53501c | ||
|
|
790da4bd0f | ||
|
|
cc2e055e09 | ||
|
|
549c6180a2 | ||
|
|
ec644ab90f | ||
|
|
f0fdc23cc9 | ||
|
|
9f2edf6b7a | ||
|
|
3a21243be7 |
@ -7,6 +7,14 @@
|
|||||||
# Allow demo assets (AIUI pre-built dist)
|
# Allow demo assets (AIUI pre-built dist)
|
||||||
!demo/
|
!demo/
|
||||||
|
|
||||||
|
# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*)
|
||||||
|
!docker/
|
||||||
|
docker/*
|
||||||
|
!docker/bitcoin-ui/
|
||||||
|
!docker/electrs-ui/
|
||||||
|
!docker/lnd-ui/
|
||||||
|
!docker/fedimint-ui/
|
||||||
|
|
||||||
# Allow backend source for ISO source builds
|
# Allow backend source for ISO source builds
|
||||||
!core/
|
!core/
|
||||||
!scripts/
|
!scripts/
|
||||||
|
|||||||
51
.githooks/pre-push
Executable file
51
.githooks/pre-push
Executable file
@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Keep the served companion APK in sync with main on every push.
|
||||||
|
#
|
||||||
|
# When a push to main includes Android changes, rebuild the APK, refresh
|
||||||
|
# neode-ui/public/packages/archipelago-companion.apk, commit it, and ask
|
||||||
|
# you to push again (so the refreshed APK rides along in the same push).
|
||||||
|
#
|
||||||
|
# Enable once per clone: git config core.hooksPath .githooks
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
# ship-companion.sh already (re)published the APK for this push — don't redo it.
|
||||||
|
[ -n "${SHIP_COMPANION:-}" ] && exit 0
|
||||||
|
|
||||||
|
PUSH_MAIN=0; RANGE_OLD=""; RANGE_NEW=""
|
||||||
|
while read -r _local_ref local_sha remote_ref remote_sha; do
|
||||||
|
if [ "${remote_ref##*/}" = "main" ]; then
|
||||||
|
PUSH_MAIN=1; RANGE_OLD="$remote_sha"; RANGE_NEW="$local_sha"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$PUSH_MAIN" = "1" ] || exit 0
|
||||||
|
|
||||||
|
# Loop-break: if the tip is already the auto APK commit, let the push proceed.
|
||||||
|
case "$(git log -1 --pretty=%s)" in
|
||||||
|
*"companion APK"*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Only rebuild when this push actually touches the Android app.
|
||||||
|
ZEROS="0000000000000000000000000000000000000000"
|
||||||
|
if [ -z "$RANGE_OLD" ] || [ "$RANGE_OLD" = "$ZEROS" ]; then
|
||||||
|
ANDROID_CHANGED=1
|
||||||
|
elif git diff --quiet "$RANGE_OLD" "$RANGE_NEW" -- Android/ 2>/dev/null; then
|
||||||
|
ANDROID_CHANGED=0
|
||||||
|
else
|
||||||
|
ANDROID_CHANGED=1
|
||||||
|
fi
|
||||||
|
[ "$ANDROID_CHANGED" = "1" ] || exit 0
|
||||||
|
|
||||||
|
bash scripts/publish-companion-apk.sh || exit 0
|
||||||
|
|
||||||
|
DEST="neode-ui/public/packages/archipelago-companion.apk"
|
||||||
|
if git diff --cached --quiet -- "$DEST"; then
|
||||||
|
exit 0 # APK unchanged — nothing to do
|
||||||
|
fi
|
||||||
|
|
||||||
|
git commit -q -m "chore(android): update companion APK download [skip ci]"
|
||||||
|
echo "" >&2
|
||||||
|
echo "▶ Companion APK rebuilt and committed. Run your push again to include it." >&2
|
||||||
|
exit 1
|
||||||
67
.github/workflows/demo-images.yml
vendored
Normal file
67
.github/workflows/demo-images.yml
vendored
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
name: Demo images
|
||||||
|
|
||||||
|
# Builds and pushes the public-demo images on every change to the UI / mock
|
||||||
|
# backend, so the separated `archy-demo` Portainer stack auto-tracks the real
|
||||||
|
# code (see demo-deploy/ and docs/demo-deployment-design.md).
|
||||||
|
#
|
||||||
|
# Required repo configuration:
|
||||||
|
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
|
||||||
|
# secrets.DEMO_REGISTRY_USER
|
||||||
|
# secrets.DEMO_REGISTRY_TOKEN
|
||||||
|
# Optional:
|
||||||
|
# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'neode-ui/**'
|
||||||
|
- 'docker-compose.demo.yml'
|
||||||
|
- '.github/workflows/demo-images.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build & push demo images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Skip cleanly on forks / before registry config is set.
|
||||||
|
if: ${{ vars.DEMO_REGISTRY != '' }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}
|
||||||
|
username: ${{ secrets.DEMO_REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.DEMO_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build & push backend
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: neode-ui/Dockerfile.backend
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo
|
||||||
|
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Build & push web
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: neode-ui/Dockerfile.web
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
VITE_DEMO=1
|
||||||
|
tags: |
|
||||||
|
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||||
|
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Trigger Portainer redeploy
|
||||||
|
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
|
||||||
|
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
|
||||||
5
Android/.gitignore
vendored
5
Android/.gitignore
vendored
@ -14,3 +14,8 @@ local.properties
|
|||||||
*.aab
|
*.aab
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
|
# Exception: the repo-dedicated *debug* keystore is committed on purpose so every
|
||||||
|
# machine (and the published companion download) signs debug builds identically —
|
||||||
|
# updates then install over the top without an uninstall. Debug keys are not
|
||||||
|
# secret (well-known password "android"); never commit a real release keystore.
|
||||||
|
!/app/debug.keystore
|
||||||
|
|||||||
94
Android/COMPANION_RELEASE.md
Normal file
94
Android/COMPANION_RELEASE.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# Companion App — Build, Ship & "App Not Installed" Runbook
|
||||||
|
|
||||||
|
Canonical procedure for releasing the Archipelago Companion Android app and for
|
||||||
|
debugging install failures. Read this before touching the companion release flow.
|
||||||
|
Hard lessons from 2026-06-26 are baked in below — don't relearn them.
|
||||||
|
|
||||||
|
## Ship the companion (the only sanctioned way)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./Android/ship-companion.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This calls `scripts/publish-companion-apk.sh` (the single source of truth, also
|
||||||
|
used by the `.githooks/pre-push` hook), which:
|
||||||
|
|
||||||
|
1. **Removes/rejects resource dirs whose names contain spaces.** Empty stray
|
||||||
|
`mipmap-* NNN` dirs (left by icon-export tools) break a *clean* build with
|
||||||
|
`Invalid resource directory name`. Incremental builds hide them — clean builds
|
||||||
|
don't.
|
||||||
|
2. **Always does a CLEAN build** (`:app:clean :app:assembleDebug`).
|
||||||
|
3. **Forces v1 + v2 + v3 signing** via `zipalign` + `apksigner`.
|
||||||
|
4. **Verifies all three schemes** (`apksigner verify --min-sdk-version 21`) and
|
||||||
|
**aborts** if any is missing.
|
||||||
|
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).
|
||||||
|
|
||||||
|
**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
|
||||||
|
APK shipped.
|
||||||
|
|
||||||
|
### Bump the version first
|
||||||
|
Edit `Android/app/build.gradle.kts` — `versionCode` (must strictly increase) and
|
||||||
|
`versionName`. The committed value can drift AHEAD of what's actually built into
|
||||||
|
the served APK, so verify the served APK's real version after shipping:
|
||||||
|
`aapt2 dump badging neode-ui/public/packages/archipelago-companion.apk | grep version`.
|
||||||
|
|
||||||
|
## Signing facts (important)
|
||||||
|
|
||||||
|
- Debug builds are signed with the **committed** `Android/app/debug.keystore`
|
||||||
|
(store/key pass `android`, alias `androiddebugkey`) so every machine and the
|
||||||
|
served download share ONE signing key. Cert SHA-256: `D6:22:E0:7E:…:66:4D`.
|
||||||
|
- **AGP silently ignores `enableV1Signing = true` for `minSdk ≥ 24`**, so a plain
|
||||||
|
gradle build produces a **v2-only** APK. The `apksigner` step in the publish
|
||||||
|
script is what actually guarantees v1+v2+v3 — do not remove it.
|
||||||
|
- **Changing the signing key forces every existing install to be uninstalled
|
||||||
|
once.** Android blocks in-place upgrades across different signatures. Treat the
|
||||||
|
keystore as permanent; never regenerate it casually.
|
||||||
|
|
||||||
|
## Debugging "App Not Installed" — DIAGNOSE FIRST
|
||||||
|
|
||||||
|
Do **not** theorize about signing schemes / OEM quirks. Get the real reason:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb install ~/Desktop/archipelago-companion-<ver>.apk
|
||||||
|
# -> Failure [INSTALL_FAILED_<REASON>: ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
Map the reason:
|
||||||
|
|
||||||
|
| `INSTALL_FAILED_*` | Cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `UPDATE_INCOMPATIBLE … signatures do not match` | Old install signed with a **different key** (e.g. pre-shared-keystore per-machine key `58:31:12…`). | Uninstall the old package, then install. **One-time** per device after a key change. |
|
||||||
|
| `INVALID_APK` / parse error | Corrupt/incomplete download or bad signing. | Re-download; re-run the publish script. |
|
||||||
|
| `INSUFFICIENT_STORAGE` | Storage. | Free space. |
|
||||||
|
| `OLDER_SDK` | Device below `minSdk` (26 = Android 8.0). | Unsupported device. |
|
||||||
|
|
||||||
|
> A manual uninstall on the phone may NOT clear `UPDATE_INCOMPATIBLE` if the
|
||||||
|
> package is registered under another user/profile — `pm path <pkg>` under user 0
|
||||||
|
> can show nothing while the conflict persists. `adb uninstall <pkg>` clears it
|
||||||
|
> across all users.
|
||||||
|
|
||||||
|
## Phone / adb safety (non-negotiable)
|
||||||
|
|
||||||
|
When acting on the user's physical phone, be surgical — the user once had all
|
||||||
|
home-screen app layouts wiped by an over-broad action.
|
||||||
|
|
||||||
|
- Default to **read-only** adb (`devices`, `getprop`, `pm path/list`, `dumpsys`).
|
||||||
|
- Mutations (`adb install`, `adb uninstall com.archipelago.app.debug`) only with
|
||||||
|
explicit go-ahead and **scoped to our exact package** — echo it first.
|
||||||
|
- **Never** run launcher/system resets: no `pm clear` on launchers, no
|
||||||
|
`reset-permissions`, no factory wipe, no uninstalling apps you didn't build.
|
||||||
|
|
||||||
|
## Verify the published download after shipping
|
||||||
|
|
||||||
|
The download served to nodes is Gitea raw-on-main. Confirm the live bytes match
|
||||||
|
what you built and signed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SERVED=neode-ui/public/packages/archipelago-companion.apk
|
||||||
|
URL=http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/$SERVED
|
||||||
|
curl -sS -o /tmp/live.apk "$URL"
|
||||||
|
shasum -a 256 "$SERVED" /tmp/live.apk # must match
|
||||||
|
apksigner verify -v --min-sdk-version 21 /tmp/live.apk | grep -i "scheme" # v1/v2/v3 = true
|
||||||
|
```
|
||||||
@ -11,15 +11,41 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 6
|
versionCode = 16
|
||||||
versionName = "0.4.2"
|
versionName = "0.4.12"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
// Repo-dedicated debug keystore (committed at app/debug.keystore) so every
|
||||||
|
// machine — and the published companion download — signs debug builds with
|
||||||
|
// the SAME key. Without this, Gradle falls back to each machine's
|
||||||
|
// ~/.android/debug.keystore, so a build from a different machine has a
|
||||||
|
// different signature and the phone rejects the update ("App not installed").
|
||||||
|
getByName("debug") {
|
||||||
|
storeFile = file("debug.keystore")
|
||||||
|
storePassword = "android"
|
||||||
|
keyAlias = "androiddebugkey"
|
||||||
|
keyPassword = "android"
|
||||||
|
// Force both legacy JAR (v1) and APK Signature Scheme v2. AGP drops v1
|
||||||
|
// for minSdk>=24, but some OEM package installers (e.g. Samsung) reject
|
||||||
|
// a v2-only sideload with "App not installed" — keep v1 for max compat.
|
||||||
|
enableV1Signing = true
|
||||||
|
enableV2Signing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
// Separate app ID so a debug/test build installs alongside the
|
||||||
|
// release app instead of colliding on signature.
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
|
|||||||
BIN
Android/app/debug.keystore
Normal file
BIN
Android/app/debug.keystore
Normal file
Binary file not shown.
@ -18,7 +18,11 @@ data class ServerEntry(
|
|||||||
val useHttps: Boolean,
|
val useHttps: Boolean,
|
||||||
val port: String = "",
|
val port: String = "",
|
||||||
val password: String = "",
|
val password: String = "",
|
||||||
|
val name: String = "",
|
||||||
) {
|
) {
|
||||||
|
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
||||||
|
fun displayName(): String = name.ifBlank { address }
|
||||||
|
|
||||||
fun toUrl(): String {
|
fun toUrl(): String {
|
||||||
val scheme = if (useHttps) "https" else "http"
|
val scheme = if (useHttps) "https" else "http"
|
||||||
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
val portSuffix = if (port.isNotBlank()) ":$port" else ""
|
||||||
@ -31,7 +35,9 @@ data class ServerEntry(
|
|||||||
return "$scheme://$address$portSuffix"
|
return "$scheme://$address$portSuffix"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun serialize(): String = "$address|$useHttps|$port|$password"
|
// name is the trailing field so entries saved before naming existed
|
||||||
|
// (4 fields) still deserialize, with name defaulting to "".
|
||||||
|
fun serialize(): String = "$address|$useHttps|$port|$password|$name"
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun deserialize(raw: String): ServerEntry? {
|
fun deserialize(raw: String): ServerEntry? {
|
||||||
@ -42,6 +48,7 @@ data class ServerEntry(
|
|||||||
useHttps = parts[1].toBooleanStrictOrNull() ?: false,
|
useHttps = parts[1].toBooleanStrictOrNull() ?: false,
|
||||||
port = parts.getOrElse(2) { "" },
|
port = parts.getOrElse(2) { "" },
|
||||||
password = parts.getOrElse(3) { "" },
|
password = parts.getOrElse(3) { "" },
|
||||||
|
name = parts.getOrElse(4) { "" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -53,6 +60,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
private val activeHttpsKey = booleanPreferencesKey("active_https")
|
private val activeHttpsKey = booleanPreferencesKey("active_https")
|
||||||
private val activePortKey = stringPreferencesKey("active_port")
|
private val activePortKey = stringPreferencesKey("active_port")
|
||||||
private val activePasswordKey = stringPreferencesKey("active_password")
|
private val activePasswordKey = stringPreferencesKey("active_password")
|
||||||
|
private val activeNameKey = stringPreferencesKey("active_name")
|
||||||
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
private val savedServersKey = stringSetPreferencesKey("saved_servers")
|
||||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||||
|
|
||||||
@ -63,6 +71,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
useHttps = prefs[activeHttpsKey] ?: false,
|
useHttps = prefs[activeHttpsKey] ?: false,
|
||||||
port = prefs[activePortKey] ?: "",
|
port = prefs[activePortKey] ?: "",
|
||||||
password = prefs[activePasswordKey] ?: "",
|
password = prefs[activePasswordKey] ?: "",
|
||||||
|
name = prefs[activeNameKey] ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,6 +90,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs[activeHttpsKey] = server.useHttps
|
prefs[activeHttpsKey] = server.useHttps
|
||||||
prefs[activePortKey] = server.port
|
prefs[activePortKey] = server.port
|
||||||
prefs[activePasswordKey] = server.password
|
prefs[activePasswordKey] = server.password
|
||||||
|
prefs[activeNameKey] = server.name
|
||||||
}
|
}
|
||||||
addSavedServer(server)
|
addSavedServer(server)
|
||||||
}
|
}
|
||||||
@ -91,6 +101,7 @@ class ServerPreferences(private val context: Context) {
|
|||||||
prefs.remove(activeHttpsKey)
|
prefs.remove(activeHttpsKey)
|
||||||
prefs.remove(activePortKey)
|
prefs.remove(activePortKey)
|
||||||
prefs.remove(activePasswordKey)
|
prefs.remove(activePasswordKey)
|
||||||
|
prefs.remove(activeNameKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,10 +112,50 @@ class ServerPreferences(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a saved server in place. Matches the existing entry by connection
|
||||||
|
* identity (address/port/scheme) so edits that change the name or password —
|
||||||
|
* or that touch a legacy 4-field entry — still update the right record. If the
|
||||||
|
* edited server is also the active one, the active record is kept in sync.
|
||||||
|
*/
|
||||||
|
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
val current = prefs[savedServersKey] ?: emptySet()
|
||||||
|
val filtered = current.filterNot { raw ->
|
||||||
|
val e = ServerEntry.deserialize(raw)
|
||||||
|
e != null &&
|
||||||
|
e.address == original.address &&
|
||||||
|
e.port == original.port &&
|
||||||
|
e.useHttps == original.useHttps
|
||||||
|
}.toSet()
|
||||||
|
prefs[savedServersKey] = filtered + updated.serialize()
|
||||||
|
|
||||||
|
val isActive = prefs[activeAddressKey] == original.address &&
|
||||||
|
(prefs[activePortKey] ?: "") == original.port &&
|
||||||
|
(prefs[activeHttpsKey] ?: false) == original.useHttps
|
||||||
|
if (isActive) {
|
||||||
|
prefs[activeAddressKey] = updated.address
|
||||||
|
prefs[activeHttpsKey] = updated.useHttps
|
||||||
|
prefs[activePortKey] = updated.port
|
||||||
|
prefs[activePasswordKey] = updated.password
|
||||||
|
prefs[activeNameKey] = updated.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun removeSavedServer(server: ServerEntry) {
|
suspend fun removeSavedServer(server: ServerEntry) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = prefs[savedServersKey] ?: emptySet()
|
val current = prefs[savedServersKey] ?: emptySet()
|
||||||
prefs[savedServersKey] = current - server.serialize()
|
// Match by connection identity (address/port/scheme) rather than the
|
||||||
|
// exact serialized string, so a rename — or the legacy 4-field format
|
||||||
|
// saved before names existed — still removes the right entry.
|
||||||
|
prefs[savedServersKey] = current.filterNot { raw ->
|
||||||
|
val e = ServerEntry.deserialize(raw)
|
||||||
|
e != null &&
|
||||||
|
e.address == server.address &&
|
||||||
|
e.port == server.port &&
|
||||||
|
e.useHttps == server.useHttps
|
||||||
|
}.toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -108,7 +108,9 @@ private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) {
|
|||||||
.pointerInput(key) {
|
.pointerInput(key) {
|
||||||
detectTapGestures(onPress = {
|
detectTapGestures(onPress = {
|
||||||
p = true; onDir(key)
|
p = true; onDir(key)
|
||||||
job = scope.launch { delay(350); while (true) { onDir(key); delay(100) } }
|
// 500ms initial delay so a normal tap sends one key, not two
|
||||||
|
// (a touch tap often exceeds 350ms → doubled nav sound).
|
||||||
|
job = scope.launch { delay(500); while (true) { onDir(key); delay(100) } }
|
||||||
tryAwaitRelease(); p = false; job?.cancel()
|
tryAwaitRelease(); p = false; job?.cancel()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@ -83,13 +83,16 @@ val ClassicPalette = NESPalette(
|
|||||||
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
|
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Glassmorphism-black (OS design): translucent dark surfaces so the backdrop
|
||||||
|
// shows through the controller, subtle white-alpha borders, translucent-white
|
||||||
|
// buttons. Accents come from each button's ring.
|
||||||
val DarkPalette = NESPalette(
|
val DarkPalette = NESPalette(
|
||||||
body = NES.DarkBody, face = NES.DarkFace, ridge = NES.DarkRidge,
|
body = Color(0xA6121216), face = Color(0x8C0E0E12), ridge = Color(0x14FFFFFF),
|
||||||
label = NES.DarkLabel, labelMuted = NES.DarkLabelMuted,
|
label = Color(0xFF9A9A9A), labelMuted = Color(0xFF777777),
|
||||||
dpad = Color(0xFF080808), dpadHi = Color(0xFF141418),
|
dpad = Color(0xFF202024), dpadHi = Color(0xFF33333A),
|
||||||
btn = NES.DarkButtonMain, btnPress = NES.DarkButtonMainPress,
|
btn = Color(0x14FFFFFF), btnPress = Color(0x0AFFFFFF),
|
||||||
capsule = Color(0xFF121216), capsulePress = Color(0xFF0A0A0C),
|
capsule = Color(0x12FFFFFF), capsulePress = Color(0x08FFFFFF),
|
||||||
inlayBg = Color(0xFF060608), inlayBorder = Color(0xFF444448),
|
inlayBg = Color(0x990A0A0A), inlayBorder = Color(0x1FFFFFFF),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
|
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
|
||||||
@ -113,20 +116,10 @@ fun NESController(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFF0C0C0C)) // Slightly lighter than black for shadow visibility
|
|
||||||
.twoFingerHold(onMenu)
|
.twoFingerHold(onMenu)
|
||||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
// Shadow platform
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(0.86f)
|
|
||||||
.aspectRatio(2.3f)
|
|
||||||
.padding(top = 6.dp)
|
|
||||||
.clip(RoundedCornerShape(18.dp))
|
|
||||||
.background(Color(0xFF000000)),
|
|
||||||
)
|
|
||||||
// Controller body
|
// Controller body
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@ -135,7 +128,7 @@ fun NESController(
|
|||||||
.shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000))
|
.shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000))
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f)))
|
Brush.verticalGradient(listOf(c.body, c.body))
|
||||||
)
|
)
|
||||||
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)),
|
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)),
|
||||||
) {
|
) {
|
||||||
@ -193,13 +186,13 @@ fun NESController(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
// C on top (white)
|
// C on top
|
||||||
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 44.dp) { onKey("c") }
|
GlassFaceBtn("C", Color(0xFFBBBBBB), 44.dp) { onKey("c") }
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
// B + A on bottom row
|
// B + A on bottom row
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 44.dp) { onKey("b") }
|
GlassFaceBtn("B", Color(0xFF60A5FA), 44.dp) { onKey("b") }
|
||||||
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 44.dp) { onKey("a") }
|
GlassFaceBtn("A", Color(0xFFF7931A), 44.dp) { onKey("a") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -264,7 +257,9 @@ fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
activeDir = dir; onDir(dir)
|
activeDir = dir; onDir(dir)
|
||||||
job?.cancel()
|
job?.cancel()
|
||||||
job = scope.launch { delay(300); while (true) { onDir(dir); delay(90) } }
|
// 500ms initial delay so a normal tap sends one key, not
|
||||||
|
// two (a touch tap often exceeds 300ms → doubled nav sound).
|
||||||
|
job = scope.launch { delay(500); while (true) { onDir(dir); delay(90) } }
|
||||||
tryAwaitRelease()
|
tryAwaitRelease()
|
||||||
job?.cancel(); activeDir = null
|
job?.cancel(); activeDir = null
|
||||||
},
|
},
|
||||||
@ -375,6 +370,28 @@ fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Uni
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Glass face button — dark translucent fill, colored ring + letter (OS style) */
|
||||||
|
@Composable
|
||||||
|
fun GlassFaceBtn(label: String, accent: Color, sz: Dp = 44.dp, onClick: () -> Unit) {
|
||||||
|
var p by remember { mutableStateOf(false) }
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(sz)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
if (p) listOf(Color.White.copy(alpha = 0.05f), Color.White.copy(alpha = 0.02f))
|
||||||
|
else listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.border(1.5.dp, accent.copy(alpha = if (p) 0.95f else 0.55f), CircleShape)
|
||||||
|
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(label, color = accent.copy(alpha = if (p) 1f else 0.85f), fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** START/SELECT capsule */
|
/** START/SELECT capsule */
|
||||||
@Composable
|
@Composable
|
||||||
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {
|
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package com.archipelago.app.ui.components
|
|||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@ -34,17 +36,35 @@ 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
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
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.ControllerStyle
|
import com.archipelago.app.ui.theme.ControllerStyle
|
||||||
import com.archipelago.app.ui.theme.NES
|
import com.archipelago.app.ui.theme.SurfaceDark
|
||||||
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
|
|
||||||
/** NES-styled modal menu — dark blue panel with white borders */
|
// Glassmorphism palette (OS design): near-black surfaces, subtle white borders,
|
||||||
|
// Bitcoin-orange accent.
|
||||||
|
private val PanelBg = SurfaceDark // #0A0A0A
|
||||||
|
private val PanelBorder = Color.White.copy(alpha = 0.12f)
|
||||||
|
private val RowBg = Color.White.copy(alpha = 0.05f)
|
||||||
|
private val RowBorder = Color.White.copy(alpha = 0.08f)
|
||||||
|
private val FieldBg = Color.White.copy(alpha = 0.04f)
|
||||||
|
|
||||||
|
private val PANEL_R = 20.dp
|
||||||
|
private val ROW_R = 14.dp
|
||||||
|
private val ROW_H = 54.dp
|
||||||
|
private val FIELD_H = 58.dp
|
||||||
|
|
||||||
|
/** Glassmorphism modal menu — #0A0A0A surface, subtle white borders. */
|
||||||
@Composable
|
@Composable
|
||||||
fun NESMenu(
|
fun NESMenu(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
@ -55,6 +75,7 @@ fun NESMenu(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSelectServer: (ServerEntry) -> Unit,
|
onSelectServer: (ServerEntry) -> Unit,
|
||||||
onAddServer: (ServerEntry) -> Unit,
|
onAddServer: (ServerEntry) -> Unit,
|
||||||
|
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||||
onRemoveServer: (ServerEntry) -> Unit,
|
onRemoveServer: (ServerEntry) -> Unit,
|
||||||
onToggleMode: () -> Unit,
|
onToggleMode: () -> Unit,
|
||||||
onToggleStyle: () -> Unit,
|
onToggleStyle: () -> Unit,
|
||||||
@ -66,7 +87,9 @@ fun NESMenu(
|
|||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
||||||
|
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,105 +103,160 @@ private fun MenuPanel(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSelectServer: (ServerEntry) -> Unit,
|
onSelectServer: (ServerEntry) -> Unit,
|
||||||
onAddServer: (ServerEntry) -> Unit,
|
onAddServer: (ServerEntry) -> Unit,
|
||||||
|
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||||
onRemoveServer: (ServerEntry) -> Unit,
|
onRemoveServer: (ServerEntry) -> Unit,
|
||||||
onToggleMode: () -> Unit,
|
onToggleMode: () -> Unit,
|
||||||
onToggleStyle: () -> Unit,
|
onToggleStyle: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)?,
|
onBackToWebView: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
var showAdd by remember { mutableStateOf(false) }
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
|
// The saved server being edited, or null when adding a new one.
|
||||||
|
var editing by remember { mutableStateOf<ServerEntry?>(null) }
|
||||||
|
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("") }
|
||||||
|
|
||||||
|
fun resetForm() {
|
||||||
|
nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startEdit(server: ServerEntry) {
|
||||||
|
editing = server
|
||||||
|
nm = server.name; addr = server.address; pwd = server.password
|
||||||
|
showAdd = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submit() {
|
||||||
|
if (addr.isBlank()) return
|
||||||
|
val orig = editing
|
||||||
|
if (orig != null) {
|
||||||
|
// Preserve fields the compact form doesn't expose (scheme, port).
|
||||||
|
onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm))
|
||||||
|
} else {
|
||||||
|
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
|
||||||
|
}
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 360.dp)
|
.widthIn(max = 420.dp)
|
||||||
.clip(RoundedCornerShape(4.dp))
|
.padding(horizontal = 20.dp)
|
||||||
.background(NES.MenuPanel)
|
.clip(RoundedCornerShape(PANEL_R))
|
||||||
.border(3.dp, NES.MenuBorder, RoundedCornerShape(4.dp))
|
.background(PanelBg)
|
||||||
|
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
||||||
.padding(16.dp),
|
.padding(22.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
// Title
|
// Title
|
||||||
Text("- MENU -", color = NES.MenuText, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp,
|
Text(
|
||||||
modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
"Menu",
|
||||||
Spacer(Modifier.height(4.dp))
|
color = TextPrimary,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 2.sp,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
|
||||||
// Servers
|
// Servers
|
||||||
servers.forEach { server ->
|
servers.forEach { server ->
|
||||||
val active = server.serialize() == activeServer?.serialize()
|
val active = server.serialize() == activeServer?.serialize()
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label = (if (active) "\u25B6 " else " ") + server.address,
|
label = server.displayName(),
|
||||||
selected = active,
|
selected = active,
|
||||||
onClick = { onSelectServer(server) },
|
onClick = { onSelectServer(server) },
|
||||||
|
onEdit = { startEdit(server) },
|
||||||
onRemove = { onRemoveServer(server) },
|
onRemove = { onRemoveServer(server) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (servers.isEmpty()) {
|
if (servers.isEmpty()) {
|
||||||
Text(" NO SERVERS", color = NES.MenuMuted, fontSize = 11.sp, modifier = Modifier.padding(vertical = 4.dp))
|
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add server
|
// Add / edit server
|
||||||
if (showAdd) {
|
if (showAdd || editing != null) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.3f)).padding(8.dp),
|
Modifier
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
|
.background(FieldBg)
|
||||||
|
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
Row(
|
||||||
value = addr, onValueChange = { addr = it.trim() },
|
Modifier.fillMaxWidth(),
|
||||||
placeholder = { Text("192.168.1.100", color = NES.MenuMuted, fontSize = 11.sp) },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp), singleLine = true,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
|
) {
|
||||||
colors = nesFieldColors(),
|
Text(
|
||||||
shape = RoundedCornerShape(2.dp),
|
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),
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
|
GlassField(
|
||||||
OutlinedTextField(
|
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 },
|
value = pwd, onValueChange = { pwd = it },
|
||||||
placeholder = { Text("PASSWORD", color = NES.MenuMuted, fontSize = 11.sp) },
|
placeholder = "Password",
|
||||||
modifier = Modifier.weight(1f).height(48.dp), singleLine = true,
|
modifier = Modifier.weight(1f),
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
||||||
keyboardActions = KeyboardActions(onGo = {
|
keyboardActions = KeyboardActions(onGo = { submit() }),
|
||||||
if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
|
|
||||||
}),
|
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
|
|
||||||
colors = nesFieldColors(),
|
|
||||||
shape = RoundedCornerShape(2.dp),
|
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.size(48.dp).clip(RoundedCornerShape(2.dp)).background(NES.MenuSelected)
|
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
|
||||||
.clickable {
|
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||||
if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
|
.clickable { submit() },
|
||||||
},
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) { Text("OK", color = NES.MenuText, fontSize = 10.sp, fontWeight = FontWeight.Bold) }
|
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
MenuItem(label = " ADD SERVER", onClick = { showAdd = true })
|
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
Box(Modifier.fillMaxWidth().height(1.dp).background(NES.MenuBorder.copy(alpha = 0.3f)))
|
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
|
|
||||||
// Mode toggle
|
// Mode toggle
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label = if (isGamepadMode) " SWITCH TO KEYBOARD" else " SWITCH TO GAMEPAD",
|
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
|
||||||
onClick = onToggleMode,
|
onClick = onToggleMode,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Style toggle
|
// Style toggle
|
||||||
MenuItem(
|
MenuItem(
|
||||||
label = if (controllerStyle == ControllerStyle.CLASSIC) " STYLE: CLASSIC" else " STYLE: DARK",
|
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
|
||||||
onClick = onToggleStyle,
|
onClick = onToggleStyle,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Back to dashboard
|
// Back to dashboard
|
||||||
if (onBackToWebView != null) {
|
if (onBackToWebView != null) {
|
||||||
MenuItem(label = " BACK TO DASHBOARD", onClick = onBackToWebView)
|
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -187,32 +265,79 @@ private fun MenuPanel(
|
|||||||
private fun MenuItem(
|
private fun MenuItem(
|
||||||
label: String,
|
label: String,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
|
labelColor: Color = TextPrimary,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onEdit: (() -> Unit)? = null,
|
||||||
onRemove: (() -> Unit)? = null,
|
onRemove: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(32.dp)
|
.height(ROW_H)
|
||||||
.background(if (selected) NES.MenuSelected.copy(alpha = 0.15f) else Color.Transparent)
|
.clip(RoundedCornerShape(ROW_R))
|
||||||
|
.background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg)
|
||||||
|
.border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R))
|
||||||
.clickable { onClick() }
|
.clickable { onClick() }
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text(label, color = if (selected) NES.MenuSelected else NES.MenuText, fontSize = 11.sp, fontWeight = FontWeight.Medium)
|
Text(
|
||||||
|
label,
|
||||||
|
color = if (selected) BitcoinOrange else labelColor,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (onEdit != null) {
|
||||||
|
Text(
|
||||||
|
"✎",
|
||||||
|
color = TextMuted,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.clickable { onEdit() }.padding(horizontal = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (onRemove != null) {
|
if (onRemove != null) {
|
||||||
Text("\u2715", color = NES.MenuMuted, fontSize = 10.sp,
|
Text(
|
||||||
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp))
|
"✕",
|
||||||
|
color = TextMuted,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Glass text field with centered input text. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun nesFieldColors() = OutlinedTextFieldDefaults.colors(
|
private fun GlassField(
|
||||||
focusedBorderColor = NES.MenuBorder,
|
value: String,
|
||||||
unfocusedBorderColor = NES.MenuMuted,
|
onValueChange: (String) -> Unit,
|
||||||
cursorColor = NES.MenuText,
|
placeholder: String,
|
||||||
focusedTextColor = NES.MenuText,
|
modifier: Modifier = Modifier,
|
||||||
unfocusedTextColor = NES.MenuText,
|
visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
|
||||||
)
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
placeholder = {
|
||||||
|
Text(placeholder, color = TextMuted, fontSize = 15.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||||
|
},
|
||||||
|
modifier = modifier.fillMaxWidth().height(FIELD_H),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = visualTransformation,
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
|
textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, textAlign = TextAlign.Center),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||||
|
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||||
|
cursorColor = BitcoinOrange,
|
||||||
|
focusedTextColor = TextPrimary,
|
||||||
|
unfocusedTextColor = TextPrimary,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -50,7 +50,6 @@ fun NESPortraitController(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFF0C0C0C))
|
|
||||||
.twoFingerHold(onMenu)
|
.twoFingerHold(onMenu)
|
||||||
.padding(horizontal = 40.dp, vertical = 24.dp),
|
.padding(horizontal = 40.dp, vertical = 24.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@ -62,7 +61,7 @@ fun NESPortraitController(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black)
|
.shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black)
|
||||||
.clip(RoundedCornerShape(20.dp))
|
.clip(RoundedCornerShape(20.dp))
|
||||||
.background(Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f))))
|
.background(Brush.verticalGradient(listOf(c.body, c.body)))
|
||||||
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)),
|
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)),
|
||||||
) {
|
) {
|
||||||
// Top highlight
|
// Top highlight
|
||||||
@ -119,11 +118,11 @@ fun NESPortraitController(
|
|||||||
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 46.dp) { onKey("c") }
|
GlassFaceBtn("C", Color(0xFFBBBBBB), 46.dp) { onKey("c") }
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 46.dp) { onKey("b") }
|
GlassFaceBtn("B", Color(0xFF60A5FA), 46.dp) { onKey("b") }
|
||||||
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 46.dp) { onKey("a") }
|
GlassFaceBtn("A", Color(0xFFF7931A), 46.dp) { onKey("a") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@ -41,7 +42,7 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@ -67,26 +68,45 @@ fun IntroScreen(onContinue: () -> Unit) {
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(SurfaceBlack)
|
.background(SurfaceBlack),
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
) {
|
||||||
|
// Reddish synthwave backdrop
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.bg_synthwave),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
// Dark scrim so the title/buttons stay legible over the art
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Black.copy(alpha = 0.55f),
|
||||||
|
Color.Black.copy(alpha = 0.35f),
|
||||||
|
Color.Black.copy(alpha = 0.75f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
.padding(horizontal = 32.dp),
|
.padding(horizontal = 32.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
// Wide pixel-art logo
|
// Circular badge logo
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = R.drawable.ic_logo_wide),
|
painter = painterResource(id = R.drawable.ic_logo),
|
||||||
contentDescription = "Archipelago",
|
contentDescription = "Archipelago",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.size(160.dp)
|
||||||
.padding(horizontal = 8.dp)
|
|
||||||
.alpha(logoAlpha.value),
|
.alpha(logoAlpha.value),
|
||||||
colorFilter = ColorFilter.tint(Color.White),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(48.dp))
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
@ -102,7 +122,7 @@ fun IntroScreen(onContinue: () -> Unit) {
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.welcome_title),
|
text = stringResource(R.string.welcome_title),
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
color = TextPrimary,
|
color = Color(0xFFFAFAFA),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -111,7 +131,7 @@ fun IntroScreen(onContinue: () -> Unit) {
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.welcome_subtitle),
|
text = stringResource(R.string.welcome_subtitle),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = TextMuted,
|
color = Color(0xFFFAFAFA),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
lineHeight = 26.sp,
|
lineHeight = 26.sp,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.archipelago.app.ui.screens
|
|||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -24,13 +25,17 @@ import androidx.compose.runtime.setValue
|
|||||||
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
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerPreferences
|
import com.archipelago.app.data.ServerPreferences
|
||||||
import com.archipelago.app.network.ConnectionState
|
import com.archipelago.app.network.ConnectionState
|
||||||
import com.archipelago.app.network.InputWebSocket
|
import com.archipelago.app.network.InputWebSocket
|
||||||
@ -58,7 +63,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
|
|
||||||
var isGamepadMode by remember { mutableStateOf(true) }
|
var isGamepadMode by remember { mutableStateOf(true) }
|
||||||
var showModal by remember { mutableStateOf(false) }
|
var showModal by remember { mutableStateOf(false) }
|
||||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) }
|
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
||||||
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
||||||
|
|
||||||
val ws = remember { InputWebSocket(scope) }
|
val ws = remember { InputWebSocket(scope) }
|
||||||
@ -113,9 +118,31 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFF0C0C0C))
|
.background(Color(0xFF0C0C0C)),
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
|
||||||
) {
|
) {
|
||||||
|
// Reddish synthwave backdrop behind the controller
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.bg_synthwave),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
// Light scrim — the controller body provides its own contrast, so keep
|
||||||
|
// this subtle and let the backdrop show through around it.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Black.copy(alpha = 0.4f),
|
||||||
|
Color.Black.copy(alpha = 0.25f),
|
||||||
|
Color.Black.copy(alpha = 0.45f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
|
||||||
when {
|
when {
|
||||||
isGamepadMode && isLandscape -> NESController(
|
isGamepadMode && isLandscape -> NESController(
|
||||||
style = controllerStyle,
|
style = controllerStyle,
|
||||||
@ -174,6 +201,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
NESMenu(
|
NESMenu(
|
||||||
visible = showModal,
|
visible = showModal,
|
||||||
@ -188,7 +216,31 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
onAddServer = { server ->
|
onAddServer = { server ->
|
||||||
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
|
||||||
},
|
},
|
||||||
onRemoveServer = { server -> scope.launch { prefs.removeSavedServer(server) } },
|
onEditServer = { original, updated ->
|
||||||
|
scope.launch {
|
||||||
|
prefs.updateSavedServer(original, updated)
|
||||||
|
// If the edited server is the live one, reconnect with the new
|
||||||
|
// address/credentials so the change takes effect immediately.
|
||||||
|
if (original.serialize() == activeServer?.serialize()) {
|
||||||
|
ws.disconnect()
|
||||||
|
prefs.setActiveServer(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRemoveServer = { server ->
|
||||||
|
scope.launch {
|
||||||
|
prefs.removeSavedServer(server)
|
||||||
|
// Deleting the last server leaves nothing to control — drop the
|
||||||
|
// active server and return to the Connect screen.
|
||||||
|
val remaining = savedServers.count { it.serialize() != server.serialize() }
|
||||||
|
if (remaining == 0) {
|
||||||
|
ws.disconnect()
|
||||||
|
prefs.clearActiveServer()
|
||||||
|
showModal = false
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
|
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
|
||||||
onToggleStyle = {
|
onToggleStyle = {
|
||||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.VisibilityOff
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
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.Close
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.LockOpen
|
import androidx.compose.material.icons.filled.LockOpen
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@ -55,6 +56,7 @@ import androidx.compose.ui.draw.drawWithContent
|
|||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
@ -97,6 +99,7 @@ fun ServerConnectScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
var address by remember { mutableStateOf("") }
|
var address by remember { mutableStateOf("") }
|
||||||
var port by remember { mutableStateOf("") }
|
var port by remember { mutableStateOf("") }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
@ -104,9 +107,50 @@ fun ServerConnectScreen(
|
|||||||
var useHttps by remember { mutableStateOf(false) }
|
var useHttps by remember { mutableStateOf(false) }
|
||||||
var isConnecting by remember { mutableStateOf(false) }
|
var isConnecting by remember { mutableStateOf(false) }
|
||||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
// The saved server currently being edited, or null when adding/connecting.
|
||||||
|
var editingServer by remember { mutableStateOf<ServerEntry?>(null) }
|
||||||
|
|
||||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
|
fun clearForm() {
|
||||||
|
name = ""
|
||||||
|
address = ""
|
||||||
|
port = ""
|
||||||
|
password = ""
|
||||||
|
useHttps = false
|
||||||
|
passwordVisible = false
|
||||||
|
errorMessage = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startEdit(server: ServerEntry) {
|
||||||
|
editingServer = server
|
||||||
|
name = server.name
|
||||||
|
address = server.address
|
||||||
|
port = server.port
|
||||||
|
password = server.password
|
||||||
|
useHttps = server.useHttps
|
||||||
|
passwordVisible = false
|
||||||
|
errorMessage = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelEdit() {
|
||||||
|
editingServer = null
|
||||||
|
clearForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveEdit() {
|
||||||
|
val original = editingServer ?: return
|
||||||
|
if (address.isBlank()) {
|
||||||
|
errorMessage = "Enter a server address"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val updated = ServerEntry(address, useHttps, port, password, name)
|
||||||
|
scope.launch {
|
||||||
|
prefs.updateSavedServer(original, updated)
|
||||||
|
cancelEdit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun connect(server: ServerEntry) {
|
fun connect(server: ServerEntry) {
|
||||||
if (isConnecting) return
|
if (isConnecting) return
|
||||||
if (server.address.isBlank()) {
|
if (server.address.isBlank()) {
|
||||||
@ -132,12 +176,33 @@ fun ServerConnectScreen(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(SurfaceBlack)
|
.background(SurfaceBlack),
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
|
||||||
) {
|
) {
|
||||||
|
// Reddish synthwave backdrop
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.bg_synthwave),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
// Dark scrim so the form stays legible over the art
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Black.copy(alpha = 0.6f),
|
||||||
|
Color.Black.copy(alpha = 0.45f),
|
||||||
|
Color.Black.copy(alpha = 0.8f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
.verticalScroll(state = rememberScrollState())
|
.verticalScroll(state = rememberScrollState())
|
||||||
.drawWithContent { drawContent() }
|
.drawWithContent { drawContent() }
|
||||||
.padding(horizontal = 24.dp)
|
.padding(horizontal = 24.dp)
|
||||||
@ -145,20 +210,17 @@ fun ServerConnectScreen(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
// Wide logo
|
// Circular badge logo
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(id = R.drawable.ic_logo_wide),
|
painter = painterResource(id = R.drawable.ic_logo),
|
||||||
contentDescription = "Archipelago",
|
contentDescription = "Archipelago",
|
||||||
modifier = Modifier
|
modifier = Modifier.size(96.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
colorFilter = ColorFilter.tint(Color.White),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Connect to Server",
|
text = if (editingServer != null) stringResource(R.string.edit_server_title) else "Connect to Server",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
color = TextPrimary,
|
color = TextPrimary,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
@ -178,6 +240,7 @@ fun ServerConnectScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(Color.Black.copy(alpha = 0.6f))
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
@ -190,6 +253,34 @@ fun ServerConnectScreen(
|
|||||||
.padding(20.dp),
|
.padding(20.dp),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = {
|
||||||
|
name = it
|
||||||
|
errorMessage = null
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.server_name_label)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.server_name_placeholder)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||||
|
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||||
|
cursorColor = Color.White,
|
||||||
|
focusedLabelColor = Color.White.copy(alpha = 0.7f),
|
||||||
|
unfocusedLabelColor = TextMuted,
|
||||||
|
focusedTextColor = TextPrimary,
|
||||||
|
unfocusedTextColor = TextPrimary,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = address,
|
value = address,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
@ -275,7 +366,11 @@ fun ServerConnectScreen(
|
|||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(
|
||||||
onGo = {
|
onGo = {
|
||||||
keyboard?.hide()
|
keyboard?.hide()
|
||||||
connect(ServerEntry(address, useHttps, port, password))
|
if (editingServer != null) {
|
||||||
|
saveEdit()
|
||||||
|
} else {
|
||||||
|
connect(ServerEntry(address, useHttps, port, password, name))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
@ -340,15 +435,40 @@ fun ServerConnectScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect button — glass style
|
if (editingServer != null) {
|
||||||
GlassButton(
|
// Save / Cancel while editing an existing saved server
|
||||||
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
Row(
|
||||||
onClick = {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
keyboard?.hide()
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
connect(ServerEntry(address, useHttps, port, password))
|
) {
|
||||||
},
|
GlassButton(
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
text = stringResource(R.string.cancel),
|
||||||
)
|
onClick = {
|
||||||
|
keyboard?.hide()
|
||||||
|
cancelEdit()
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
|
)
|
||||||
|
GlassButton(
|
||||||
|
text = stringResource(R.string.save_changes),
|
||||||
|
onClick = {
|
||||||
|
keyboard?.hide()
|
||||||
|
saveEdit()
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Connect button — glass style
|
||||||
|
GlassButton(
|
||||||
|
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
|
||||||
|
onClick = {
|
||||||
|
keyboard?.hide()
|
||||||
|
connect(ServerEntry(address, useHttps, port, password, name))
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isConnecting) {
|
if (isConnecting) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
@ -358,8 +478,8 @@ fun ServerConnectScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saved servers
|
// Saved servers (hidden while editing one to keep focus on the form)
|
||||||
if (savedServers.isNotEmpty()) {
|
if (editingServer == null && savedServers.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.saved_servers),
|
text = stringResource(R.string.saved_servers),
|
||||||
@ -373,6 +493,7 @@ fun ServerConnectScreen(
|
|||||||
SavedServerItem(
|
SavedServerItem(
|
||||||
server = server,
|
server = server,
|
||||||
onConnect = { connect(it) },
|
onConnect = { connect(it) },
|
||||||
|
onEdit = { startEdit(it) },
|
||||||
onRemove = { scope.launch { prefs.removeSavedServer(it) } },
|
onRemove = { scope.launch { prefs.removeSavedServer(it) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -385,12 +506,14 @@ fun ServerConnectScreen(
|
|||||||
private fun SavedServerItem(
|
private fun SavedServerItem(
|
||||||
server: ServerEntry,
|
server: ServerEntry,
|
||||||
onConnect: (ServerEntry) -> Unit,
|
onConnect: (ServerEntry) -> Unit,
|
||||||
|
onEdit: (ServerEntry) -> Unit,
|
||||||
onRemove: (ServerEntry) -> Unit,
|
onRemove: (ServerEntry) -> Unit,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color.Black.copy(alpha = 0.6f))
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
@ -414,12 +537,21 @@ private fun SavedServerItem(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Column {
|
Column {
|
||||||
Text(text = server.address, style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(text = server.displayName(), style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
if (server.port.isNotBlank()) {
|
val secondary = buildString {
|
||||||
Text(text = "Port ${server.port}", style = MaterialTheme.typography.labelMedium, color = TextMuted)
|
if (server.name.isNotBlank()) append(server.address)
|
||||||
|
if (server.port.isNotBlank()) {
|
||||||
|
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (secondary.isNotBlank()) {
|
||||||
|
Text(text = secondary, style = MaterialTheme.typography.labelMedium, color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = { onEdit(server) }) {
|
||||||
|
Icon(imageVector = Icons.Default.Edit, contentDescription = stringResource(R.string.edit_server), modifier = Modifier.size(18.dp), tint = TextMuted)
|
||||||
|
}
|
||||||
IconButton(onClick = { onRemove(server) }) {
|
IconButton(onClick = { onRemove(server) }) {
|
||||||
Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.remove_server), modifier = Modifier.size(18.dp), tint = TextMuted)
|
Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.remove_server), modifier = Modifier.size(18.dp), tint = TextMuted)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.archipelago.app.ui.screens
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.WebChromeClient
|
import android.webkit.WebChromeClient
|
||||||
@ -14,10 +15,12 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
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.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.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@ -26,14 +29,24 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.CloudOff
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
|
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
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.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@ -41,8 +54,12 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
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.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
@ -50,8 +67,70 @@ import com.archipelago.app.ui.theme.BitcoinOrange
|
|||||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||||
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
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||||
|
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||||
|
try {
|
||||||
|
val intent = android.content.Intent(
|
||||||
|
android.content.Intent.ACTION_VIEW,
|
||||||
|
android.net.Uri.parse(url),
|
||||||
|
).apply {
|
||||||
|
// Required when launching from a non-Activity/binder thread
|
||||||
|
// (the JS bridge below can run off the UI thread).
|
||||||
|
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when [url] points at the same host as the connected Archipelago node
|
||||||
|
* (ignoring port). Such URLs are node apps — e.g. one that can't be iframed —
|
||||||
|
* and should stay inside the app rather than bouncing out to the browser. */
|
||||||
|
private fun isSameHost(url: String, base: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val a = android.net.Uri.parse(url).host ?: return false
|
||||||
|
val b = android.net.Uri.parse(base).host ?: return false
|
||||||
|
a.equals(b, ignoreCase = true)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
||||||
|
* These are tuned for SPA performance and parity with the mobile browser;
|
||||||
|
* none of them alter how a page renders visually. */
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private fun WebView.applyArchipelagoSettings() {
|
||||||
|
// Pre-rasterize just outside the viewport so flinging the kiosk/app doesn't
|
||||||
|
// show blank checkerboarding — the single biggest scroll-smoothness win and
|
||||||
|
// a major part of the "feels slower than the browser" gap. (API 23+)
|
||||||
|
settings.setOffscreenPreRaster(true)
|
||||||
|
|
||||||
|
settings.apply {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
databaseEnabled = true
|
||||||
|
mediaPlaybackRequiresUserGesture = false
|
||||||
|
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||||
|
useWideViewPort = true
|
||||||
|
loadWithOverviewMode = true
|
||||||
|
setSupportZoom(false)
|
||||||
|
builtInZoomControls = false
|
||||||
|
cacheMode = WebSettings.LOAD_DEFAULT
|
||||||
|
allowContentAccess = true
|
||||||
|
allowFileAccess = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// chrome://inspect profiling on debuggable builds only — lets us measure the
|
||||||
|
// real in-page bottleneck rather than guess. No effect on release builds.
|
||||||
|
val debuggable = 0 != (context.applicationInfo.flags and
|
||||||
|
android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE)
|
||||||
|
if (debuggable) WebView.setWebContentsDebuggingEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
|
||||||
@Composable
|
@Composable
|
||||||
fun WebViewScreen(
|
fun WebViewScreen(
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
@ -63,7 +142,12 @@ fun WebViewScreen(
|
|||||||
var hasError by remember { mutableStateOf(false) }
|
var hasError by remember { mutableStateOf(false) }
|
||||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
|
||||||
BackHandler(enabled = webView?.canGoBack() == true) {
|
// A node app that refused iframing, opened in a local WebView overlay.
|
||||||
|
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
||||||
|
// while this is shown, so closing it returns instantly with no reload.
|
||||||
|
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
||||||
webView?.goBack()
|
webView?.goBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,20 +216,6 @@ fun WebViewScreen(
|
|||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
fun openExternalUrl(url: String) {
|
|
||||||
try {
|
|
||||||
val intent = android.content.Intent(
|
|
||||||
android.content.Intent.ACTION_VIEW,
|
|
||||||
android.net.Uri.parse(url),
|
|
||||||
).apply {
|
|
||||||
// Required when launching from a non-Activity/binder
|
|
||||||
// thread (the JS bridge below runs off the UI thread).
|
|
||||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
context.startActivity(intent)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
WebView(context).apply {
|
WebView(context).apply {
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
@ -159,19 +229,8 @@ fun WebViewScreen(
|
|||||||
cookieManager.setAcceptCookie(true)
|
cookieManager.setAcceptCookie(true)
|
||||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||||
|
|
||||||
|
applyArchipelagoSettings()
|
||||||
settings.apply {
|
settings.apply {
|
||||||
javaScriptEnabled = true
|
|
||||||
domStorageEnabled = true
|
|
||||||
databaseEnabled = true
|
|
||||||
mediaPlaybackRequiresUserGesture = false
|
|
||||||
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
|
||||||
useWideViewPort = true
|
|
||||||
loadWithOverviewMode = true
|
|
||||||
setSupportZoom(false)
|
|
||||||
builtInZoomControls = false
|
|
||||||
cacheMode = WebSettings.LOAD_DEFAULT
|
|
||||||
allowContentAccess = true
|
|
||||||
allowFileAccess = false
|
|
||||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||||
// Let JS open windows without a synchronous user-gesture
|
// Let JS open windows without a synchronous user-gesture
|
||||||
// chain; without this, window.open() from a Vue click
|
// chain; without this, window.open() from a Vue click
|
||||||
@ -179,18 +238,35 @@ fun WebViewScreen(
|
|||||||
javaScriptCanOpenWindowsAutomatically = true
|
javaScriptCanOpenWindowsAutomatically = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deterministic bridge for "open in the phone's browser".
|
|
||||||
// The web UI calls window.ArchipelagoNative.openExternal(url)
|
|
||||||
// when present (companion app), falling back to window.open
|
|
||||||
// in a plain mobile browser. This avoids relying on the
|
|
||||||
// window.open → onCreateWindow path, which noopener/noreferrer
|
|
||||||
// can suppress in the WebView.
|
|
||||||
val webViewRef = this
|
val webViewRef = this
|
||||||
|
|
||||||
|
// Decide where an outbound URL goes:
|
||||||
|
// - same host as the node → in-app WebView overlay
|
||||||
|
// (this is the "open in browser" target for apps the
|
||||||
|
// kiosk couldn't iframe — keep the user inside the app)
|
||||||
|
// - different host → the phone's real browser
|
||||||
|
fun routeOutbound(url: String) {
|
||||||
|
if (isSameHost(url, serverUrl)) {
|
||||||
|
inAppUrl = url
|
||||||
|
} else {
|
||||||
|
openExternalUrl(context, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JS bridge. The web UI calls:
|
||||||
|
// window.ArchipelagoNative.openExternal(url) — host-routed
|
||||||
|
// window.ArchipelagoNative.openInApp(url) — force in-app
|
||||||
|
// Falls back to window.open in a plain mobile browser.
|
||||||
addJavascriptInterface(
|
addJavascriptInterface(
|
||||||
object {
|
object {
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun openExternal(url: String) {
|
fun openExternal(url: String) {
|
||||||
webViewRef.post { openExternalUrl(url) }
|
webViewRef.post { routeOutbound(url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun openInApp(url: String) {
|
||||||
|
webViewRef.post { inAppUrl = url }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ArchipelagoNative",
|
"ArchipelagoNative",
|
||||||
@ -247,15 +323,35 @@ fun WebViewScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Node apps (e.g. NetBird) terminate TLS with a
|
||||||
|
// self-signed cert — the dashboard needs a secure
|
||||||
|
// context for OIDC/window.crypto.subtle (#15). The
|
||||||
|
// WebView default is to CANCEL untrusted certs, so
|
||||||
|
// those apps render blank. The user explicitly trusts
|
||||||
|
// their own node, so proceed for same-host certs only;
|
||||||
|
// reject anything else (don't blanket-trust the web).
|
||||||
|
override fun onReceivedSslError(
|
||||||
|
view: WebView?,
|
||||||
|
handler: android.webkit.SslErrorHandler?,
|
||||||
|
error: android.net.http.SslError?,
|
||||||
|
) {
|
||||||
|
val u = error?.url
|
||||||
|
if (u != null && isSameHost(u, serverUrl)) {
|
||||||
|
handler?.proceed()
|
||||||
|
} else {
|
||||||
|
handler?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun shouldOverrideUrlLoading(
|
override fun shouldOverrideUrlLoading(
|
||||||
view: WebView?,
|
view: WebView?,
|
||||||
request: WebResourceRequest?,
|
request: WebResourceRequest?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val url = request?.url?.toString() ?: return false
|
val url = request?.url?.toString() ?: return false
|
||||||
// Keep navigation within the Archipelago server
|
// Keep kiosk navigation (same origin incl. port) in place
|
||||||
if (url.startsWith(serverUrl)) return false
|
if (url.startsWith(serverUrl)) return false
|
||||||
// Open external URLs in the system browser
|
// Same node (other port) → in-app; external → browser
|
||||||
openExternalUrl(url)
|
routeOutbound(url)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -265,7 +361,9 @@ fun WebViewScreen(
|
|||||||
loadProgress = newProgress
|
loadProgress = newProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle window.open() — open in system browser
|
// window.open() — e.g. the kiosk's "Open in new tab"
|
||||||
|
// for an app that can't be iframed. Capture the target
|
||||||
|
// URL via a throwaway WebView and route it ourselves.
|
||||||
override fun onCreateWindow(
|
override fun onCreateWindow(
|
||||||
view: WebView?,
|
view: WebView?,
|
||||||
isDialog: Boolean,
|
isDialog: Boolean,
|
||||||
@ -283,12 +381,12 @@ fun WebViewScreen(
|
|||||||
request: WebResourceRequest?,
|
request: WebResourceRequest?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val url = request?.url?.toString() ?: return true
|
val url = request?.url?.toString() ?: return true
|
||||||
openExternalUrl(url)
|
routeOutbound(url)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
if (url != null) openExternalUrl(url)
|
if (url != null) routeOutbound(url)
|
||||||
view?.stopLoading()
|
view?.stopLoading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -350,6 +448,255 @@ fun WebViewScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-app browser overlay for non-iframeable node apps. Rendered last
|
||||||
|
// so it sits above the kiosk WebView, which stays alive underneath.
|
||||||
|
inAppUrl?.let { target ->
|
||||||
|
InAppBrowser(
|
||||||
|
url = target,
|
||||||
|
serverUrl = serverUrl,
|
||||||
|
onClose = { inAppUrl = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon
|
||||||
|
* can be shown on the loading screen before the WebView reports onReceivedIcon
|
||||||
|
* (which only fires once the page's <head> has parsed). Blocking — call on IO. */
|
||||||
|
private fun fetchFavicon(pageUrl: String): Bitmap? {
|
||||||
|
return try {
|
||||||
|
val u = android.net.Uri.parse(pageUrl)
|
||||||
|
val scheme = u.scheme ?: return null
|
||||||
|
val host = u.host ?: return null
|
||||||
|
val portPart = if (u.port > 0) ":${u.port}" else ""
|
||||||
|
val conn = (java.net.URL("$scheme://$host$portPart/favicon.ico").openConnection()
|
||||||
|
as java.net.HttpURLConnection).apply {
|
||||||
|
connectTimeout = 4000
|
||||||
|
readTimeout = 4000
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
}
|
||||||
|
conn.inputStream.use { BitmapFactory.decodeStream(it) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight in-app browser used when the kiosk hands off an app that can't be
|
||||||
|
* shown in an iframe. Loads the app in a local WebView with a centered loading
|
||||||
|
* screen (app favicon + progress bar) and a BOTTOM control bar mirroring the
|
||||||
|
* web mobile-iframe footer (back / forward / reload / open-in-browser / close).
|
||||||
|
* Same-host navigation stays here; any genuinely external link escapes to the
|
||||||
|
* phone's browser.
|
||||||
|
*/
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
private fun InAppBrowser(
|
||||||
|
url: String,
|
||||||
|
serverUrl: String,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var browser by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
|
||||||
|
var favicon by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
|
var progress by remember { mutableIntStateOf(0) }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var canGoBack by remember { mutableStateOf(false) }
|
||||||
|
var canGoForward by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Seed the loading-screen icon immediately from a best-effort favicon
|
||||||
|
// pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the
|
||||||
|
// loader shows an icon right away instead of staying blank until the page
|
||||||
|
// parses its <head> (which is what made the loader look stuck).
|
||||||
|
LaunchedEffect(url) {
|
||||||
|
val fetched = withContext(Dispatchers.IO) { fetchFavicon(url) }
|
||||||
|
if (fetched != null && favicon == null) favicon = fetched
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back: walk the in-app history first, then close the overlay.
|
||||||
|
BackHandler {
|
||||||
|
val b = browser
|
||||||
|
if (b != null && b.canGoBack()) b.goBack() else onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(SurfaceBlack)
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||||
|
) {
|
||||||
|
// WebView + loading overlay fill the area above the bottom control bar.
|
||||||
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { ctx ->
|
||||||
|
WebView(ctx).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
)
|
||||||
|
isVerticalScrollBarEnabled = false
|
||||||
|
isHorizontalScrollBarEnabled = false
|
||||||
|
|
||||||
|
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||||
|
applyArchipelagoSettings()
|
||||||
|
|
||||||
|
webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||||
|
progress = newProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedTitle(view: WebView?, t: String?) {
|
||||||
|
if (!t.isNullOrBlank()) title = t
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
|
||||||
|
if (icon != null) favicon = icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||||
|
loading = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView?, u: String?) {
|
||||||
|
loading = false
|
||||||
|
canGoBack = view?.canGoBack() == true
|
||||||
|
canGoForward = view?.canGoForward() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
|
||||||
|
canGoBack = view?.canGoBack() == true
|
||||||
|
canGoForward = view?.canGoForward() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-signed TLS on the node's apps (e.g. NetBird on
|
||||||
|
// :8087) would otherwise be cancelled by the WebView
|
||||||
|
// and render blank. Proceed for the user's own node
|
||||||
|
// (same host); reject any other untrusted cert.
|
||||||
|
override fun onReceivedSslError(
|
||||||
|
view: WebView?,
|
||||||
|
handler: android.webkit.SslErrorHandler?,
|
||||||
|
error: android.net.http.SslError?,
|
||||||
|
) {
|
||||||
|
val u = error?.url
|
||||||
|
if (u != null && isSameHost(u, serverUrl)) {
|
||||||
|
handler?.proceed()
|
||||||
|
} else {
|
||||||
|
handler?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldOverrideUrlLoading(
|
||||||
|
view: WebView?,
|
||||||
|
request: WebResourceRequest?,
|
||||||
|
): Boolean {
|
||||||
|
val u = request?.url?.toString() ?: return false
|
||||||
|
// Stay in the overlay for same-node navigation;
|
||||||
|
// hand genuinely external links to the real browser.
|
||||||
|
if (isSameHost(u, serverUrl)) return false
|
||||||
|
openExternalUrl(ctx, u)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
browser = this
|
||||||
|
loadUrl(url)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Centered loading screen — app favicon (or spinner) + title + bar.
|
||||||
|
if (loading) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(SurfaceBlack),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(84.dp).clip(RoundedCornerShape(20.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val fav = favicon
|
||||||
|
if (fav != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = fav.asImageBitmap(),
|
||||||
|
contentDescription = title,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(color = BitcoinOrange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(18.dp))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = TextPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress / 100f },
|
||||||
|
modifier = Modifier.width(220.dp),
|
||||||
|
color = BitcoinOrange,
|
||||||
|
trackColor = TextMuted.copy(alpha = 0.2f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom control bar — mirrors the web mobile-iframe footer.
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
.background(SurfaceBlack)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceAround,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { browser?.goBack() }, enabled = canGoBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = if (canGoBack) TextPrimary else TextMuted.copy(alpha = 0.4f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { browser?.goForward() }, enabled = canGoForward) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||||
|
contentDescription = "Forward",
|
||||||
|
tint = if (canGoForward) TextPrimary else TextMuted.copy(alpha = 0.4f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { browser?.reload() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Refresh,
|
||||||
|
contentDescription = "Reload",
|
||||||
|
tint = TextPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.OpenInBrowser,
|
||||||
|
contentDescription = stringResource(R.string.open_in_browser),
|
||||||
|
tint = TextPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.close),
|
||||||
|
tint = TextPrimary,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Android/app/src/main/res/drawable/bg_synthwave.jpg
Normal file
BIN
Android/app/src/main/res/drawable/bg_synthwave.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 869 KiB |
@ -1,10 +1,53 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Whole badge lives here (background renders to the mask edge with no
|
||||||
|
safe-zone cropping, unlike the foreground): dark fill + metallic ring pulled
|
||||||
|
inward to ~0.88 so the mask can't clip it + grid at ~0.58. Matches the
|
||||||
|
locally-rendered preview. Foreground is transparent. -->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="752"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="752">
|
||||||
|
|
||||||
<path
|
<path
|
||||||
android:fillColor="#030202"
|
android:fillColor="#0A0A0A"
|
||||||
android:pathData="M0,0h108v108H0z" />
|
android:pathData="M0,0h752v752H0z" />
|
||||||
|
|
||||||
|
<!-- Ring matching logo.svg's gradient (#000->#666). Scale 0.65 places it at
|
||||||
|
the home-screen's visible edge (calibrated from a device home screenshot;
|
||||||
|
launcher3 crops less than the Settings App-info view). -->
|
||||||
|
<group
|
||||||
|
android:pivotX="376"
|
||||||
|
android:pivotY="376"
|
||||||
|
android:scaleX="0.65"
|
||||||
|
android:scaleY="0.65">
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeWidth="22.8834"
|
||||||
|
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
|
||||||
|
<aapt:attr name="android:strokeColor">
|
||||||
|
<gradient
|
||||||
|
android:type="linear"
|
||||||
|
android:startX="751.337"
|
||||||
|
android:startY="751.338"
|
||||||
|
android:endX="0"
|
||||||
|
android:endY="0.000976562">
|
||||||
|
<item android:offset="0" android:color="#FF000000" />
|
||||||
|
<item android:offset="1" android:color="#FF666666" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- White Archipelago grid -->
|
||||||
|
<group
|
||||||
|
android:pivotX="376"
|
||||||
|
android:pivotY="376"
|
||||||
|
android:scaleX="0.55"
|
||||||
|
android:scaleY="0.55">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
|
||||||
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@ -1,45 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Archipelago pixel-art "A" logo — scaled 90% and centered -->
|
<!-- Transparent — the whole badge (ring + grid) is in the background layer so it
|
||||||
|
renders to the mask edge without safe-zone cropping. -->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="1024"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="1024">
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
<group
|
android:fillColor="#00000000"
|
||||||
android:pivotX="512"
|
android:pathData="M0,0h108v108H0z" />
|
||||||
android:pivotY="512"
|
|
||||||
android:scaleX="0.55"
|
|
||||||
android:scaleY="0.55">
|
|
||||||
|
|
||||||
<!-- Row 1: 4 blocks -->
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
|
|
||||||
|
|
||||||
<!-- Row 2: 2 blocks (right side) -->
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
|
|
||||||
|
|
||||||
<!-- Row 3: 6 blocks (full width) -->
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
|
|
||||||
|
|
||||||
<!-- Row 4: 4 blocks (sides only — the "A" gap) -->
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
|
|
||||||
|
|
||||||
<!-- Row 5: 4 blocks (bottom) -->
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
|
|
||||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
|
|
||||||
</group>
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
33
Android/app/src/main/res/drawable/ic_logo.xml
Normal file
33
Android/app/src/main/res/drawable/ic_logo.xml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Archipelago circular badge logo (from logo.svg):
|
||||||
|
dark circle with a black→grey gradient ring + white pixel-grid mark. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="120dp"
|
||||||
|
android:height="120dp"
|
||||||
|
android:viewportWidth="752"
|
||||||
|
android:viewportHeight="752">
|
||||||
|
|
||||||
|
<!-- Ringed circle (circle converted to a path; stroke carries the gradient) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#0A0A0A"
|
||||||
|
android:strokeWidth="22.8834"
|
||||||
|
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
|
||||||
|
<aapt:attr name="android:strokeColor">
|
||||||
|
<gradient
|
||||||
|
android:type="linear"
|
||||||
|
android:startX="751.337"
|
||||||
|
android:startY="751.338"
|
||||||
|
android:endX="0"
|
||||||
|
android:endY="0">
|
||||||
|
<item android:offset="0" android:color="#FF000000" />
|
||||||
|
<item android:offset="1" android:color="#FF666666" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
|
||||||
|
<!-- White Archipelago pixel grid -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
|
||||||
|
</vector>
|
||||||
12
Android/app/src/main/res/drawable/ic_nav_back.xml
Normal file
12
Android/app/src/main/res/drawable/ic_nav_back.xml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M15,19l-7,-7 7,-7"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round" />
|
||||||
|
</vector>
|
||||||
12
Android/app/src/main/res/drawable/ic_nav_close.xml
Normal file
12
Android/app/src/main/res/drawable/ic_nav_close.xml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M6,18L18,6M6,6l12,12"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round" />
|
||||||
|
</vector>
|
||||||
12
Android/app/src/main/res/drawable/ic_nav_forward.xml
Normal file
12
Android/app/src/main/res/drawable/ic_nav_forward.xml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M9,5l7,7 -7,7"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round" />
|
||||||
|
</vector>
|
||||||
12
Android/app/src/main/res/drawable/ic_nav_newtab.xml
Normal file
12
Android/app/src/main/res/drawable/ic_nav_newtab.xml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M10,6H6a2,2 0,0 0,-2 2v10a2,2 0,0 0,2 2h10a2,2 0,0 0,2 -2v-4M14,4h6m0,0v6m0,-6L10,14"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round" />
|
||||||
|
</vector>
|
||||||
12
Android/app/src/main/res/drawable/ic_nav_refresh.xml
Normal file
12
Android/app/src/main/res/drawable/ic_nav_refresh.xml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M4,4v6h6M20,20v-6h-6M5.64,15.36A8,8 0,0 0,18.36 18M18.36,8.64A8,8 0,0 0,5.64 6"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round" />
|
||||||
|
</vector>
|
||||||
@ -21,4 +21,15 @@
|
|||||||
<string name="retry">Retry</string>
|
<string name="retry">Retry</string>
|
||||||
<string name="remote_input">Remote Control</string>
|
<string name="remote_input">Remote Control</string>
|
||||||
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
|
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
|
||||||
|
<string name="close">Close</string>
|
||||||
|
<string name="open_in_browser">Open in browser</string>
|
||||||
|
<string name="back">Back</string>
|
||||||
|
<string name="forward">Forward</string>
|
||||||
|
<string name="refresh">Refresh</string>
|
||||||
|
<string name="server_name_label">Server Name (optional)</string>
|
||||||
|
<string name="server_name_placeholder">My Archipelago</string>
|
||||||
|
<string name="edit_server">Edit</string>
|
||||||
|
<string name="edit_server_title">Edit Server</string>
|
||||||
|
<string name="save_changes">Save Changes</string>
|
||||||
|
<string name="cancel">Cancel</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
10
Android/logo.svg
Normal file
10
Android/logo.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg width="752" height="752" viewBox="0 0 752 752" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="375.668" cy="375.669" r="364.227" fill="#0A0A0A" stroke="url(#paint0_linear_877_1990)" stroke-width="22.8834"/>
|
||||||
|
<path d="M253.805 278.37V222.28H309.853V278.37H253.805ZM315.797 278.37V222.28H372.694V278.37H315.797ZM378.639 278.37V222.28H435.536V278.37H378.639ZM441.481 278.37V222.28H497.529V278.37H441.481ZM441.481 341.259V284.319H497.529V341.259H441.481ZM503.473 341.259V284.319H560.37V341.259H503.473ZM190.963 404.148V347.208H247.86V404.148H190.963ZM253.805 404.148V347.208H309.853V404.148H253.805ZM315.797 404.148V347.208H372.694V404.148H315.797ZM378.639 404.148V347.208H435.536V404.148H378.639ZM441.481 404.148V347.208H497.529V404.148H441.481ZM503.473 404.148V347.208H560.37V404.148H503.473ZM190.963 466.187V410.097H247.86V466.187H190.963ZM253.805 466.187V410.097H309.853V466.187H253.805ZM441.481 466.187V410.097H497.529V466.187H441.481ZM503.473 466.187V410.097H560.37V466.187H503.473ZM253.805 529.076V472.136H309.853V529.076H253.805ZM315.797 529.076V472.136H372.694V529.076H315.797ZM378.639 529.076V472.136H435.536V529.076H378.639ZM441.481 529.076V472.136H497.529V529.076H441.481Z" fill="white"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_877_1990" x1="751.337" y1="751.338" x2="0" y2="0.000976562" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop/>
|
||||||
|
<stop offset="1" stop-color="#666666"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
41
Android/ship-companion.sh
Executable file
41
Android/ship-companion.sh
Executable file
@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Build the Android companion app and publish it as the served download
|
||||||
|
# (neode-ui/public/packages/archipelago-companion.apk — a plain APK a phone can
|
||||||
|
# install straight from the link), then commit + push.
|
||||||
|
#
|
||||||
|
# Use this INSTEAD of `git push` when shipping the companion app, so the
|
||||||
|
# downloadable APK on the node always matches what's on main.
|
||||||
|
#
|
||||||
|
# ./Android/ship-companion.sh
|
||||||
|
#
|
||||||
|
# The actual build/sign/verify/stage is done by scripts/publish-companion-apk.sh
|
||||||
|
# (single source of truth, shared with the pre-push hook). It does a CLEAN build,
|
||||||
|
# forces v1+v2+v3 signing, and ABORTS if any signature scheme is missing — so a
|
||||||
|
# broken or v2-only APK can never be shipped.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}"
|
||||||
|
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||||
|
|
||||||
|
DEST="neode-ui/public/packages/archipelago-companion.apk"
|
||||||
|
|
||||||
|
echo "==> Building + signing + verifying companion APK"
|
||||||
|
bash scripts/publish-companion-apk.sh
|
||||||
|
|
||||||
|
[ -f "$DEST" ] || { echo "ERROR: served APK not found at $DEST" >&2; exit 1; }
|
||||||
|
|
||||||
|
if git diff --cached --quiet -- "$DEST"; then
|
||||||
|
echo "==> Nothing to commit (APK unchanged)"
|
||||||
|
else
|
||||||
|
git commit -q -m "chore(android): update companion apk download"
|
||||||
|
echo "==> Committed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Pushing $(git branch --show-current)"
|
||||||
|
# SHIP_COMPANION lets the pre-push guard know the APK was just refreshed.
|
||||||
|
SHIP_COMPANION=1 git push origin "$(git branch --show-current)"
|
||||||
|
echo "==> Done — companion APK published and pushed."
|
||||||
29
CHANGELOG.md
29
CHANGELOG.md
@ -1,5 +1,34 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.8.00-alpha (2026-06-18)
|
||||||
|
|
||||||
|
Polishes the mesh AI assistant and Fedimint, on top of all the v1.7.99 features (kept listed below so you can still see what's new).
|
||||||
|
|
||||||
|
- The off-grid mesh radio no longer posts cryptic identity codes to the shared public channel. Your node was announcing a line starting with "ARCHY:" to the public channel about once a minute, which everyone else on that channel saw as spam; that broadcast has been removed.
|
||||||
|
- You can now use your node's AI assistant straight from a normal chat. Send "!ai <your question>" in a direct message to an AI-enabled node and the answer comes right back in the same conversation — whether your message travelled over the internet or the LoRa radio. Before, the reply could be sent on the wrong path and never arrive.
|
||||||
|
- The Mesh AI Assistant panel is easier to set up: pick the Claude model from a dropdown (Haiku, Sonnet, or Opus) instead of typing it, and add specific contacts to an "always allow" list so chosen people can use "!ai" even when the assistant is set to trusted-nodes-only.
|
||||||
|
- Fedimint federations show up in Wallet Settings again. The Fedimint client app wasn't starting because of a configuration error, so the federation your node auto-joins never appeared; the client is fixed and runs again.
|
||||||
|
- In Settings, "App Updates" and "App Registry" now sit directly under your Account section for quicker access.
|
||||||
|
- In Mesh chat, scrolling the conversation no longer also scrolls the contact list behind it.
|
||||||
|
- Mesh direct messages are now private and end-to-end encrypted to the recipient — they're sent as real radio DMs instead of being broadcast on the public channel, so other people on the mesh no longer see them, and the answer arrives intact (even on standard meshcore phone apps).
|
||||||
|
- You can now message standard meshcore apps (like the phone companion) and they can message you — text shows up readable on both sides, and your node's AI answers come back as a private reply rather than on the public channel.
|
||||||
|
- New contacts you hear on the radio are added automatically, so people show up in your Peers list without any extra steps.
|
||||||
|
- "Clear All" now actually removes contacts (rather than hiding them forever); a contact comes back on its own the next time it's in range. Each contact also shows a reachability dot so you can see who's currently reachable.
|
||||||
|
- The Peers list has a search box (with a clear button) to quickly filter your contacts by name, DID, npub, or key.
|
||||||
|
|
||||||
|
All the v1.7.99-alpha features are included as well:
|
||||||
|
|
||||||
|
- Your node can now hold Fedimint ecash as well as Cashu, with tabbed Wallet Settings for each and both balances shown side by side on the home wallet card.
|
||||||
|
- You can buy files shared by another node right from their cloud, paying from this node's ecash, your Lightning wallet, on-chain, or by scanning a Lightning QR with any outside wallet.
|
||||||
|
- Your node can act as an AI assistant on the off-grid mesh: peers ask by starting a message with "!ai" and get an answer back over the radio, with a panel to turn it on or off.
|
||||||
|
- You can view your node's 24-word recovery phrase any time from Settings, behind a password (and 2FA) confirmation and a tap-to-show blur.
|
||||||
|
- Setting up a brand-new node is smoother: it waits and retries quietly instead of flashing errors, and shows a gentle "securing your private connection…" status that turns to "ready" on its own.
|
||||||
|
- The NetBird VPN app now logs in (it's served over HTTPS and opens in a browser tab).
|
||||||
|
- Phone remote-control of a node's screen now supports two-finger scrolling inside apps, and external-browser apps open on your phone.
|
||||||
|
- You can choose whether your node shares Bitcoin block headers over the mesh, and your choices are remembered.
|
||||||
|
- Version numbers display cleanly everywhere (no more doubled "v"), and "Back" buttons look and behave consistently across desktop and mobile.
|
||||||
|
- For advanced testing, Settings includes an optional update & app source choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode, with the trusted origin remaining the default.
|
||||||
|
|
||||||
## v1.7.99-alpha (2026-06-17)
|
## v1.7.99-alpha (2026-06-17)
|
||||||
|
|
||||||
- Your node can now hold Fedimint ecash as well as Cashu. Wallet Settings now has tabbed sections for each: keep your list of trusted Cashu mints, or paste a Fedimint invite code to join a federation, and the home wallet card shows both your Cashu and Fedimint balances side by side. A new "Fedimint Client" app in the catalog powers the federation side.
|
- Your node can now hold Fedimint ecash as well as Cashu. Wallet Settings now has tabbed sections for each: keep your list of trusted Cashu mints, or paste a Fedimint invite code to join a federation, and the home wallet card shows both your Cashu and Fedimint balances side by side. A new "Fedimint Client" app in the catalog powers the federation side.
|
||||||
|
|||||||
57
CLAUDE.md
Normal file
57
CLAUDE.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Archipelago — agent guide
|
||||||
|
|
||||||
|
## ✅ Single-node production gate is GREEN (2026-06-23)
|
||||||
|
|
||||||
|
`tests/lifecycle/run-gate.sh` is **5/5 on .228, 0 failures** — the single-node exit
|
||||||
|
criterion is met and the priority banner is demoted. Next exit-criteria: the
|
||||||
|
**multinode pass** (`docs/multinode-testing-plan.md`) and workstreams B/C/D.
|
||||||
|
|
||||||
|
**Read `docs/PRODUCTION-MASTER-PLAN.md` first** — it is still the authoritative plan
|
||||||
|
for the north star: a world-class, **developer-ready app platform** where every app
|
||||||
|
is manifest-driven, manifests ship via the **signed registry** (not OTA disk files),
|
||||||
|
and **third-party developers publish apps via an external/decentralized registry** —
|
||||||
|
all rootless, secure, robust, and 100%-uptime-capable. It no longer overrides all
|
||||||
|
ad-hoc direction now that the gate is green, but it remains the source of truth for
|
||||||
|
sequencing the remaining workstreams.
|
||||||
|
|
||||||
|
Detailed sub-plans (all linked from the master):
|
||||||
|
- App platform / packaging phases + security model → `docs/APP-PACKAGING-MIGRATION-PLAN.md`
|
||||||
|
- Registry-distributed manifests (in progress) → `docs/registry-manifest-design.md`
|
||||||
|
- External/decentralized marketplace for devs → `docs/marketplace-protocol.md`
|
||||||
|
- Current per-app state → `docs/app-registry-status-2026-06-21.md`
|
||||||
|
- Production test gate (exit criterion) → `tests/lifecycle/TESTING.md`
|
||||||
|
|
||||||
|
## Invariants (never violate)
|
||||||
|
|
||||||
|
- **Rootless Podman only.** No rootful, no Docker-socket mounts, no privileged
|
||||||
|
containers unless explicitly approved.
|
||||||
|
- **No per-app Rust installers / no OS-level reliance.** Apps are declarative;
|
||||||
|
the orchestrator owns the lifecycle. `install_immich_stack` (hardcoded
|
||||||
|
`podman run` + `sudo chown`) is the anti-pattern being deleted, not a template.
|
||||||
|
- **Secrets are manifest-declared** (`generated_secrets`, materialised by
|
||||||
|
`container::secrets`, 0600/rootless) — never hardcoded, per-app, or logged.
|
||||||
|
- **Migrations never destroy data** — preserve `/var/lib/archipelago/<app>`,
|
||||||
|
secrets, credentials, ports, and adoption container names; keep a rollback path.
|
||||||
|
- **Verify on the real node .228 before any tag.** (Fleet-wide multinode
|
||||||
|
verification is a separate plan: `docs/multinode-testing-plan.md`.)
|
||||||
|
|
||||||
|
## Build / verify
|
||||||
|
|
||||||
|
- Rust workspace root is `core/` (no Cargo.toml at repo root). `cargo` from `core/`.
|
||||||
|
- If a `cargo test`/build hits `rust-lld: undefined hidden symbol`, it's
|
||||||
|
incremental-cache corruption — rebuild with `CARGO_INCREMENTAL=0`.
|
||||||
|
- Frontend: `neode-ui/` → `npm run build` outputs to `web/dist/neode-ui/`.
|
||||||
|
Grep the built bundle for new strings before shipping (build can silently no-op).
|
||||||
|
- App manifests load from disk on nodes at `/opt/archipelago/apps/*/manifest.yml`
|
||||||
|
(today); the goal is to distribute them via the signed catalog instead.
|
||||||
|
|
||||||
|
## Production test gate (definition of done)
|
||||||
|
|
||||||
|
`tests/lifecycle/run-gate.sh` green across install / UI / stop / start / restart /
|
||||||
|
reinstall / reboot-survive / archipelago-restart-survive / uninstall — **5× on
|
||||||
|
.228** (`ARCHY_ITERATIONS=5`). **Run the gate ON the node** (it uses local podman/systemctl/bitcoin
|
||||||
|
probes), not via RPC from another host. **✅ GREEN 2026-06-23 (5/5, 0 not-ok)** — keep it
|
||||||
|
green (re-run after orchestrator/lifecycle changes); regressions are top priority again.
|
||||||
|
**Multinode testing (.198 + the rest of the fleet) is a SEPARATE plan** —
|
||||||
|
`docs/multinode-testing-plan.md` — not part of this single-node gate criterion, and is
|
||||||
|
the next exit criterion now that single-node is green.
|
||||||
@ -73,7 +73,7 @@
|
|||||||
"author": "Mempool",
|
"author": "Mempool",
|
||||||
"category": "money",
|
"category": "money",
|
||||||
"tier": "core",
|
"tier": "core",
|
||||||
"dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0",
|
"dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1",
|
||||||
"repoUrl": "https://github.com/mempool/mempool",
|
"repoUrl": "https://github.com/mempool/mempool",
|
||||||
"requires": [
|
"requires": [
|
||||||
"bitcoin-knots",
|
"bitcoin-knots",
|
||||||
@ -195,7 +195,7 @@
|
|||||||
"title": "Nostr Relay (Rust)",
|
"title": "Nostr Relay (Rust)",
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
|
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
|
||||||
"icon": "/assets/img/app-icons/nostr.svg",
|
"icon": "/assets/img/app-icons/nostrudel.svg",
|
||||||
"author": "Nostr RS Relay",
|
"author": "Nostr RS Relay",
|
||||||
"category": "community",
|
"category": "community",
|
||||||
"tier": "recommended",
|
"tier": "recommended",
|
||||||
@ -214,31 +214,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "meshtastic",
|
|
||||||
"title": "Meshtastic",
|
|
||||||
"version": "2-daily-alpine",
|
|
||||||
"description": "Open-source mesh networking for LoRa radios. Create decentralized communication networks.",
|
|
||||||
"icon": "/assets/img/app-icons/meshcore.svg",
|
|
||||||
"author": "Meshtastic",
|
|
||||||
"category": "networking",
|
|
||||||
"tier": "recommended",
|
|
||||||
"dockerImage": "docker.io/meshtastic/meshtasticd:daily-alpine",
|
|
||||||
"repoUrl": "https://github.com/meshtastic/firmware",
|
|
||||||
"containerConfig": {
|
|
||||||
"ports": [
|
|
||||||
"4403:4403"
|
|
||||||
],
|
|
||||||
"volumes": [
|
|
||||||
"/var/lib/archipelago/meshtastic:/var/lib/meshtasticd"
|
|
||||||
],
|
|
||||||
"env": [
|
|
||||||
"MESHTASTIC_PORT=/dev/ttyUSB0",
|
|
||||||
"MESHTASTIC_SERIAL=true"
|
|
||||||
],
|
|
||||||
"notes": "Requires a LoRa radio device at /dev/ttyUSB0. The config file is rendered from the app manifest before container start."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "vaultwarden",
|
"id": "vaultwarden",
|
||||||
"title": "Vaultwarden",
|
"title": "Vaultwarden",
|
||||||
@ -281,7 +256,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "fedimint",
|
"id": "fedimint",
|
||||||
"title": "Fedimint",
|
"title": "Fedimint Guardian",
|
||||||
"version": "0.10.0",
|
"version": "0.10.0",
|
||||||
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
|
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
|
||||||
"icon": "/assets/img/app-icons/fedimint.png",
|
"icon": "/assets/img/app-icons/fedimint.png",
|
||||||
@ -294,12 +269,12 @@
|
|||||||
"id": "fedimint-clientd",
|
"id": "fedimint-clientd",
|
||||||
"title": "Fedimint Client",
|
"title": "Fedimint Client",
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"description": "Fedimint ecash client daemon (fmcd). Lets your node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
|
"description": "Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
|
||||||
"icon": "/assets/img/app-icons/fedimint.png",
|
"icon": "/assets/img/app-icons/fedimint.png",
|
||||||
"author": "Fedimint",
|
"author": "Fedimint",
|
||||||
"category": "money",
|
"category": "money",
|
||||||
"tier": "core",
|
"tier": "core",
|
||||||
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.0",
|
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.1",
|
||||||
"repoUrl": "https://github.com/minmoto/fmcd"
|
"repoUrl": "https://github.com/minmoto/fmcd"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -346,8 +321,8 @@
|
|||||||
{
|
{
|
||||||
"id": "immich",
|
"id": "immich",
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"version": "1.90.0",
|
"version": "2.7.4",
|
||||||
"description": "High-performance photo and video backup with ML.",
|
"description": "Self-hosted photo and video backup with mobile apps and search.",
|
||||||
"icon": "/assets/img/app-icons/immich.png",
|
"icon": "/assets/img/app-icons/immich.png",
|
||||||
"author": "Immich",
|
"author": "Immich",
|
||||||
"category": "data",
|
"category": "data",
|
||||||
@ -453,13 +428,13 @@
|
|||||||
{
|
{
|
||||||
"id": "netbird",
|
"id": "netbird",
|
||||||
"title": "NetBird",
|
"title": "NetBird",
|
||||||
"version": "0.71.2",
|
"version": "2.38.0",
|
||||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.",
|
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
|
||||||
"icon": "/assets/img/app-icons/netbird.svg",
|
"icon": "/assets/img/app-icons/netbird.svg",
|
||||||
"author": "NetBird",
|
"author": "NetBird",
|
||||||
"category": "networking",
|
"category": "networking",
|
||||||
"tier": "recommended",
|
"tier": "recommended",
|
||||||
"dockerImage": "docker.io/netbirdio/dashboard:v2.38.0",
|
"dockerImage": "docker.io/library/nginx:1.27-alpine",
|
||||||
"repoUrl": "https://github.com/netbirdio/netbird",
|
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||||
"containerConfig": {
|
"containerConfig": {
|
||||||
"ports": [
|
"ports": [
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
app:
|
app:
|
||||||
id: archy-mempool-web
|
id: archy-mempool-web
|
||||||
name: Mempool Web
|
name: Mempool Web
|
||||||
version: 3.0.0
|
version: 3.0.1
|
||||||
description: Frontend web UI for mempool explorer.
|
description: Frontend web UI for mempool explorer.
|
||||||
container_name: mempool
|
container_name: mempool
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: git.tx1138.com/lfg2025/mempool-frontend:v3.0.0
|
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
network: archy-net
|
network: archy-net
|
||||||
|
|
||||||
@ -33,7 +33,10 @@ app:
|
|||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: http
|
||||||
endpoint: http://localhost:8080
|
# 127.0.0.1 not localhost: the image's wget resolves localhost to ::1 (IPv6)
|
||||||
|
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
|
||||||
|
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
|
||||||
|
endpoint: http://127.0.0.1:8080
|
||||||
path: /
|
path: /
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
|
|||||||
@ -1,5 +1,34 @@
|
|||||||
# Bitcoin Core - uses official image
|
# Bitcoin Core — minimal rootless image built from the OFFICIAL upstream release.
|
||||||
FROM bitcoin/bitcoin:24.0
|
#
|
||||||
|
# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which
|
||||||
# Default user is already 'bitcoin'
|
# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature
|
||||||
# No additional setup needed
|
# (fail-closed), and tags/pushes <registry>/bitcoin:<version>. This Dockerfile
|
||||||
|
# mirrors that image for a manual/local build and replaces the old stale
|
||||||
|
# community base (`FROM bitcoin/bitcoin:24.0`).
|
||||||
|
#
|
||||||
|
# Build (binaries must be pre-fetched + verified into ./bin — see the script):
|
||||||
|
# scripts/build-bitcoin-image.sh core 31.0
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
ARG BITCOIN_VERSION=31.0
|
||||||
|
RUN set -eux; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates; \
|
||||||
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
|
useradd -m -u 1000 -s /bin/bash bitcoin; \
|
||||||
|
mkdir -p /home/bitcoin/.bitcoin; \
|
||||||
|
chown -R bitcoin:bitcoin /home/bitcoin
|
||||||
|
# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Guix-built,
|
||||||
|
# x86_64-linux-gnu) extracted from the official release tarball.
|
||||||
|
COPY bin/bitcoind /usr/local/bin/bitcoind
|
||||||
|
COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli
|
||||||
|
RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli
|
||||||
|
# Run as (container) root, like the legacy hand-built :latest image. Rootless
|
||||||
|
# Podman maps container-root to the unprivileged host service user; the manifest
|
||||||
|
# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the
|
||||||
|
# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to
|
||||||
|
# this image's `bitcoin` user. A non-root USER can't read existing chain data and
|
||||||
|
# bitcoind crash-loops with "Error initializing block database".
|
||||||
|
WORKDIR /home/bitcoin
|
||||||
|
VOLUME ["/home/bitcoin/.bitcoin"]
|
||||||
|
EXPOSE 8332 8333
|
||||||
|
ENTRYPOINT ["bitcoind"]
|
||||||
|
|||||||
35
apps/bitcoin-knots/Dockerfile
Normal file
35
apps/bitcoin-knots/Dockerfile
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Bitcoin Knots — minimal rootless image built from the OFFICIAL upstream release.
|
||||||
|
#
|
||||||
|
# Knots previously had NO Dockerfile (the :latest tag was built/pushed by hand).
|
||||||
|
# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which
|
||||||
|
# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature
|
||||||
|
# (fail-closed, Luke-Jr release key), and tags/pushes
|
||||||
|
# <registry>/bitcoin-knots:<version>. Knots version strings embed a build date,
|
||||||
|
# e.g. 29.3.knots20260508 — the full string is the tag.
|
||||||
|
#
|
||||||
|
# Build (binaries must be pre-fetched + verified into ./bin — see the script):
|
||||||
|
# scripts/build-bitcoin-image.sh knots 29.3.knots20260508
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
ARG KNOTS_VERSION=29.3.knots20260508
|
||||||
|
RUN set -eux; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates; \
|
||||||
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
|
useradd -m -u 1000 -s /bin/bash bitcoin; \
|
||||||
|
mkdir -p /home/bitcoin/.bitcoin; \
|
||||||
|
chown -R bitcoin:bitcoin /home/bitcoin
|
||||||
|
# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Knots, Guix-built,
|
||||||
|
# x86_64-linux-gnu) extracted from the official release tarball.
|
||||||
|
COPY bin/bitcoind /usr/local/bin/bitcoind
|
||||||
|
COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli
|
||||||
|
RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli
|
||||||
|
# Run as (container) root, like the legacy hand-built :latest image. Rootless
|
||||||
|
# Podman maps container-root to the unprivileged host service user; the manifest
|
||||||
|
# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the
|
||||||
|
# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to
|
||||||
|
# this image's `bitcoin` user. A non-root USER can't read existing chain data and
|
||||||
|
# bitcoind crash-loops with "Error initializing block database".
|
||||||
|
WORKDIR /home/bitcoin
|
||||||
|
VOLUME ["/home/bitcoin/.bitcoin"]
|
||||||
|
EXPOSE 8332 8333
|
||||||
|
ENTRYPOINT ["bitcoind"]
|
||||||
@ -9,13 +9,18 @@ app:
|
|||||||
# 0.8.2 — iroh-capable). No usable upstream image exists, so we build + push
|
# 0.8.2 — iroh-capable). No usable upstream image exists, so we build + push
|
||||||
# this to the node registry. Pin the tag to match the REST shapes coded in
|
# this to the node registry. Pin the tag to match the REST shapes coded in
|
||||||
# core/archipelago/src/wallet/fedimint_client.rs (validated against 0.8.2).
|
# core/archipelago/src/wallet/fedimint_client.rs (validated against 0.8.2).
|
||||||
image: 146.59.87.168:3000/lfg2025/fmcd:0.8.0
|
image: 146.59.87.168:3000/lfg2025/fmcd:0.8.1
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
network: archy-net
|
network: archy-net
|
||||||
# No entrypoint override: the image's resilient `fmcd-run` launcher loops
|
# No entrypoint override: the image's resilient `fmcd-run` launcher loops
|
||||||
# fmcd and retries on join failure (fmcd needs >=1 federation to boot), so an
|
# fmcd and retries on join failure (fmcd needs >=1 federation to boot), so an
|
||||||
# unreachable default never crash-loops. All config comes from FMCD_* env
|
# unreachable default never crash-loops. All config comes from FMCD_* env
|
||||||
# below. Nodes can join more federations via wallet.fedimint-join.
|
# below. Nodes can join more federations via wallet.fedimint-join.
|
||||||
|
# Auto-generated on first install (random hex, 0600, rootless-owned) so the
|
||||||
|
# app needs no host provisioning. The wallet bridge reads the same file.
|
||||||
|
generated_secrets:
|
||||||
|
- name: fmcd-password
|
||||||
|
kind: hex16
|
||||||
secret_env:
|
secret_env:
|
||||||
- key: FMCD_PASSWORD
|
- key: FMCD_PASSWORD
|
||||||
secret_file: fmcd-password
|
secret_file: fmcd-password
|
||||||
@ -28,17 +33,32 @@ app:
|
|||||||
- storage: 2Gi
|
- storage: 2Gi
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
|
# fmcd's embedded iroh networking can hot-loop on relay/hole-punch retries
|
||||||
|
# on NAT'd nodes that reach the federation neither directly nor via iroh's
|
||||||
|
# public relays, pegging its whole allotment. Cap it low so a stuck instance
|
||||||
|
# can't starve the node (steady-state is <3% of a core; joins are brief);
|
||||||
|
# the fmcd-run watchdog additionally restarts a sustained-hot process.
|
||||||
cpu_limit: 1
|
cpu_limit: 1
|
||||||
memory_limit: 1Gi
|
memory_limit: 1Gi
|
||||||
disk_limit: 2Gi
|
disk_limit: 2Gi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
capabilities: []
|
# fmcd's `fmcd-run` launcher chowns its /data (existing federation DB) on
|
||||||
|
# every start. With the default `cap_drop: ALL` and no caps added back, that
|
||||||
|
# chown fails and fmcd dies "Operation not permitted (os error 1)" — but ONLY
|
||||||
|
# once /data holds a joined federation (a fresh/empty dir needs no chown, so
|
||||||
|
# it appeared to work). Restore the standard container capability set so the
|
||||||
|
# startup chown succeeds (#7). Verified by bisection on .116: these caps make
|
||||||
|
# fmcd boot + serve /v2/*; DAC_OVERRIDE or SETUID/SETGID alone do NOT.
|
||||||
|
capabilities: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID"]
|
||||||
readonly_root: true
|
readonly_root: true
|
||||||
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
|
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
|
||||||
# relays to reach iroh-transport federations. Lock down once the default
|
# relays to reach iroh-transport federations. `bridge` gives NAT'd outbound
|
||||||
# federation's reachability model is finalized.
|
# (UDP/DHT/iroh hole-punch all work) plus the published 8178→8080 port the
|
||||||
network_policy: open
|
# wallet bridge targets. ("open" is not a valid policy — it made the loader
|
||||||
|
# skip this whole manifest, so fmcd never ran and federations never joined.)
|
||||||
|
# Lock down once the default federation's reachability model is finalized.
|
||||||
|
network_policy: bridge
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
# fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the
|
# fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the
|
||||||
@ -66,10 +86,15 @@ app:
|
|||||||
# join reliability from a real second node before relying on auto-bundle.
|
# join reliability from a real second node before relying on auto-bundle.
|
||||||
- FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp
|
- FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp
|
||||||
|
|
||||||
|
# fmcd serves only authenticated /v2/* routes — there is no unauthenticated
|
||||||
|
# /health endpoint, so an http probe to /health 404s forever and pins the
|
||||||
|
# container in "(starting)". fmcd's own image also ships neither curl nor wget.
|
||||||
|
# Use a TCP probe: the Quadlet renderer skips it (no HealthCmd emitted) and the
|
||||||
|
# host-side lifecycle layer verifies reachability, so the container reports
|
||||||
|
# "running" instead of a perpetual false-negative "(starting)".
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: tcp
|
||||||
endpoint: http://localhost:8080
|
endpoint: localhost:8080
|
||||||
path: /health
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@ -16,6 +16,14 @@ app:
|
|||||||
else
|
else
|
||||||
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||||
fi
|
fi
|
||||||
|
# The gateway's admin API is gated by a bcrypt password hash. Generate it on
|
||||||
|
# first install (random password + its bcrypt hash, both 0600 rootless-owned)
|
||||||
|
# so the app installs from its manifest alone — `fedimint-gateway-hash` holds
|
||||||
|
# the hash passed to gatewayd, `fedimint-gateway-hash.pw` the plaintext for
|
||||||
|
# any client that must authenticate. Self-heals a wrongly root-owned hash.
|
||||||
|
generated_secrets:
|
||||||
|
- name: fedimint-gateway-hash
|
||||||
|
kind: bcrypt
|
||||||
secret_env:
|
secret_env:
|
||||||
- key: FM_BITCOIND_PASSWORD
|
- key: FM_BITCOIND_PASSWORD
|
||||||
secret_file: bitcoin-rpc-password
|
secret_file: bitcoin-rpc-password
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
app:
|
app:
|
||||||
id: fedimint
|
id: fedimint
|
||||||
name: Fedimint
|
name: Fedimint Guardian
|
||||||
version: 0.10.0
|
version: 0.10.0
|
||||||
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
|
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
|
||||||
|
|
||||||
|
|||||||
58
apps/immich-postgres/manifest.yml
Normal file
58
apps/immich-postgres/manifest.yml
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
app:
|
||||||
|
id: immich-postgres
|
||||||
|
name: Immich Postgres
|
||||||
|
version: "14-vectorchord0.4.3-pgvectors0.2.0"
|
||||||
|
description: Postgres (pgvecto.rs / vectorchord) backend for Immich.
|
||||||
|
|
||||||
|
# Container named immich_postgres (underscore) to match the runtime's existing
|
||||||
|
# per-app references (lifecycle/health/crash-recovery/config) and serve as the
|
||||||
|
# server's DB_HOSTNAME alias. Top-level key → serde(flatten) → extensions →
|
||||||
|
# compute_container_name.
|
||||||
|
container_name: immich_postgres
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
# postgres drops to its own uid (container 999 → host 100998 under rootless),
|
||||||
|
# so the data dir must be owned by that mapped uid — mirrors archy-btcpay-db.
|
||||||
|
# Verified on .228: the live immich-db is owned 100998. Without this a FRESH
|
||||||
|
# install's dir would be service-user-owned and postgres would EACCES.
|
||||||
|
data_uid: "100998:100998"
|
||||||
|
generated_secrets:
|
||||||
|
- name: immich-db-password
|
||||||
|
kind: hex32
|
||||||
|
secret_env:
|
||||||
|
- key: POSTGRES_PASSWORD
|
||||||
|
secret_file: immich-db-password
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 40Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 2Gi
|
||||||
|
disk_limit: 40Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/immich-db
|
||||||
|
target: /var/lib/postgresql/data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_DB=immich
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:5432
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
37
apps/immich-redis/manifest.yml
Normal file
37
apps/immich-redis/manifest.yml
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
app:
|
||||||
|
id: immich-redis
|
||||||
|
name: Immich Redis
|
||||||
|
version: "7-alpine"
|
||||||
|
description: Valkey (Redis-compatible) cache for Immich.
|
||||||
|
|
||||||
|
# Container named immich_redis (underscore) to match runtime per-app references
|
||||||
|
# and serve as the server's REDIS_HOSTNAME alias on archy-net.
|
||||||
|
container_name: immich_redis
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/valkey:7-alpine
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
|
||||||
|
dependencies: []
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 128Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [SETGID, SETUID]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
volumes: []
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:6379
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
74
apps/immich/manifest.yml
Normal file
74
apps/immich/manifest.yml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
app:
|
||||||
|
id: immich
|
||||||
|
name: Immich
|
||||||
|
version: "2.7.4"
|
||||||
|
description: Self-hosted photo and video backup with mobile apps and search.
|
||||||
|
|
||||||
|
# app_id "immich" = the user-facing launcher (matches the catalog entry's title
|
||||||
|
# + icon). The container is named "immich_server" so it matches the runtime's
|
||||||
|
# existing per-app container references (lifecycle/health/crash-recovery/ports);
|
||||||
|
# `container_name` is a top-level app key (captured by serde(flatten) into
|
||||||
|
# extensions, read by compute_container_name). It reaches its backends by their
|
||||||
|
# underscore aliases on archy-net (DB_HOSTNAME / REDIS_HOSTNAME below).
|
||||||
|
container_name: immich_server
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/immich-server:release
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
secret_env:
|
||||||
|
- key: DB_PASSWORD
|
||||||
|
secret_file: immich-db-password
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: immich-postgres
|
||||||
|
- app_id: immich-redis
|
||||||
|
- storage: 200Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 2Gi
|
||||||
|
disk_limit: 200Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 2283
|
||||||
|
container: 2283
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/immich
|
||||||
|
target: /usr/src/app/upload
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- DB_HOSTNAME=immich_postgres
|
||||||
|
- DB_USERNAME=postgres
|
||||||
|
- DB_DATABASE_NAME=immich
|
||||||
|
- REDIS_HOSTNAME=immich_redis
|
||||||
|
- UPLOAD_LOCATION=/usr/src/app/upload
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://localhost:2283
|
||||||
|
path: /api/server/ping
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Immich photo library
|
||||||
|
type: ui
|
||||||
|
port: 2283
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
launch:
|
||||||
|
open_in_new_tab: true
|
||||||
77
apps/indeedhub-api/manifest.yml
Normal file
77
apps/indeedhub-api/manifest.yml
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
app:
|
||||||
|
id: indeedhub-api
|
||||||
|
name: IndeedHub API
|
||||||
|
version: "1.0.0"
|
||||||
|
description: IndeedHub backend API (Nostr auth, media, payments).
|
||||||
|
category: community
|
||||||
|
|
||||||
|
# Hyphen name matches runtime references + the live container (adoption);
|
||||||
|
# alias `api` is the short hostname the frontend nginx proxies to
|
||||||
|
# (http://api:4000). Reaches its backends by their short aliases
|
||||||
|
# (postgres/redis/minio) on indeedhub-net — unchanged from the legacy installer.
|
||||||
|
container_name: indeedhub-api
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/indeedhub-api:1.0.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: indeedhub-net
|
||||||
|
network_aliases: [api]
|
||||||
|
# The JWT signing secret is owned here (no backend container owns it); the
|
||||||
|
# db + minio passwords are owned by indeedhub-postgres / indeedhub-minio and
|
||||||
|
# only consumed here. ensure_generated_secrets no-ops when a file already
|
||||||
|
# exists, so live values on .228 are preserved (postgres pw is fixed at
|
||||||
|
# PGDATA init — regenerating would lock the API out).
|
||||||
|
generated_secrets:
|
||||||
|
- name: indeedhub-jwt
|
||||||
|
kind: hex32
|
||||||
|
secret_env:
|
||||||
|
- key: DATABASE_PASSWORD
|
||||||
|
secret_file: indeedhub-db-password
|
||||||
|
- key: AWS_SECRET_KEY
|
||||||
|
secret_file: indeedhub-minio-password
|
||||||
|
- key: NOSTR_JWT_SECRET
|
||||||
|
secret_file: indeedhub-jwt
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: indeedhub-postgres
|
||||||
|
- app_id: indeedhub-redis
|
||||||
|
- app_id: indeedhub-minio
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 2Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
volumes: []
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- PORT=4000
|
||||||
|
- DATABASE_HOST=postgres
|
||||||
|
- DATABASE_PORT=5432
|
||||||
|
- DATABASE_USER=indeedhub
|
||||||
|
- DATABASE_NAME=indeedhub
|
||||||
|
- QUEUE_HOST=redis
|
||||||
|
- QUEUE_PORT=6379
|
||||||
|
- S3_ENDPOINT=http://minio:9000
|
||||||
|
- AWS_REGION=us-east-1
|
||||||
|
- AWS_ACCESS_KEY=indeeadmin
|
||||||
|
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
|
||||||
|
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
|
||||||
|
- S3_PUBLIC_BUCKET_URL=/storage
|
||||||
|
- NOSTR_JWT_EXPIRES_IN=7d
|
||||||
|
# Fixed across the fleet (envelope-encryption master key baked by the legacy
|
||||||
|
# installer); not node-specific, so a plain env literal, not a secret.
|
||||||
|
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
|
||||||
|
- ENVIRONMENT=production
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:4000
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
51
apps/indeedhub-ffmpeg/manifest.yml
Normal file
51
apps/indeedhub-ffmpeg/manifest.yml
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
app:
|
||||||
|
id: indeedhub-ffmpeg
|
||||||
|
name: IndeedHub FFmpeg Worker
|
||||||
|
version: "1.0.0"
|
||||||
|
description: IndeedHub background media transcoding worker.
|
||||||
|
category: community
|
||||||
|
|
||||||
|
# Hyphen name matches runtime references + the live container (adoption). No
|
||||||
|
# network_alias: nothing connects TO the worker — it only dials out to
|
||||||
|
# postgres/redis/minio (resolved by their aliases on indeedhub-net).
|
||||||
|
container_name: indeedhub-ffmpeg
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/indeedhub-ffmpeg:1.0.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: indeedhub-net
|
||||||
|
secret_env:
|
||||||
|
- key: DATABASE_PASSWORD
|
||||||
|
secret_file: indeedhub-db-password
|
||||||
|
- key: AWS_SECRET_KEY
|
||||||
|
secret_file: indeedhub-minio-password
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: indeedhub-api
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 4Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
volumes: []
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- DATABASE_HOST=postgres
|
||||||
|
- DATABASE_PORT=5432
|
||||||
|
- DATABASE_USER=indeedhub
|
||||||
|
- DATABASE_NAME=indeedhub
|
||||||
|
- QUEUE_HOST=redis
|
||||||
|
- QUEUE_PORT=6379
|
||||||
|
- S3_ENDPOINT=http://minio:9000
|
||||||
|
- AWS_REGION=us-east-1
|
||||||
|
- AWS_ACCESS_KEY=indeeadmin
|
||||||
|
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
|
||||||
|
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
|
||||||
|
- ENVIRONMENT=production
|
||||||
|
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
|
||||||
60
apps/indeedhub-minio/manifest.yml
Normal file
60
apps/indeedhub-minio/manifest.yml
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
app:
|
||||||
|
id: indeedhub-minio
|
||||||
|
name: IndeedHub MinIO
|
||||||
|
version: "RELEASE.2024-11-07T00-52-20Z"
|
||||||
|
description: MinIO S3-compatible object storage for IndeedHub media.
|
||||||
|
category: community
|
||||||
|
|
||||||
|
# Hyphen name matches runtime references + the live container (adoption);
|
||||||
|
# alias `minio` is the short hostname the api/ffmpeg use (S3_ENDPOINT=
|
||||||
|
# http://minio:9000) AND the frontend nginx proxies to (http://minio:9000).
|
||||||
|
container_name: indeedhub-minio
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/minio:RELEASE.2024-11-07T00-52-20Z
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: indeedhub-net
|
||||||
|
network_aliases: [minio]
|
||||||
|
# `server /data` — the minio entrypoint args from the legacy installer.
|
||||||
|
custom_args: [server, /data]
|
||||||
|
generated_secrets:
|
||||||
|
- name: indeedhub-minio-password
|
||||||
|
kind: hex32
|
||||||
|
secret_env:
|
||||||
|
- key: MINIO_ROOT_PASSWORD
|
||||||
|
secret_file: indeedhub-minio-password
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 50Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 1Gi
|
||||||
|
disk_limit: 50Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
# Named volume matches the live indeedhub-minio-data volume on .228.
|
||||||
|
volumes:
|
||||||
|
- type: volume
|
||||||
|
source: indeedhub-minio-data
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
# MINIO_ROOT_USER "indeeadmin" is the fixed admin identity baked by the legacy
|
||||||
|
# installer (api/ffmpeg use it as AWS_ACCESS_KEY); the password is the
|
||||||
|
# generated secret above. Not secret, so it stays a plain env value.
|
||||||
|
environment:
|
||||||
|
- MINIO_ROOT_USER=indeeadmin
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://localhost:9000
|
||||||
|
path: /minio/health/live
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
59
apps/indeedhub-postgres/manifest.yml
Normal file
59
apps/indeedhub-postgres/manifest.yml
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
app:
|
||||||
|
id: indeedhub-postgres
|
||||||
|
name: IndeedHub Postgres
|
||||||
|
version: "16.13-alpine"
|
||||||
|
description: Postgres database backend for IndeedHub.
|
||||||
|
category: community
|
||||||
|
|
||||||
|
# Container named indeedhub-postgres (hyphen) to match the runtime's existing
|
||||||
|
# per-app references (health_monitor tiers/deps, crash_recovery) and the live
|
||||||
|
# .228 install, so the orchestrator ADOPTS the running container instead of
|
||||||
|
# recreating it. `network_aliases: [postgres]` keeps the short hostname the
|
||||||
|
# api/ffmpeg/relay reach by (DATABASE_HOST=postgres) resolvable on
|
||||||
|
# indeedhub-net, reproducing the legacy `--network-alias postgres`.
|
||||||
|
container_name: indeedhub-postgres
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/postgres:16.13-alpine
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: indeedhub-net
|
||||||
|
network_aliases: [postgres]
|
||||||
|
generated_secrets:
|
||||||
|
- name: indeedhub-db-password
|
||||||
|
kind: hex32
|
||||||
|
secret_env:
|
||||||
|
- key: POSTGRES_PASSWORD
|
||||||
|
secret_file: indeedhub-db-password
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 10Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 1Gi
|
||||||
|
disk_limit: 10Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
# Named podman volume (matches the live indeedhub-postgres-data volume on .228);
|
||||||
|
# preserves all existing database content across the migration.
|
||||||
|
volumes:
|
||||||
|
- type: volume
|
||||||
|
source: indeedhub-postgres-data
|
||||||
|
target: /var/lib/postgresql/data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=indeedhub
|
||||||
|
- POSTGRES_DB=indeedhub
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:5432
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
45
apps/indeedhub-redis/manifest.yml
Normal file
45
apps/indeedhub-redis/manifest.yml
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
app:
|
||||||
|
id: indeedhub-redis
|
||||||
|
name: IndeedHub Redis
|
||||||
|
version: "7.4.8-alpine"
|
||||||
|
description: Redis queue/cache backend for IndeedHub.
|
||||||
|
category: community
|
||||||
|
|
||||||
|
# Hyphen name matches runtime references + the live container (adoption);
|
||||||
|
# alias `redis` is the short hostname the api/ffmpeg reach (QUEUE_HOST=redis).
|
||||||
|
container_name: indeedhub-redis
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/redis:7.4.8-alpine
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: indeedhub-net
|
||||||
|
network_aliases: [redis]
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 1Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 256Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [SETGID, SETUID]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
# Named volume matches the live indeedhub-redis-data volume on .228.
|
||||||
|
volumes:
|
||||||
|
- type: volume
|
||||||
|
source: indeedhub-redis-data
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:6379
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
47
apps/indeedhub-relay/manifest.yml
Normal file
47
apps/indeedhub-relay/manifest.yml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
app:
|
||||||
|
id: indeedhub-relay
|
||||||
|
name: IndeedHub Nostr Relay
|
||||||
|
version: "0.9.0"
|
||||||
|
description: nostr-rs-relay backing IndeedHub's Nostr identity + comments.
|
||||||
|
category: community
|
||||||
|
|
||||||
|
# Hyphen name matches runtime references + the live container (adoption);
|
||||||
|
# alias `relay` is the short hostname the frontend nginx proxies to
|
||||||
|
# (http://relay:8080 for the /relay websocket).
|
||||||
|
container_name: indeedhub-relay
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: indeedhub-net
|
||||||
|
network_aliases: [relay]
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 2Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 256Mi
|
||||||
|
disk_limit: 2Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
# Named volume matches the live indeedhub-relay-data volume on .228.
|
||||||
|
volumes:
|
||||||
|
- type: volume
|
||||||
|
source: indeedhub-relay-data
|
||||||
|
target: /usr/src/app/db
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:8080
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
@ -1,63 +1,84 @@
|
|||||||
app:
|
app:
|
||||||
id: indeedhub
|
id: indeedhub
|
||||||
name: IndeeHub
|
name: IndeeHub
|
||||||
version: 1.0.0
|
version: "1.0.0"
|
||||||
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
|
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
|
||||||
category: community
|
category: community
|
||||||
|
|
||||||
|
# The user-facing launcher (app_id "indeedhub"). Container is named "indeedhub"
|
||||||
|
# (matches the runtime's per-app references + the live container, so the
|
||||||
|
# orchestrator adopts it). Its nginx (listen 7777) proxies to the backends by
|
||||||
|
# their short aliases on indeedhub-net: api:4000, minio:9000, relay:8080.
|
||||||
|
container_name: indeedhub
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: 146.59.87.168:3000/lfg2025/indeedhub:1.0.0
|
image: 146.59.87.168:3000/lfg2025/indeedhub:1.0.0
|
||||||
pull_policy: always # Pull from registry; falls back to local build
|
pull_policy: if-not-present
|
||||||
network: indeedhub-net
|
network: indeedhub-net
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
- app_id: indeedhub-api
|
||||||
- storage: 1Gi
|
- storage: 1Gi
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
cpu_limit: 2
|
|
||||||
memory_limit: 512Mi
|
memory_limit: 512Mi
|
||||||
disk_limit: 1Gi
|
disk_limit: 1Gi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
capabilities: []
|
# nginx master runs as root and drops workers to the nginx user (uid/gid
|
||||||
readonly_root: true
|
# 101) — needs SET{UID,GID}; CHOWN + DAC_OVERRIDE let it own + write the
|
||||||
no_new_privileges: true
|
# proxy cache under the tmpfs /var/cache/nginx. The orchestrator does
|
||||||
user: 1001
|
# --cap-drop=ALL, so (unlike the legacy `podman run` default caps) these
|
||||||
seccomp_profile: default
|
# must be declared or nginx workers die with "setgid(101) failed".
|
||||||
network_policy: bridge
|
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID]
|
||||||
apparmor_profile: default
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- host: 7778
|
- host: 7778
|
||||||
container: 7777
|
container: 7777
|
||||||
protocol: tcp # Web UI. Port 7777 on the host is reserved for Nostr relay.
|
protocol: tcp # Web UI. Port 7777 on the host is reserved for the Nostr relay.
|
||||||
|
|
||||||
|
# Writable scratch the baked nginx needs; matches the legacy installer's
|
||||||
|
# --tmpfs /run + /var/cache/nginx.
|
||||||
volumes:
|
volumes:
|
||||||
- type: tmpfs
|
|
||||||
target: /tmp
|
|
||||||
options: [rw,noexec,nosuid,size=64m]
|
|
||||||
- type: tmpfs
|
|
||||||
target: /app/.next/cache
|
|
||||||
options: [rw,noexec,nosuid,size=128m]
|
|
||||||
- type: tmpfs
|
- type: tmpfs
|
||||||
target: /run
|
target: /run
|
||||||
options: [rw,nosuid,nodev,size=16m]
|
options: [rw, nosuid, nodev, size=16m]
|
||||||
- type: tmpfs
|
- type: tmpfs
|
||||||
target: /var/cache/nginx
|
target: /var/cache/nginx
|
||||||
options: [rw,nosuid,nodev,size=32m]
|
options: [rw, nosuid, nodev, size=32m]
|
||||||
|
|
||||||
environment:
|
environment: []
|
||||||
- NODE_ENV=production
|
|
||||||
- NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
|
# Defensive + idempotent. The current indeedhub:1.0.0 image already bakes the
|
||||||
|
# iframe-friendly nginx (X-Frame-Options omitted, nostr-provider.js present +
|
||||||
|
# <script> injected), so these are mostly no-ops on that tag — but they keep
|
||||||
|
# the app iframe-loadable + the provider script fresh for any image build that
|
||||||
|
# predates the bake. copy_from_host pulls /opt/archipelago/web-ui/nostr-provider.js
|
||||||
|
# (kept current by frontend OTA releases). Replaces the legacy hardcoded
|
||||||
|
# patch_indeedhub_nostr_provider() Rust hook.
|
||||||
|
hooks:
|
||||||
|
post_install:
|
||||||
|
- exec: ["sed", "-i", "/X-Frame-Options/d", "/etc/nginx/conf.d/default.conf"]
|
||||||
|
- copy_from_host:
|
||||||
|
src: "web-ui/nostr-provider.js"
|
||||||
|
dest: "/usr/share/nginx/html/nostr-provider.js"
|
||||||
|
- exec: ["sh", "-c", "grep -q nostr-provider /etc/nginx/conf.d/default.conf || sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /etc/nginx/conf.d/default.conf"]
|
||||||
|
- exec: ["nginx", "-s", "reload"]
|
||||||
|
|
||||||
|
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
|
||||||
|
# startup (before workers), so this passes immediately and stays green under
|
||||||
|
# load. An http check of / runs the SPA + sub_filter and false-fails when the
|
||||||
|
# node is busy → the reconciler then treats the frontend as wedged and
|
||||||
|
# recreates it in a loop (observed churning the frontend on the loaded .198).
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: tcp
|
||||||
endpoint: http://localhost:3000
|
endpoint: localhost:7777
|
||||||
path: /
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 5
|
||||||
start_period: 40s
|
start_period: 30s
|
||||||
|
|
||||||
interfaces:
|
interfaces:
|
||||||
main:
|
main:
|
||||||
|
|||||||
@ -5,7 +5,7 @@ app:
|
|||||||
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0
|
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ app:
|
|||||||
|
|
||||||
ports:
|
ports:
|
||||||
- host: 4080
|
- host: 4080
|
||||||
container: 4080
|
container: 8080 # mempool-frontend nginx listens on 8080 (FRONTEND_HTTP_PORT=8080)
|
||||||
protocol: tcp # Web UI
|
protocol: tcp # Web UI
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
# Meshtastic - uses official image
|
|
||||||
FROM meshtastic/meshtastic:latest
|
|
||||||
|
|
||||||
# Default configuration is in the image
|
|
||||||
# No additional setup needed
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
app:
|
|
||||||
id: meshtastic
|
|
||||||
name: Meshtastic
|
|
||||||
version: 2-daily-alpine
|
|
||||||
description: Open-source mesh networking for LoRa radios. Create decentralized communication networks.
|
|
||||||
|
|
||||||
container:
|
|
||||||
image: docker.io/meshtastic/meshtasticd:daily-alpine
|
|
||||||
pull_policy: if-not-present
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
- storage: 1Gi
|
|
||||||
|
|
||||||
resources:
|
|
||||||
cpu_limit: 1
|
|
||||||
memory_limit: 512Mi
|
|
||||||
disk_limit: 1Gi
|
|
||||||
|
|
||||||
security:
|
|
||||||
capabilities: [NET_ADMIN, SYS_ADMIN] # Required for LoRa radio access
|
|
||||||
readonly_root: false # Needs write access for device management
|
|
||||||
no_new_privileges: true
|
|
||||||
user: 1000
|
|
||||||
seccomp_profile: default
|
|
||||||
network_policy: host # Requires host network for radio access
|
|
||||||
apparmor_profile: meshtastic
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- host: 4403
|
|
||||||
container: 4403
|
|
||||||
protocol: tcp # Meshtastic TCP API
|
|
||||||
|
|
||||||
devices:
|
|
||||||
- /dev/ttyUSB0 # LoRa radio device (if connected)
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: /var/lib/archipelago/meshtastic
|
|
||||||
target: /var/lib/meshtasticd
|
|
||||||
options: [rw]
|
|
||||||
|
|
||||||
files:
|
|
||||||
- path: /var/lib/archipelago/meshtastic/config.yaml
|
|
||||||
content: |
|
|
||||||
General:
|
|
||||||
MACAddress: AA:BB:CC:DD:EE:01
|
|
||||||
Webserver:
|
|
||||||
Port: 4403
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- MESHTASTIC_PORT=/dev/ttyUSB0
|
|
||||||
- MESHTASTIC_SERIAL=true
|
|
||||||
|
|
||||||
health_check:
|
|
||||||
type: cmd
|
|
||||||
endpoint: test -f /var/lib/meshtasticd/config.yaml
|
|
||||||
interval: 30s
|
|
||||||
timeout: 30s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
networking:
|
|
||||||
mesh_enabled: true
|
|
||||||
local_network_access: true
|
|
||||||
|
|
||||||
metadata:
|
|
||||||
icon: /assets/img/app-icons/meshcore.svg
|
|
||||||
category: networking
|
|
||||||
tier: recommended
|
|
||||||
repo: https://github.com/meshtastic/firmware
|
|
||||||
77
apps/netbird-dashboard/manifest.yml
Normal file
77
apps/netbird-dashboard/manifest.yml
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
app:
|
||||||
|
id: netbird-dashboard
|
||||||
|
name: NetBird Dashboard
|
||||||
|
version: "2.38.0"
|
||||||
|
description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy.
|
||||||
|
category: networking
|
||||||
|
|
||||||
|
# Hyphen name matches runtime references + the live container (adoption).
|
||||||
|
# Alias `netbird-dashboard` is the short hostname the proxy's nginx proxies to.
|
||||||
|
container_name: netbird-dashboard
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: docker.io/netbirdio/dashboard:v2.38.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: netbird-net
|
||||||
|
network_aliases: [netbird-dashboard]
|
||||||
|
# The dashboard SPA bakes its API/OIDC base URL from these at container
|
||||||
|
# start. They must point at the proxy's public HTTPS origin (8087) so the
|
||||||
|
# browser uses a secure context (window.crypto.subtle / OIDC PKCE, #15).
|
||||||
|
# {{HOST_IP}} is the node's primary host IP, resolved at apply time.
|
||||||
|
derived_env:
|
||||||
|
- key: NETBIRD_MGMT_API_ENDPOINT
|
||||||
|
template: "https://{{HOST_IP}}:8087"
|
||||||
|
- key: NETBIRD_MGMT_GRPC_API_ENDPOINT
|
||||||
|
template: "https://{{HOST_IP}}:8087"
|
||||||
|
- key: AUTH_AUTHORITY
|
||||||
|
template: "https://{{HOST_IP}}:8087/oauth2"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: netbird-server
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 256Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
# cap-drop=ALL is applied by the orchestrator. The dashboard image runs
|
||||||
|
# nginx (master as root, drops workers) binding :80 — needs the worker-drop
|
||||||
|
# caps + NET_BIND_SERVICE for the privileged port.
|
||||||
|
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
# Internal only — reached container-to-container by the proxy via netbird-net.
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
volumes: []
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- AUTH_AUDIENCE=netbird-dashboard
|
||||||
|
- AUTH_CLIENT_ID=netbird-dashboard
|
||||||
|
- AUTH_CLIENT_SECRET=
|
||||||
|
- USE_AUTH0=false
|
||||||
|
- AUTH_SUPPORTED_SCOPES=openid profile email groups
|
||||||
|
- AUTH_REDIRECT_URI=/nb-auth
|
||||||
|
- AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
||||||
|
- NETBIRD_TOKEN_SOURCE=idToken
|
||||||
|
- NGINX_SSL_PORT=443
|
||||||
|
- LETSENCRYPT_DOMAIN=none
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:80
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
author: NetBird
|
||||||
|
icon: /assets/img/app-icons/netbird.svg
|
||||||
|
website: https://netbird.io
|
||||||
|
repo: https://github.com/netbirdio/dashboard
|
||||||
|
license: BSD-3-Clause
|
||||||
|
tags:
|
||||||
|
- networking
|
||||||
|
- vpn
|
||||||
|
- dashboard
|
||||||
122
apps/netbird-server/manifest.yml
Normal file
122
apps/netbird-server/manifest.yml
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
app:
|
||||||
|
id: netbird-server
|
||||||
|
name: NetBird Server
|
||||||
|
version: "0.71.2"
|
||||||
|
description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN.
|
||||||
|
category: networking
|
||||||
|
|
||||||
|
# Hyphen name matches the runtime references (crash_recovery / dependencies /
|
||||||
|
# config startup order) + the live container, so on an existing node the
|
||||||
|
# orchestrator ADOPTS the running server rather than recreating it (data +
|
||||||
|
# the sqlite store under /var/lib/netbird preserved). Alias `netbird-server`
|
||||||
|
# is the short hostname the proxy's nginx proxies/grpc-passes to.
|
||||||
|
container_name: netbird-server
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: docker.io/netbirdio/netbird-server:0.71.2
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: netbird-net
|
||||||
|
network_aliases: [netbird-server]
|
||||||
|
# The relay authSecret and the sqlite store encryptionKey are base64 keys
|
||||||
|
# (the server base64-decodes them to recover raw bytes — hex would decode to
|
||||||
|
# the wrong value). Generated once and reused: ensure_generated_secrets
|
||||||
|
# no-ops when the file already exists, so a re-render of config.yaml on an
|
||||||
|
# adopted node keeps the same keys (regenerating would orphan the store).
|
||||||
|
generated_secrets:
|
||||||
|
- name: netbird-relay-auth-secret
|
||||||
|
kind: base64
|
||||||
|
- name: netbird-store-encryption-key
|
||||||
|
kind: base64
|
||||||
|
# Pass the rendered config explicitly, mirroring the legacy `--config` arg.
|
||||||
|
custom_args: ["--config", "/etc/netbird/config.yaml"]
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 1Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 1Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
# cap-drop=ALL is applied by the orchestrator. The server binds :80
|
||||||
|
# (management/signal/relay HTTP + gRPC) inside the container — a privileged
|
||||||
|
# port — so it needs NET_BIND_SERVICE. STUN is 3478/udp (unprivileged).
|
||||||
|
capabilities: [NET_BIND_SERVICE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 8086
|
||||||
|
container: 80
|
||||||
|
protocol: tcp # management API + embedded OIDC issuer (/oauth2)
|
||||||
|
- host: 3478
|
||||||
|
container: 3478
|
||||||
|
protocol: udp # STUN — must be UDP; tcp here breaks relay discovery
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/netbird/data
|
||||||
|
target: /var/lib/netbird
|
||||||
|
options: [rw]
|
||||||
|
# The rendered config.yaml, read-only. Re-rendered on every reconcile from
|
||||||
|
# host facts + the base64 secrets; idempotent (stable bytes → no restart).
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/netbird/config.yaml
|
||||||
|
target: /etc/netbird/config.yaml
|
||||||
|
options: [ro]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
# The server's config. {{HOST_IP}} is the node's primary host IP (the proxy's
|
||||||
|
# public origin is https on 8087 — the dashboard needs a secure context for
|
||||||
|
# OIDC PKCE, issue #15). {{secret:...}} are read 0600 from the secrets dir.
|
||||||
|
files:
|
||||||
|
- path: /var/lib/archipelago/netbird/config.yaml
|
||||||
|
overwrite: true
|
||||||
|
content: |
|
||||||
|
server:
|
||||||
|
listenAddress: ":80"
|
||||||
|
exposedAddress: "https://{{HOST_IP}}:8087"
|
||||||
|
stunPorts:
|
||||||
|
- 3478
|
||||||
|
metricsPort: 9090
|
||||||
|
healthcheckAddress: ":9000"
|
||||||
|
logLevel: "info"
|
||||||
|
logFile: "console"
|
||||||
|
authSecret: "{{secret:netbird-relay-auth-secret}}"
|
||||||
|
dataDir: "/var/lib/netbird"
|
||||||
|
auth:
|
||||||
|
issuer: "https://{{HOST_IP}}:8087/oauth2"
|
||||||
|
localAuthDisabled: false
|
||||||
|
signKeyRefreshEnabled: false
|
||||||
|
dashboardRedirectURIs:
|
||||||
|
- "https://{{HOST_IP}}:8087/nb-auth"
|
||||||
|
- "https://{{HOST_IP}}:8087/nb-silent-auth"
|
||||||
|
dashboardPostLogoutRedirectURIs:
|
||||||
|
- "https://{{HOST_IP}}:8087/"
|
||||||
|
cliRedirectURIs:
|
||||||
|
- "http://localhost:53000/"
|
||||||
|
store:
|
||||||
|
engine: "sqlite"
|
||||||
|
encryptionKey: "{{secret:netbird-store-encryption-key}}"
|
||||||
|
|
||||||
|
# TCP liveness on the management port. Binds at startup, stays green; an http
|
||||||
|
# check of /oauth2 would false-fail while the issuer warms up.
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:80
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
author: NetBird
|
||||||
|
icon: /assets/img/app-icons/netbird.svg
|
||||||
|
website: https://netbird.io
|
||||||
|
repo: https://github.com/netbirdio/netbird
|
||||||
|
license: BSD-3-Clause
|
||||||
|
tags:
|
||||||
|
- networking
|
||||||
|
- vpn
|
||||||
|
- wireguard
|
||||||
|
- mesh
|
||||||
182
apps/netbird/manifest.yml
Normal file
182
apps/netbird/manifest.yml
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
app:
|
||||||
|
id: netbird
|
||||||
|
name: NetBird
|
||||||
|
version: "2.38.0"
|
||||||
|
description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.
|
||||||
|
category: networking
|
||||||
|
|
||||||
|
# The user-facing launcher (app_id + container both "netbird", matching the
|
||||||
|
# runtime references + the live container so the orchestrator adopts it). This
|
||||||
|
# is the nginx that terminates TLS on 8087 and fans out to the dashboard +
|
||||||
|
# server by their short aliases on netbird-net.
|
||||||
|
container_name: netbird
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: docker.io/library/nginx:1.27-alpine
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: netbird-net
|
||||||
|
# Self-signed TLS cert materialised before create — the dashboard needs a
|
||||||
|
# secure context (window.crypto.subtle / OIDC PKCE, issue #15), so the proxy
|
||||||
|
# serves HTTPS. Idempotent: kept as-is when crt+key already exist (a user
|
||||||
|
# accepts it once). SAN defaults to the host IP + 127.0.0.1 + localhost.
|
||||||
|
generated_certs:
|
||||||
|
- crt: /var/lib/archipelago/netbird/tls.crt
|
||||||
|
key: /var/lib/archipelago/netbird/tls.key
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: netbird-server
|
||||||
|
- app_id: netbird-dashboard
|
||||||
|
- storage: 1Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 256Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
|
||||||
|
# workers) binds :443 — needs the worker-drop caps + NET_BIND_SERVICE.
|
||||||
|
capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
# 8087 publishes the TLS listener (container :443). HTTPS is required for the
|
||||||
|
# dashboard's secure context (issue #15).
|
||||||
|
- host: 8087
|
||||||
|
container: 443
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/netbird/nginx.conf
|
||||||
|
target: /etc/nginx/conf.d/default.conf
|
||||||
|
options: [ro]
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/netbird/tls.crt
|
||||||
|
target: /etc/nginx/tls.crt
|
||||||
|
options: [ro]
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/netbird/tls.key
|
||||||
|
target: /etc/nginx/tls.key
|
||||||
|
options: [ro]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
# The proxy config. {{NETWORK_GATEWAY}} is the netbird-net bridge gateway =
|
||||||
|
# Podman's aardvark DNS. nginx uses it as an explicit `resolver` with VARIABLE
|
||||||
|
# upstreams so it re-resolves container names per request — without it nginx
|
||||||
|
# pins a container IP at startup and 502s forever once that IP moves on a
|
||||||
|
# restart/reboot (issue #15, observed live on .198). Every #15 fix below
|
||||||
|
# (CORS $http_origin reflect, grpc pass, nb-auth/nb-silent-auth rewrite to
|
||||||
|
# index.html, /relay websocket) is preserved verbatim from the legacy config.
|
||||||
|
files:
|
||||||
|
- path: /var/lib/archipelago/netbird/nginx.conf
|
||||||
|
overwrite: true
|
||||||
|
content: |
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# netbird's dashboard needs a secure context (window.crypto.subtle for
|
||||||
|
# OIDC PKCE), so the proxy terminates TLS with a self-signed cert (#15).
|
||||||
|
ssl_certificate /etc/nginx/tls.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/tls.key;
|
||||||
|
|
||||||
|
# Rootless Podman can hand a container a new IP across restarts/reboots.
|
||||||
|
# nginx resolves a literal upstream name ONCE at startup and caches it,
|
||||||
|
# so after the IP moves every request 502s with "host unreachable"
|
||||||
|
# (issue #15, observed live on .198: nginx pinned to a dead
|
||||||
|
# netbird-dashboard IP). Fix: point `resolver` at the netbird-net
|
||||||
|
# gateway (Podman's aardvark DNS) and use VARIABLE upstreams, which
|
||||||
|
# forces nginx to re-resolve the container names at request time.
|
||||||
|
resolver {{NETWORK_GATEWAY}} valid=10s ipv6=off;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
location ~ ^/(relay|ws-proxy/) {
|
||||||
|
set $nb_server netbird-server;
|
||||||
|
proxy_pass http://$nb_server:80;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(api|oauth2)(/|$) {
|
||||||
|
# The dashboard is a SPA whose API/OIDC base URL is baked at build
|
||||||
|
# time to one host:port. A single box is reached via several
|
||||||
|
# addresses, so those fetches are cross-origin and the browser
|
||||||
|
# blocks them with no Access-Control-Allow-Origin (#15, live on
|
||||||
|
# .198). Reflect the caller's Origin and answer the CORS preflight.
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Credentials true always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||||
|
add_header Access-Control-Max-Age 86400 always;
|
||||||
|
add_header Content-Length 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Credentials true always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||||
|
set $nb_server netbird-server;
|
||||||
|
proxy_pass http://$nb_server:80;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {
|
||||||
|
set $nb_server netbird-server;
|
||||||
|
grpc_pass grpc://$nb_server:80;
|
||||||
|
grpc_read_timeout 1d;
|
||||||
|
grpc_send_timeout 1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
# OIDC callback routes are client-side SPA routes with NO prebuilt page
|
||||||
|
# in the dashboard bundle, so proxying them straight through 404s —
|
||||||
|
# which crashes the dashboard's auth init and shows "Unauthenticated"
|
||||||
|
# with dead buttons (#15, live on .198: /nb-auth + /nb-silent-auth
|
||||||
|
# returned 404). Serve index.html at these paths (URL unchanged) so
|
||||||
|
# react-oidc boots and completes the login / silent-SSO.
|
||||||
|
location ~ ^/(nb-auth|nb-silent-auth) {
|
||||||
|
set $nb_dashboard netbird-dashboard;
|
||||||
|
rewrite ^.*$ /index.html break;
|
||||||
|
proxy_pass http://$nb_dashboard:80;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
set $nb_dashboard netbird-dashboard;
|
||||||
|
proxy_pass http://$nb_dashboard:80;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:443
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Dashboard
|
||||||
|
description: Manage your self-hosted NetBird mesh VPN
|
||||||
|
type: ui
|
||||||
|
port: 8087
|
||||||
|
protocol: https
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
author: NetBird
|
||||||
|
icon: /assets/img/app-icons/netbird.svg
|
||||||
|
website: https://netbird.io
|
||||||
|
repo: https://github.com/netbirdio/netbird
|
||||||
|
license: BSD-3-Clause
|
||||||
|
tags:
|
||||||
|
- networking
|
||||||
|
- vpn
|
||||||
|
- wireguard
|
||||||
|
- mesh
|
||||||
@ -146,7 +146,9 @@ impl ApiHandler {
|
|||||||
Ok(content_server::ServeResult::Forbidden) => Ok(build_response(
|
Ok(content_server::ServeResult::Forbidden) => Ok(build_response(
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"application/json",
|
"application/json",
|
||||||
hyper::Body::from(r#"{"error":"Access denied — federation peer required"}"#),
|
hyper::Body::from(
|
||||||
|
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||||
|
),
|
||||||
)),
|
)),
|
||||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
@ -222,8 +224,12 @@ impl ApiHandler {
|
|||||||
hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#),
|
hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#),
|
||||||
)),
|
)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
// Surface the FULL error chain ({:#}) — the generic top-level
|
||||||
|
// message hid the real cause (e.g. the LND REST connection
|
||||||
|
// failing), which made this 503 undiagnosable.
|
||||||
|
tracing::warn!("content invoice creation failed: {e:#}");
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"error": format!("Could not create invoice: {e}")
|
"error": format!("Could not create invoice: {e:#}")
|
||||||
});
|
});
|
||||||
Ok(build_response(
|
Ok(build_response(
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
|||||||
@ -171,6 +171,12 @@ impl RpcHandler {
|
|||||||
// than the WebSocket-delivered package_data, which caused apps to flicker
|
// than the WebSocket-delivered package_data, which caused apps to flicker
|
||||||
// between "installed" and "not-installed" in the UI.
|
// between "installed" and "not-installed" in the UI.
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
// Apps the user explicitly stopped must read as "stopped" even though a
|
||||||
|
// UI companion (electrs-ui, bitcoin-ui, …) keeps serving the launch port:
|
||||||
|
// launch_port_reachable() below would otherwise upgrade an exited backend
|
||||||
|
// back to "running". The reconcile guard keeps these backends down, so the
|
||||||
|
// marker is authoritative here.
|
||||||
|
let user_stopped = crate::crash_recovery::load_user_stopped(&self.config.data_dir).await;
|
||||||
if data.server_info.status_info.containers_scanned && !data.package_data.is_empty() {
|
if data.server_info.status_info.containers_scanned && !data.package_data.is_empty() {
|
||||||
let mut containers = Vec::with_capacity(data.package_data.len());
|
let mut containers = Vec::with_capacity(data.package_data.len());
|
||||||
for (id, pkg) in &data.package_data {
|
for (id, pkg) in &data.package_data {
|
||||||
@ -202,7 +208,11 @@ impl RpcHandler {
|
|||||||
// Scanner backoff preserves cached package_data. Refresh stable
|
// Scanner backoff preserves cached package_data. Refresh stable
|
||||||
// states so callers do not see stale `running`/`exited` after
|
// states so callers do not see stale `running`/`exited` after
|
||||||
// health-monitor recovery or Quadlet --rm container removal.
|
// health-monitor recovery or Quadlet --rm container removal.
|
||||||
if state == "running" && requires_launch_port_for_health(id) {
|
if user_stopped.contains(id) {
|
||||||
|
// User stopped it → authoritative "stopped". Do NOT let a
|
||||||
|
// still-running UI companion's launch port mark it running.
|
||||||
|
state = "stopped".to_string();
|
||||||
|
} else if state == "running" && requires_launch_port_for_health(id) {
|
||||||
if !self.cached_reachable_health(id).await?.is_some() {
|
if !self.cached_reachable_health(id).await?.is_some() {
|
||||||
state = live_state_for_app(id)
|
state = live_state_for_app(id)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -19,6 +19,29 @@ fn is_valid_v3_onion(addr: &str) -> bool {
|
|||||||
|
|
||||||
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
||||||
|
|
||||||
|
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
||||||
|
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
||||||
|
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||||
|
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
||||||
|
/// the seller already claimed the token (then the value is genuinely gone).
|
||||||
|
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
||||||
|
let res = match backend {
|
||||||
|
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||||
|
.await
|
||||||
|
.map(|(sats, _fed)| sats),
|
||||||
|
_ => ecash::receive_token(data_dir, token).await,
|
||||||
|
};
|
||||||
|
match res {
|
||||||
|
Ok(sats) => tracing::info!(
|
||||||
|
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||||
|
),
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||||
|
claimed it): {e:#}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// List content I'm sharing.
|
/// List content I'm sharing.
|
||||||
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
||||||
@ -260,6 +283,20 @@ impl RpcHandler {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A 403 carries an actionable reason in its JSON body (e.g. "shared with
|
||||||
|
// the host's federation peers only — federate first"). Surface that to
|
||||||
|
// the user instead of a bare "Peer returned: 403 Forbidden".
|
||||||
|
if response.status() == reqwest::StatusCode::FORBIDDEN {
|
||||||
|
let status = response.status();
|
||||||
|
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||||
|
let msg = body
|
||||||
|
.get("error")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| format!("Peer returned: {status}"));
|
||||||
|
return Err(anyhow::anyhow!(msg));
|
||||||
|
}
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
|
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
|
||||||
}
|
}
|
||||||
@ -369,10 +406,64 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mint ecash payment token
|
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
||||||
let token_str = ecash::send_token(&self.config.data_dir, price_sats)
|
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
|
||||||
.await
|
// verify_payment_token accepts either, so a node whose balance lives in
|
||||||
.context("Failed to create ecash payment token — check wallet balance")?;
|
// one system can still pay (#3).
|
||||||
|
let method = params.get("method").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
let mint_cashu = || ecash::send_token(&self.config.data_dir, price_sats);
|
||||||
|
let mint_fedimint =
|
||||||
|
|| crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, price_sats);
|
||||||
|
|
||||||
|
let (token_str, used_backend) = match method {
|
||||||
|
Some("cashu") => match mint_cashu().await {
|
||||||
|
Ok(t) => (t, "cashu"),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("paid download: cashu mint failed for {price_sats} sats: {e:#}");
|
||||||
|
return Ok(serde_json::json!({ "error": format!(
|
||||||
|
"Couldn't pay {price_sats} sats from your Cashu wallet: {e}. \
|
||||||
|
Fund it, or choose Fedimint."
|
||||||
|
) }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some("fedimint") => match mint_fedimint().await {
|
||||||
|
Ok((notes, fed)) => {
|
||||||
|
tracing::info!(
|
||||||
|
"paid download: spending {price_sats} sats Fedimint notes from {fed}"
|
||||||
|
);
|
||||||
|
(notes, "fedimint")
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"paid download: fedimint spend failed for {price_sats} sats: {e:#}"
|
||||||
|
);
|
||||||
|
return Ok(serde_json::json!({ "error": format!(
|
||||||
|
"Couldn't pay {price_sats} sats from your Fedimint wallet: {e}. \
|
||||||
|
Fund it, or choose Cashu."
|
||||||
|
) }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => match mint_cashu().await {
|
||||||
|
Ok(t) => (t, "cashu"),
|
||||||
|
Err(cashu_err) => match mint_fedimint().await {
|
||||||
|
Ok((notes, _fed)) => (notes, "fedimint"),
|
||||||
|
Err(fedi_err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"paid download: no ecash backend could pay {price_sats} sats \
|
||||||
|
(cashu: {cashu_err:#}; fedimint: {fedi_err:#})"
|
||||||
|
);
|
||||||
|
return Ok(serde_json::json!({ "error": format!(
|
||||||
|
"Couldn't pay {price_sats} sats from your ecash wallet \
|
||||||
|
(Cashu or Fedimint). Fund either wallet and try again."
|
||||||
|
) }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
"paid download: paying {price_sats} sats to {onion} via {used_backend} ecash"
|
||||||
|
);
|
||||||
|
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
@ -389,7 +480,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)
|
||||||
.header("X-Payment-Token", token_str)
|
.header("X-Payment-Token", token_str.clone())
|
||||||
.timeout(std::time::Duration::from_secs(900))
|
.timeout(std::time::Duration::from_secs(900))
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
@ -397,8 +488,11 @@ impl RpcHandler {
|
|||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||||
|
// The token was already minted/spent — reclaim it so the buyer
|
||||||
|
// doesn't lose the value when the seller was simply unreachable.
|
||||||
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -412,30 +506,92 @@ impl RpcHandler {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||||
// Payment was rejected — token is spent but content not received
|
// Payment was rejected by the seller. Surface the most likely cause
|
||||||
|
// per backend — for ecash both sides must share a redemption network
|
||||||
|
// (a Cashu mint, or a Fedimint federation).
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!(
|
||||||
|
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
||||||
|
);
|
||||||
|
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||||
|
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||||
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
|
let hint = match used_backend {
|
||||||
|
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
||||||
|
_ => "the seller doesn't accept your Cashu mint",
|
||||||
|
};
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": "Payment rejected by peer — the token may have been insufficient or invalid."
|
"error": format!(
|
||||||
|
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
||||||
|
your wallet. Try the other ecash type, or use a shared mint/federation."
|
||||||
|
)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||||
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!("Peer returned an error ({}).", response.status())
|
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture the content type BEFORE consuming the body so the local cache
|
||||||
|
// can render the right viewer (image vs video) later.
|
||||||
|
let mime_type = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
|
|
||||||
let bytes = response
|
let bytes = response
|
||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.context("Failed to read response body")?;
|
.context("Failed to read response body")?;
|
||||||
|
|
||||||
|
// Persist the purchase so it "stays unlocked" for this buyer: cache the
|
||||||
|
// bytes + metadata keyed by (onion, content_id). The gallery then renders
|
||||||
|
// it unblurred and views it in-app from this cache — no re-payment and no
|
||||||
|
// reliance on a browser download (which silently fails on the mobile
|
||||||
|
// companion, the original "paid but never unlocked" report). Best-effort:
|
||||||
|
// a cache-write failure must not fail an already-paid download.
|
||||||
|
let filename = params
|
||||||
|
.get("filename")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(content_id)
|
||||||
|
.to_string();
|
||||||
|
let purchased_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
if let Err(e) = crate::content_owned::record_purchase(
|
||||||
|
&self.config.data_dir,
|
||||||
|
onion,
|
||||||
|
content_id,
|
||||||
|
&filename,
|
||||||
|
&mime_type,
|
||||||
|
&bytes,
|
||||||
|
price_sats,
|
||||||
|
used_backend,
|
||||||
|
&purchased_at,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||||
|
}
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
|
||||||
|
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"data": encoded,
|
"data": encoded,
|
||||||
"size": bytes.len(),
|
"size": bytes.len(),
|
||||||
"paid_sats": price_sats,
|
"paid_sats": price_sats,
|
||||||
|
"ecash_backend": used_backend,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"owned": true,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -463,12 +619,16 @@ impl RpcHandler {
|
|||||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
|
|
||||||
|
// Minting a bolt11 is a tiny request/response — keep it snappy. Cap the
|
||||||
|
// FIPS attempt hard so a cold overlay can't burn the whole budget, and
|
||||||
|
// give Tor a short-but-real window (onion circuits need a few seconds).
|
||||||
let path = format!("/content/{}/invoice", content_id);
|
let path = format!("/content/{}/invoice", content_id);
|
||||||
let (response, _transport) =
|
let (response, _transport) =
|
||||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||||
.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(60))
|
.timeout(std::time::Duration::from_secs(25))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@ -524,11 +684,15 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
|
// Settlement poll — runs repeatedly, so each call must be quick. Fast-fail
|
||||||
|
// FIPS and keep a short Tor window; an unreachable peer just reads as
|
||||||
|
// "not yet paid" and the UI polls again.
|
||||||
let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash);
|
let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash);
|
||||||
let (response, _transport) =
|
let (response, _transport) =
|
||||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
match 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(15))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@ -652,12 +816,15 @@ impl RpcHandler {
|
|||||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
|
|
||||||
|
// Issuing an address is a tiny request/response — fast-fail FIPS, short
|
||||||
|
// Tor window (same budget shape as the invoice path, #6).
|
||||||
let path = format!("/content/{}/onchain", content_id);
|
let path = format!("/content/{}/onchain", content_id);
|
||||||
let (response, _transport) =
|
let (response, _transport) =
|
||||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||||
.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(60))
|
.timeout(std::time::Duration::from_secs(25))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@ -715,7 +882,8 @@ impl RpcHandler {
|
|||||||
let (response, _transport) =
|
let (response, _transport) =
|
||||||
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
match 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(15))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@ -895,4 +1063,43 @@ impl RpcHandler {
|
|||||||
"preview_mode": is_preview,
|
"preview_mode": is_preview,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `content.owned-list` — every paid item this node has purchased, so the
|
||||||
|
/// gallery can render owned items unblurred/viewable without re-payment.
|
||||||
|
pub(super) async fn handle_content_owned_list(&self) -> Result<serde_json::Value> {
|
||||||
|
let items = crate::content_owned::list_owned(&self.config.data_dir).await;
|
||||||
|
Ok(serde_json::json!({ "items": items }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `content.owned-get` — return a purchased item's bytes (base64) from the
|
||||||
|
/// local cache for in-app viewing/saving. No network, no re-payment.
|
||||||
|
pub(super) async fn handle_content_owned_get(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let onion = params
|
||||||
|
.get("onion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||||
|
let content_id = params
|
||||||
|
.get("content_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||||
|
|
||||||
|
match crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await {
|
||||||
|
Some((mime_type, bytes)) => {
|
||||||
|
use base64::Engine;
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"data": encoded,
|
||||||
|
"size": bytes.len(),
|
||||||
|
"mime_type": mime_type,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Ok(serde_json::json!({
|
||||||
|
"error": "You don't own this item yet, or its cached copy is missing."
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,6 +57,8 @@ impl RpcHandler {
|
|||||||
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
|
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
|
||||||
"package.update" => self.clone().spawn_package_update(params).await,
|
"package.update" => self.clone().spawn_package_update(params).await,
|
||||||
"package.check-updates" => self.handle_package_check_updates(params).await,
|
"package.check-updates" => self.handle_package_check_updates(params).await,
|
||||||
|
"package.versions" => self.handle_package_versions(params).await,
|
||||||
|
"package.set-config" => self.clone().handle_package_set_config(params).await,
|
||||||
"package.credentials" => self.handle_package_credentials(params).await,
|
"package.credentials" => self.handle_package_credentials(params).await,
|
||||||
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
||||||
|
|
||||||
@ -276,6 +278,8 @@ impl RpcHandler {
|
|||||||
"content.browse-peer" => self.handle_content_browse_peer(params).await,
|
"content.browse-peer" => self.handle_content_browse_peer(params).await,
|
||||||
"content.download-peer" => self.handle_content_download_peer(params).await,
|
"content.download-peer" => self.handle_content_download_peer(params).await,
|
||||||
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
||||||
|
"content.owned-list" => self.handle_content_owned_list().await,
|
||||||
|
"content.owned-get" => self.handle_content_owned_get(params).await,
|
||||||
"content.request-invoice" => self.handle_content_request_invoice(params).await,
|
"content.request-invoice" => self.handle_content_request_invoice(params).await,
|
||||||
"content.invoice-status" => self.handle_content_invoice_status(params).await,
|
"content.invoice-status" => self.handle_content_invoice_status(params).await,
|
||||||
"content.download-peer-invoice" => {
|
"content.download-peer-invoice" => {
|
||||||
@ -362,6 +366,7 @@ impl RpcHandler {
|
|||||||
"mesh.send" => self.handle_mesh_send(params).await,
|
"mesh.send" => self.handle_mesh_send(params).await,
|
||||||
"mesh.send-channel" => self.handle_mesh_send_channel(params).await,
|
"mesh.send-channel" => self.handle_mesh_send_channel(params).await,
|
||||||
"mesh.broadcast" => self.handle_mesh_broadcast().await,
|
"mesh.broadcast" => self.handle_mesh_broadcast().await,
|
||||||
|
"mesh.reboot-radio" => self.handle_mesh_reboot_radio(params).await,
|
||||||
"mesh.configure" => self.handle_mesh_configure(params).await,
|
"mesh.configure" => self.handle_mesh_configure(params).await,
|
||||||
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,
|
"mesh.send-invoice" => self.handle_mesh_send_invoice(params).await,
|
||||||
"mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await,
|
"mesh.send-coordinate" => self.handle_mesh_send_coordinate(params).await,
|
||||||
|
|||||||
@ -156,6 +156,35 @@ impl RpcHandler {
|
|||||||
/// Shared helper used by both the `lnd.createinvoice` RPC and the seller-side
|
/// Shared helper used by both the `lnd.createinvoice` RPC and the seller-side
|
||||||
/// peer-file invoice flow (#46). LND returns `r_hash` as base64; we re-encode
|
/// peer-file invoice flow (#46). LND returns `r_hash` as base64; we re-encode
|
||||||
/// it as hex so it can be used as a stable lookup key and passed in URLs.
|
/// it as hex so it can be used as a stable lookup key and passed in URLs.
|
||||||
|
/// Whether LND reports it's synced to its Bitcoin chain backend. Used to
|
||||||
|
/// fail invoice minting FAST with a clear reason while the node's Bitcoin
|
||||||
|
/// backend is still in initial block download — otherwise the `/v1/invoices`
|
||||||
|
/// POST hangs for the full client timeout (×3 retries ≈ 45s) and surfaces as
|
||||||
|
/// an opaque failure. `getinfo` answers in ~2s even mid-IBD. Returns
|
||||||
|
/// `Some(false)` only when LND is reachable AND explicitly not synced;
|
||||||
|
/// `None` when we couldn't tell (let the mint attempt proceed and report its
|
||||||
|
/// own error rather than guess "syncing").
|
||||||
|
pub(crate) async fn lnd_chain_synced(&self) -> Option<bool> {
|
||||||
|
let (client, macaroon_hex) = self.lnd_client().await.ok()?;
|
||||||
|
let resp = client
|
||||||
|
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
let body: serde_json::Value = resp.json().await.ok()?;
|
||||||
|
body.get("synced_to_chain").and_then(|v| v.as_bool())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error returned when the node can't mint a Lightning invoice because its
|
||||||
|
/// Bitcoin backend is still syncing. Kept as one string so every invoice
|
||||||
|
/// entry point surfaces the same clear, user-facing reason.
|
||||||
|
fn syncing_invoice_err() -> anyhow::Error {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Your Bitcoin node is still syncing — Lightning invoices are unavailable until it finishes. Try again once the node is fully synced."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn create_invoice(
|
pub(crate) async fn create_invoice(
|
||||||
&self,
|
&self,
|
||||||
amount_sats: i64,
|
amount_sats: i64,
|
||||||
@ -173,13 +202,55 @@ impl RpcHandler {
|
|||||||
"value": amount_sats.to_string(),
|
"value": amount_sats.to_string(),
|
||||||
"memo": memo,
|
"memo": memo,
|
||||||
});
|
});
|
||||||
let resp = client
|
// LND's REST endpoint can briefly drop/reset connections under load
|
||||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
// (swap pressure, just-restarted, TLS handshake races), which used to
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
// hard-fail the buy-file invoice with an opaque 503. Retry on a
|
||||||
.json(&invoice_body)
|
// CONNECTION error with short backoff so a transient blip doesn't
|
||||||
.send()
|
// surface as a payment failure. A *timeout* is NOT retried: it means LND
|
||||||
.await
|
// accepted the connection but isn't answering the mint (e.g. a degraded
|
||||||
.context("Failed to create invoice")?;
|
// node), and retrying just multiplies the wait (3×15s ≈ 45s) — fail
|
||||||
|
// after the first hang and let the caller surface the real reason.
|
||||||
|
let mut last_err: Option<anyhow::Error> = None;
|
||||||
|
let mut resp = None;
|
||||||
|
for attempt in 0..3u32 {
|
||||||
|
match client
|
||||||
|
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||||
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
|
.json(&invoice_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => {
|
||||||
|
resp = Some(r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let timed_out = e.is_timeout();
|
||||||
|
last_err = Some(anyhow::anyhow!(
|
||||||
|
"LND REST send failed (attempt {}): {e}",
|
||||||
|
attempt + 1
|
||||||
|
));
|
||||||
|
if timed_out {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let resp = match resp {
|
||||||
|
Some(r) => r,
|
||||||
|
None => {
|
||||||
|
// If LND is reachable but explicitly not synced to chain, say so —
|
||||||
|
// it's the most common reason a just-restored/syncing node can't
|
||||||
|
// mint. Otherwise surface the underlying transport error.
|
||||||
|
if self.lnd_chain_synced().await == Some(false) {
|
||||||
|
return Err(Self::syncing_invoice_err());
|
||||||
|
}
|
||||||
|
return Err(last_err.unwrap_or_else(|| {
|
||||||
|
anyhow::anyhow!("Failed to reach LND REST to create invoice")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp
|
let body: serde_json::Value = resp
|
||||||
@ -356,13 +427,23 @@ impl RpcHandler {
|
|||||||
"memo": memo,
|
"memo": memo,
|
||||||
});
|
});
|
||||||
|
|
||||||
let resp = client
|
let resp = match client
|
||||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
.json(&invoice_body)
|
.json(&invoice_body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("Failed to create invoice")?;
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
// A hung/failed mint while LND is explicitly not synced to chain
|
||||||
|
// gets a clear, user-facing reason instead of an opaque error.
|
||||||
|
if self.lnd_chain_synced().await == Some(false) {
|
||||||
|
return Err(Self::syncing_invoice_err());
|
||||||
|
}
|
||||||
|
return Err(anyhow::anyhow!(e).context("Failed to create invoice"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp
|
let body: serde_json::Value = resp
|
||||||
|
|||||||
@ -14,12 +14,15 @@ impl RpcHandler {
|
|||||||
pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
|
pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let cfg = {
|
let (cfg, denied_askers) = {
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
let svc = service
|
let svc = service
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
svc.assistant_config().await
|
(
|
||||||
|
svc.assistant_config().await,
|
||||||
|
svc.assistant_denied_askers().await,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let (ollama_detected, models) = detect_ollama().await;
|
let (ollama_detected, models) = detect_ollama().await;
|
||||||
@ -32,10 +35,12 @@ impl RpcHandler {
|
|||||||
"model": cfg.model,
|
"model": cfg.model,
|
||||||
"trusted_only": cfg.trusted_only,
|
"trusted_only": cfg.trusted_only,
|
||||||
"backend": cfg.backend,
|
"backend": cfg.backend,
|
||||||
|
"allowed_contacts": cfg.allowed_contacts,
|
||||||
"default_model": DEFAULT_MODEL,
|
"default_model": DEFAULT_MODEL,
|
||||||
"ollama_detected": ollama_detected,
|
"ollama_detected": ollama_detected,
|
||||||
"claude_available": claude_available,
|
"claude_available": claude_available,
|
||||||
"models": models,
|
"models": models,
|
||||||
|
"denied_askers": denied_askers,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,8 +69,18 @@ impl RpcHandler {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
// allowed_contacts: present + array => replace the allowlist (pubkey hex
|
||||||
|
// strings); absent => leave unchanged.
|
||||||
|
let allowed_contacts = params
|
||||||
|
.get("allowed_contacts")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|e| e.as_str().map(|s| s.to_string()))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
});
|
||||||
|
|
||||||
svc.configure_assistant(enabled, model, trusted_only, backend)
|
svc.configure_assistant(enabled, model, trusted_only, backend, allowed_contacts)
|
||||||
.await?;
|
.await?;
|
||||||
let cfg = svc.assistant_config().await;
|
let cfg = svc.assistant_config().await;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
@ -73,6 +88,7 @@ impl RpcHandler {
|
|||||||
"model": cfg.model,
|
"model": cfg.model,
|
||||||
"trusted_only": cfg.trusted_only,
|
"trusted_only": cfg.trusted_only,
|
||||||
"backend": cfg.backend,
|
"backend": cfg.backend,
|
||||||
|
"allowed_contacts": cfg.allowed_contacts,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -86,6 +86,29 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "broadcast": true }))
|
Ok(serde_json::json!({ "broadcast": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// mesh.reboot-radio — Reboot the locally-connected radio firmware to
|
||||||
|
/// recover a wedged / RX-deaf radio. Optional `seconds` delay (default 2).
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_reboot_radio(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let seconds = params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.get("seconds"))
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(2);
|
||||||
|
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||||
|
|
||||||
|
svc.reboot_radio(seconds).await?;
|
||||||
|
info!(seconds, "Mesh radio reboot requested via RPC");
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "reboot": true, "seconds": seconds }))
|
||||||
|
}
|
||||||
|
|
||||||
/// mesh.configure — Enable/disable mesh and set device path.
|
/// mesh.configure — Enable/disable mesh and set device path.
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_configure(
|
pub(in crate::api::rpc) async fn handle_mesh_configure(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@ -95,12 +95,17 @@ impl RpcHandler {
|
|||||||
if let Some(svc) = service.as_ref() {
|
if let Some(svc) = service.as_ref() {
|
||||||
let peers = svc.peers().await;
|
let peers = svc.peers().await;
|
||||||
let messages = svc.messages(None).await;
|
let messages = svc.messages(None).await;
|
||||||
// Per-peer last message.
|
// Collapse radio/federation twins into one conversation per identity
|
||||||
for peer in &peers {
|
// so a node reachable both ways shows once, with its messages unioned
|
||||||
|
// across both twin contact_ids (#12).
|
||||||
|
let groups = mesh::group_peer_twins(&peers);
|
||||||
|
for group in &groups {
|
||||||
|
let peer = &group.canonical;
|
||||||
|
// Newest message across ALL twin contact_ids in this group.
|
||||||
let last = messages
|
let last = messages
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.find(|m| m.peer_contact_id == peer.contact_id);
|
.find(|m| group.contact_ids.contains(&m.peer_contact_id));
|
||||||
let is_federation = peer.contact_id & 0x8000_0000 != 0;
|
let is_federation = peer.contact_id & 0x8000_0000 != 0;
|
||||||
conversations.push(serde_json::json!({
|
conversations.push(serde_json::json!({
|
||||||
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
|
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
|
||||||
@ -163,8 +168,16 @@ impl RpcHandler {
|
|||||||
let filtered: Vec<_> = match kind {
|
let filtered: Vec<_> = match kind {
|
||||||
"mesh" | "federation" => {
|
"mesh" | "federation" => {
|
||||||
let contact_id: u32 = rest.parse().unwrap_or(0);
|
let contact_id: u32 = rest.parse().unwrap_or(0);
|
||||||
|
// Resolve this id's twin group and union messages across all of
|
||||||
|
// its contact_ids, so opening either twin shows the full thread
|
||||||
|
// (federation-injected + radio messages) (#12).
|
||||||
|
let ids: Vec<u32> = mesh::group_peer_twins(&svc.peers().await)
|
||||||
|
.into_iter()
|
||||||
|
.find(|g| g.contact_ids.contains(&contact_id))
|
||||||
|
.map(|g| g.contact_ids)
|
||||||
|
.unwrap_or_else(|| vec![contact_id]);
|
||||||
all.into_iter()
|
all.into_iter()
|
||||||
.filter(|m| m.peer_contact_id == contact_id)
|
.filter(|m| ids.contains(&m.peer_contact_id))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
"channel" => {
|
"channel" => {
|
||||||
@ -258,43 +271,45 @@ impl RpcHandler {
|
|||||||
if let Some(svc) = service.as_ref() {
|
if let Some(svc) = service.as_ref() {
|
||||||
let state = svc.state();
|
let state = svc.state();
|
||||||
|
|
||||||
// Snapshot the firmware pubkeys we currently know about, then
|
// NOTE: `clear-all` intentionally does NOT build a radio-contact
|
||||||
// add them to the radio-contact blocklist. MeshCore's on-device
|
// blocklist. Permanently ignoring firmware contacts meant a cleared
|
||||||
// contact table is persistent and reads back stale rows on the
|
// peer could never return even when it re-advertised (it also broke
|
||||||
// next refresh_contacts, so without this step `clear-all` only
|
// re-pairing a phone after a clear). Real per-contact blocking will
|
||||||
// wipes the app view for a few seconds before the old entries
|
// be a separate, explicit feature. Here we just wipe the app-side
|
||||||
// reappear. The blocklist is also saved to disk so the filter
|
// view and ALSO clear any blocklist left over from older builds, so
|
||||||
// survives a restart.
|
// previously-hidden contacts can re-appear when next heard. The
|
||||||
let firmware_pubkeys: Vec<String> = state
|
// firmware's own contact table is the source of truth on refresh.
|
||||||
|
{
|
||||||
|
let mut set = state.radio_contact_blocklist.write().await;
|
||||||
|
set.clear();
|
||||||
|
}
|
||||||
|
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await;
|
||||||
|
|
||||||
|
// Actually DELETE each radio contact from the firmware table (via
|
||||||
|
// CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next
|
||||||
|
// refresh. They come back only when they re-advertise (reachable).
|
||||||
|
// Federation-synthetic peers (high contact_id bit) aren't firmware
|
||||||
|
// contacts, so skip those.
|
||||||
|
let firmware_pubkeys: Vec<[u8; 32]> = state
|
||||||
.peers
|
.peers
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.values()
|
.values()
|
||||||
.filter_map(|p| {
|
.filter(|p| p.contact_id & 0x8000_0000 == 0)
|
||||||
// Federation-synthetic peers have their contact_id in the
|
.filter_map(|p| p.pubkey_hex.as_deref())
|
||||||
// high half of u32 and carry the archipelago key — those
|
.filter_map(|h| hex::decode(h).ok())
|
||||||
// aren't firmware contacts and must not go on the list.
|
.filter(|b| b.len() == 32)
|
||||||
if p.contact_id & 0x8000_0000 != 0 {
|
.map(|b| {
|
||||||
None
|
let mut k = [0u8; 32];
|
||||||
} else {
|
k.copy_from_slice(&b);
|
||||||
p.pubkey_hex.clone()
|
k
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
{
|
for pk in firmware_pubkeys {
|
||||||
let mut set = state.radio_contact_blocklist.write().await;
|
let _ = state
|
||||||
for pk in &firmware_pubkeys {
|
.send_cmd(crate::mesh::listener::MeshCommand::RemoveContact { pubkey: pk })
|
||||||
set.insert(pk.clone());
|
.await;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let persisted: Vec<String> = state
|
|
||||||
.radio_contact_blocklist
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &persisted).await;
|
|
||||||
|
|
||||||
state.peers.write().await.clear();
|
state.peers.write().await.clear();
|
||||||
state.messages.write().await.clear();
|
state.messages.write().await.clear();
|
||||||
|
|||||||
@ -1133,9 +1133,13 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
let state = svc.shared_state();
|
let state = svc.shared_state();
|
||||||
let contacts = state.contacts.read().await;
|
let contacts = state.contacts.read().await;
|
||||||
let peers = state.peers.read().await;
|
let peer_vec: Vec<_> = state.peers.read().await.values().cloned().collect();
|
||||||
|
// Collapse radio/federation twins so a node reachable both ways shows as
|
||||||
|
// one contact instead of two (#12).
|
||||||
|
let groups = crate::mesh::group_peer_twins(&peer_vec);
|
||||||
let mut out: Vec<serde_json::Value> = Vec::new();
|
let mut out: Vec<serde_json::Value> = Vec::new();
|
||||||
for peer in peers.values() {
|
for group in &groups {
|
||||||
|
let peer = &group.canonical;
|
||||||
if let Some(pk) = peer.pubkey_hex.as_ref() {
|
if let Some(pk) = peer.pubkey_hex.as_ref() {
|
||||||
let entry = contacts.get(pk).cloned().unwrap_or_default();
|
let entry = contacts.get(pk).cloned().unwrap_or_default();
|
||||||
out.push(serde_json::json!({
|
out.push(serde_json::json!({
|
||||||
|
|||||||
@ -349,13 +349,37 @@ fn http_probe_cmd(url: &'static str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bitcoin UTXO cache (`-dbcache`) in MB, sized to host RAM.
|
||||||
|
///
|
||||||
|
/// A fixed large dbcache on a small box pushes bitcoind + the ~20 app
|
||||||
|
/// containers past physical RAM and triggers system-wide swap thrash: the
|
||||||
|
/// disk saturates, bitcoind can't answer its own RPC, and the dashboard
|
||||||
|
/// backend's sqlite reads stall — surfacing as /rpc/v1 502s and a blank
|
||||||
|
/// Bitcoin UI. Budget ~1/16 of RAM for the cache (floor 300 MB — bitcoind's
|
||||||
|
/// own default is 450 — cap 4096 MB), mirroring scripts/container-specs.sh.
|
||||||
|
pub(super) fn bitcoin_dbcache_mb() -> u64 {
|
||||||
|
let total_mb = std::fs::read_to_string("/proc/meminfo")
|
||||||
|
.ok()
|
||||||
|
.and_then(|c| {
|
||||||
|
c.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("MemTotal:"))
|
||||||
|
.and_then(|v| v.split_whitespace().next())
|
||||||
|
.and_then(|kb| kb.parse::<u64>().ok())
|
||||||
|
})
|
||||||
|
.map(|kb| kb / 1024)
|
||||||
|
.unwrap_or(16000); // assume a comfortable host if /proc/meminfo is unreadable
|
||||||
|
(total_mb / 16).clamp(300, 4096)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get per-app memory limit.
|
/// Get per-app memory limit.
|
||||||
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||||
match app_id {
|
match app_id {
|
||||||
// Heavy apps. Bitcoin: dbcache uses ~4GB; the daemon also needs
|
// Heavy apps. Bitcoin: dbcache is now host-RAM-aware (see
|
||||||
// headroom for mempool + connection buffers + script-verifier
|
// bitcoin_dbcache_mb), so the daemon's footprint scales with the box.
|
||||||
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
|
// This cgroup cap is an upper bound for mempool + connection buffers +
|
||||||
// floor; ideally this would be host-RAM aware (next pass).
|
// script-verifier memory + I/O; a tight cap (4g) previously caused
|
||||||
|
// OOM-cascades during IBD, so keep 8g as a generous ceiling rather
|
||||||
|
// than a tight limit — swap thrash is prevented at the dbcache layer.
|
||||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
|
||||||
// ElectrumX indexing spikes above its cache size due Python,
|
// ElectrumX indexing spikes above its cache size due Python,
|
||||||
// RocksDB, socket buffers, and reorg/history work. Keep cache
|
// RocksDB, socket buffers, and reorg/history work. Keep cache
|
||||||
@ -674,9 +698,10 @@ pub(super) async fn get_app_config(
|
|||||||
// RPC is reachable from the bitcoin-ui companion container.
|
// RPC is reachable from the bitcoin-ui companion container.
|
||||||
//
|
//
|
||||||
// Sync-speed flags:
|
// Sync-speed flags:
|
||||||
// -dbcache=4096 — UTXO set cache; 4GB is the sweet spot before
|
// -dbcache — UTXO set cache, sized to host RAM via
|
||||||
// diminishing returns. Container has --memory=8g now so
|
// bitcoin_dbcache_mb() (see there). A fixed 4GB cache swap-
|
||||||
// there's headroom for mempool + connections.
|
// thrashed small nodes into fleet-wide 502s; ~1/16 of RAM
|
||||||
|
// keeps headroom for mempool + connections + the app stack.
|
||||||
// -par=0 — use all available cores for script
|
// -par=0 — use all available cores for script
|
||||||
// verification (defaults to NCPU-1 capped at 16). Was
|
// verification (defaults to NCPU-1 capped at 16). Was
|
||||||
// effectively pinned at 2 by --cpus=2 (now removed).
|
// effectively pinned at 2 by --cpus=2 (now removed).
|
||||||
@ -689,7 +714,7 @@ pub(super) async fn get_app_config(
|
|||||||
"-rpcport=8332".to_string(),
|
"-rpcport=8332".to_string(),
|
||||||
"-printtoconsole=1".to_string(),
|
"-printtoconsole=1".to_string(),
|
||||||
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
||||||
"-dbcache=4096".to_string(),
|
format!("-dbcache={}", bitcoin_dbcache_mb()),
|
||||||
"-par=0".to_string(),
|
"-par=0".to_string(),
|
||||||
"-maxconnections=125".to_string(),
|
"-maxconnections=125".to_string(),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@ -376,16 +376,31 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
|||||||
/// order for the given app. Unknown containers sort to the end.
|
/// order for the given app. Unknown containers sort to the end.
|
||||||
pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec<String>> {
|
pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec<String>> {
|
||||||
let containers = get_containers_for_app(package_id).await?;
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
|
Ok(order_present_containers(package_id, containers))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Order the *actually-present* containers of an app by its dependency-aware
|
||||||
|
/// startup order. Containers whose name is unknown to the order list sort to
|
||||||
|
/// the end, preserving their relative input order.
|
||||||
|
///
|
||||||
|
/// This deliberately does NOT inject order entries that aren't live
|
||||||
|
/// containers. `startup_order` is a union of container-name variants across
|
||||||
|
/// install generations (e.g. `mysql-mempool` vs `archy-mempool-db`), so any
|
||||||
|
/// single install only ever has a subset of those names. Injecting a phantom
|
||||||
|
/// name makes the start path fail on a "no such object" inspect — and because
|
||||||
|
/// `do_orchestrator_package_start` propagates the unknown-app-id fallback
|
||||||
|
/// error via `?`, every later member (the api + frontend) is then skipped,
|
||||||
|
/// leaving the stack down until the health monitor recovers it minutes later.
|
||||||
|
/// That was the source of mempool gate flakes #73 (frontend) / #74 (api).
|
||||||
|
fn order_present_containers(package_id: &str, containers: Vec<String>) -> Vec<String> {
|
||||||
|
if containers.is_empty() {
|
||||||
|
// Nothing is live under any known name. Fall back to the package id so
|
||||||
|
// a single-container app whose container matches its id still gets one
|
||||||
|
// start attempt; multi-container stacks with no live members are
|
||||||
|
// surfaced as "no containers" by the caller's emptiness check.
|
||||||
|
return vec![package_id.to_string()];
|
||||||
|
}
|
||||||
let order = startup_order(package_id);
|
let order = startup_order(package_id);
|
||||||
if order.is_empty() && containers.is_empty() {
|
|
||||||
return Ok(vec![package_id.to_string()]);
|
|
||||||
}
|
|
||||||
let mut sorted = containers;
|
|
||||||
for required in order {
|
|
||||||
if !sorted.iter().any(|name| name == required) {
|
|
||||||
sorted.push((*required).to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If no special order is defined, fall back to mempool order for legacy
|
// If no special order is defined, fall back to mempool order for legacy
|
||||||
// multi-container names that may still be returned by config lookups.
|
// multi-container names that may still be returned by config lookups.
|
||||||
let effective_order: &[&str] = if order.is_empty() {
|
let effective_order: &[&str] = if order.is_empty() {
|
||||||
@ -393,8 +408,14 @@ pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec
|
|||||||
} else {
|
} else {
|
||||||
order
|
order
|
||||||
};
|
};
|
||||||
sorted.sort_by_key(|c| effective_order.iter().position(|o| *o == c).unwrap_or(99));
|
let mut sorted = containers;
|
||||||
Ok(sorted)
|
sorted.sort_by_key(|c| {
|
||||||
|
effective_order
|
||||||
|
.iter()
|
||||||
|
.position(|o| *o == c)
|
||||||
|
.unwrap_or(usize::MAX)
|
||||||
|
});
|
||||||
|
sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configure Fedimint Gateway to use LND instead of LDK.
|
/// Configure Fedimint Gateway to use LND instead of LDK.
|
||||||
@ -452,7 +473,48 @@ pub(super) fn configure_fedimint_lnd(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{requires_unpruned_bitcoin, startup_order};
|
use super::{order_present_containers, requires_unpruned_bitcoin, startup_order};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_present_containers_never_injects_phantom_stack_members() {
|
||||||
|
// The live mempool stack on a node: db + api + frontend. These are the
|
||||||
|
// only real container names; the startup_order list also contains
|
||||||
|
// variant/legacy names (mysql-mempool, archy-mempool-api, ...) that are
|
||||||
|
// NOT live here and must never appear in the result — a phantom name in
|
||||||
|
// the start list aborts the orchestrator start mid-sequence (gate
|
||||||
|
// #73/#74).
|
||||||
|
let present = vec![
|
||||||
|
"mempool".to_string(),
|
||||||
|
"mempool-api".to_string(),
|
||||||
|
"archy-mempool-db".to_string(),
|
||||||
|
];
|
||||||
|
let ordered = order_present_containers("mempool", present);
|
||||||
|
// Dependency order: db -> api -> frontend.
|
||||||
|
assert_eq!(ordered, vec!["archy-mempool-db", "mempool-api", "mempool"]);
|
||||||
|
// No phantom variants leaked in.
|
||||||
|
for phantom in ["mysql-mempool", "archy-mempool-api", "archy-mempool-web"] {
|
||||||
|
assert!(
|
||||||
|
!ordered.iter().any(|c| c == phantom),
|
||||||
|
"phantom {phantom} must not be injected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_present_containers_orders_known_before_unknown() {
|
||||||
|
let present = vec!["mempool".to_string(), "some-sidecar".to_string()];
|
||||||
|
let ordered = order_present_containers("mempool", present);
|
||||||
|
// The known frontend sorts ahead of an unknown sidecar.
|
||||||
|
assert_eq!(ordered, vec!["mempool", "some-sidecar"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_present_containers_empty_falls_back_to_package_id() {
|
||||||
|
assert_eq!(
|
||||||
|
order_present_containers("mempool", vec![]),
|
||||||
|
vec!["mempool".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn btcpay_start_order_includes_required_stack_members() {
|
fn btcpay_start_order_includes_required_stack_members() {
|
||||||
|
|||||||
@ -243,6 +243,17 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multi-version support: honor an install-time version selection for the
|
||||||
|
// orchestrator-managed Bitcoin apps. Selecting the catalog default (or
|
||||||
|
// omitting `version`) leaves the app unpinned (tracks latest); selecting
|
||||||
|
// an older version pins it so install_fresh resolves that image and the
|
||||||
|
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
||||||
|
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
|
||||||
|
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
|
||||||
|
persist_install_version_selection(package_id, version).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Phase: Preparing — emit BEFORE the stack dispatch so multi-container
|
// Phase: Preparing — emit BEFORE the stack dispatch so multi-container
|
||||||
// stacks also flip state to Installing immediately. Without this, the
|
// stacks also flip state to Installing immediately. Without this, the
|
||||||
// backend's package state for stack apps stayed empty until the first
|
// backend's package state for stack apps stayed empty until the first
|
||||||
@ -2427,6 +2438,36 @@ exit 2
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist an install-time version selection for a multi-version app. Selecting
|
||||||
|
/// the catalog default (or a version equal to it) un-pins so the app tracks
|
||||||
|
/// latest; selecting any other version pins it. Best-effort: a write failure
|
||||||
|
/// just means the app installs at the catalog default.
|
||||||
|
async fn persist_install_version_selection(app_id: &str, version: &str) {
|
||||||
|
use crate::container::version_config::{read, write, AppVersionConfig};
|
||||||
|
let is_default = crate::container::app_catalog::catalog_default_version(app_id)
|
||||||
|
.map(|d| d == version)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let existing = read(app_id);
|
||||||
|
let cfg = AppVersionConfig {
|
||||||
|
pinned_version: if is_default {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(version.to_string())
|
||||||
|
},
|
||||||
|
auto_update: existing.auto_update,
|
||||||
|
};
|
||||||
|
if let Err(e) = write(app_id, &cfg) {
|
||||||
|
tracing::warn!(app_id, version, error = %e, "failed to persist install-time version selection");
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
app_id,
|
||||||
|
version,
|
||||||
|
pinned = !is_default,
|
||||||
|
"persisted install-time version selection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn should_try_orchestrator_install(package_id: &str, orchestrator_available: bool) -> bool {
|
fn should_try_orchestrator_install(package_id: &str, orchestrator_available: bool) -> bool {
|
||||||
orchestrator_available && uses_orchestrator_install_flow(package_id)
|
orchestrator_available && uses_orchestrator_install_flow(package_id)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ mod install;
|
|||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
|
mod set_config;
|
||||||
mod stacks;
|
mod stacks;
|
||||||
mod update;
|
mod update;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|||||||
@ -22,6 +22,11 @@ const PODMAN_LOG_TIMEOUT: Duration = Duration::from_secs(15);
|
|||||||
/// Per-container graceful shutdown timeout in seconds.
|
/// Per-container graceful shutdown timeout in seconds.
|
||||||
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
|
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
|
||||||
/// indexers 300s for index flush, databases 120s for WAL/transaction commit.
|
/// indexers 300s for index flush, databases 120s for WAL/transaction commit.
|
||||||
|
///
|
||||||
|
/// MIRRORS `archipelago_container::runtime::stop_grace_secs_for` (which returns
|
||||||
|
/// `u64` and is the canonical table used by the orchestrator stop path). This
|
||||||
|
/// `&str` variant exists for the legacy `podman stop -t <s>` call sites here —
|
||||||
|
/// keep the two tables in sync until those are migrated to the orchestrator.
|
||||||
pub fn stop_timeout_secs(container_name: &str) -> &'static str {
|
pub fn stop_timeout_secs(container_name: &str) -> &'static str {
|
||||||
let id = container_name
|
let id = container_name
|
||||||
.strip_prefix("archy-")
|
.strip_prefix("archy-")
|
||||||
@ -307,7 +312,16 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let mut stopped = 0u32;
|
let mut stopped = 0u32;
|
||||||
let mut removed = 0u32;
|
let mut removed = 0u32;
|
||||||
let mut errors = Vec::new();
|
// Two distinct failure classes, kept separate so they don't get
|
||||||
|
// conflated (the old single `errors` vec did, which caused the "ghost in
|
||||||
|
// My Apps" bug): `container_errors` means a container could NOT be
|
||||||
|
// removed (force-rm failed too) — the app is genuinely still present, so
|
||||||
|
// we keep its state entry and surface a hard error. `cleanup_errors`
|
||||||
|
// means volume/network/data-dir teardown left residue — the containers
|
||||||
|
// are already gone, so the app IS uninstalled and MUST disappear from My
|
||||||
|
// Apps; the residue is logged but never ghosts the app.
|
||||||
|
let mut container_errors: Vec<String> = Vec::new();
|
||||||
|
let mut cleanup_errors: Vec<String> = Vec::new();
|
||||||
|
|
||||||
self.set_uninstall_stage(
|
self.set_uninstall_stage(
|
||||||
package_id,
|
package_id,
|
||||||
@ -365,7 +379,7 @@ impl RpcHandler {
|
|||||||
let msg =
|
let msg =
|
||||||
format!("Failed to remove {}: {}; {}", name, stderr.trim(), e);
|
format!("Failed to remove {}: {}; {}", name, stderr.trim(), e);
|
||||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||||
errors.push(msg);
|
container_errors.push(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -374,12 +388,35 @@ impl RpcHandler {
|
|||||||
Err(force_err) => {
|
Err(force_err) => {
|
||||||
let msg = format!("Failed to remove {}: {}; {}", name, e, force_err);
|
let msg = format!("Failed to remove {}: {}; {}", name, e, force_err);
|
||||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||||
errors.push(msg);
|
container_errors.push(msg);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A container that survived even force-remove means the app is NOT
|
||||||
|
// actually uninstalled — keep its state entry and fail so the spawned
|
||||||
|
// task reverts it to its prior state (and the user can retry), rather
|
||||||
|
// than orphaning a live container that's missing from My Apps.
|
||||||
|
if !container_errors.is_empty() {
|
||||||
|
tracing::error!(
|
||||||
|
"Uninstall {}: containers could not be removed: {:?}",
|
||||||
|
package_id,
|
||||||
|
container_errors
|
||||||
|
);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Uninstall {} failed: {}",
|
||||||
|
package_id,
|
||||||
|
container_errors.join("; ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Containers are gone → the app is uninstalled. Remove its state entry
|
||||||
|
// NOW, before the (possibly slow, possibly fallible) volume/data
|
||||||
|
// teardown below, so My Apps updates immediately and a residue failure
|
||||||
|
// can never leave a ghost. Reinstall/scan no longer see a stale entry.
|
||||||
|
self.remove_package_state_entry(package_id).await;
|
||||||
|
|
||||||
self.set_uninstall_stage(package_id, "Cleaning up volumes")
|
self.set_uninstall_stage(package_id, "Cleaning up volumes")
|
||||||
.await;
|
.await;
|
||||||
// Avoid global Podman volume prune on production nodes: store-wide
|
// Avoid global Podman volume prune on production nodes: store-wide
|
||||||
@ -427,70 +464,73 @@ impl RpcHandler {
|
|||||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||||
let msg = format!("Failed to remove data {}: {}", dir, stderr.trim());
|
let msg = format!("Failed to remove data {}: {}", dir, stderr.trim());
|
||||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||||
errors.push(msg);
|
cleanup_errors.push(msg);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!("Failed to remove data {}: {}", dir, e);
|
let msg = format!("Failed to remove data {}: {}", dir, e);
|
||||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||||
errors.push(msg);
|
cleanup_errors.push(msg);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !errors.is_empty() {
|
// The app is already gone from My Apps (entry removed above). Residual
|
||||||
|
// volume/data cleanup failures are logged but NEVER ghost the app — a
|
||||||
|
// reinstall and the next uninstall both tolerate leftover dirs.
|
||||||
|
if !cleanup_errors.is_empty() {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"Uninstall {} completed with errors: {:?}",
|
"Uninstall {} removed but left cleanup residue: {:?}",
|
||||||
package_id,
|
package_id,
|
||||||
errors
|
cleanup_errors
|
||||||
);
|
);
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"Uninstall {} partially failed: {}",
|
|
||||||
package_id,
|
|
||||||
errors.join("; ")
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Uninstall {} complete: stopped={}, removed={}",
|
"Uninstall {} complete: stopped={}, removed={}, cleanup_errors={}",
|
||||||
package_id,
|
package_id,
|
||||||
stopped,
|
stopped,
|
||||||
removed
|
removed,
|
||||||
|
cleanup_errors.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Immediately remove from in-memory state so the UI updates without
|
|
||||||
// waiting for the scanner's absence threshold (3 scans × 60s each).
|
|
||||||
{
|
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
|
||||||
let before = data.package_data.len();
|
|
||||||
data.package_data.remove(package_id);
|
|
||||||
// Also remove any alias keys (e.g. "bitcoin-knots" vs "bitcoin")
|
|
||||||
let aliases: Vec<String> = data
|
|
||||||
.package_data
|
|
||||||
.keys()
|
|
||||||
.filter(|k| {
|
|
||||||
super::config::all_container_names(package_id)
|
|
||||||
.iter()
|
|
||||||
.any(|c| c.strip_prefix("archy-").unwrap_or(c) == k.as_str())
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
for alias in &aliases {
|
|
||||||
data.package_data.remove(alias);
|
|
||||||
}
|
|
||||||
if data.package_data.len() < before {
|
|
||||||
self.state_manager.update_data(data).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"status": "uninstalled",
|
"status": "uninstalled",
|
||||||
"stopped": stopped,
|
"stopped": stopped,
|
||||||
"removed": removed,
|
"removed": removed,
|
||||||
|
"cleanup_warnings": cleanup_errors,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove a package's entry (and any alias keys) from persisted state so it
|
||||||
|
/// disappears from My Apps immediately, without waiting for the scanner's
|
||||||
|
/// absence threshold (3 scans × 60s). Called as soon as an uninstall has
|
||||||
|
/// removed the app's containers — before the slower volume/data teardown —
|
||||||
|
/// so a residue failure can never leave a ghost entry behind.
|
||||||
|
async fn remove_package_state_entry(&self, package_id: &str) {
|
||||||
|
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||||
|
let before = data.package_data.len();
|
||||||
|
data.package_data.remove(package_id);
|
||||||
|
// Also remove any alias keys (e.g. "bitcoin-knots" vs "bitcoin").
|
||||||
|
let aliases: Vec<String> = data
|
||||||
|
.package_data
|
||||||
|
.keys()
|
||||||
|
.filter(|k| {
|
||||||
|
super::config::all_container_names(package_id)
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.strip_prefix("archy-").unwrap_or(c) == k.as_str())
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
for alias in &aliases {
|
||||||
|
data.package_data.remove(alias);
|
||||||
|
}
|
||||||
|
if data.package_data.len() < before {
|
||||||
|
self.state_manager.update_data(data).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Start a bundled app (create container from pre-loaded image if needed).
|
/// Start a bundled app (create container from pre-loaded image if needed).
|
||||||
pub(in crate::api::rpc) async fn handle_bundled_app_start(
|
pub(in crate::api::rpc) async fn handle_bundled_app_start(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
270
core/archipelago/src/api/rpc/package/set_config.rs
Normal file
270
core/archipelago/src/api/rpc/package/set_config.rs
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
//! Multi-version support — version listing + in-app version switch / pin /
|
||||||
|
//! auto-update toggle (`docs/bitcoin-multi-version-design.md` §3 Phase 3).
|
||||||
|
//!
|
||||||
|
//! Two RPCs:
|
||||||
|
//! - `package.versions` — read the selectable versions for an app plus the
|
||||||
|
//! runner's current pin / auto-update preference and (best-effort) the
|
||||||
|
//! version actually running. Drives the install modal + "Version & Updates"
|
||||||
|
//! card.
|
||||||
|
//! - `package.set-config` — persist a version pin (or un-pin to track latest)
|
||||||
|
//! and/or the auto-update toggle, then recreate the app at the chosen image
|
||||||
|
//! when the version actually changed. A DOWNGRADE (older release over a
|
||||||
|
//! newer chainstate — the highest-risk operation, design §4) is refused
|
||||||
|
//! unless the caller passes `confirm: true`, so the UI can warn first.
|
||||||
|
|
||||||
|
use super::config::get_containers_for_app;
|
||||||
|
use super::install::install_log;
|
||||||
|
use super::validation::validate_app_id;
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use crate::container::{app_catalog, version_config};
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// Apps that participate in multi-version selection today. Kept narrow on
|
||||||
|
/// purpose: version switching recreates the container, which is only safe for
|
||||||
|
/// the single-container, orchestrator-managed Bitcoin backends whose data and
|
||||||
|
/// downgrade semantics we understand. Any app the catalog gives a `versions[]`
|
||||||
|
/// list also qualifies (third-party registry apps inherit the capability).
|
||||||
|
fn supports_versions(app_id: &str) -> bool {
|
||||||
|
matches!(app_id, "bitcoin-core" | "bitcoin-knots")
|
||||||
|
|| !app_catalog::catalog_versions(app_id).is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the tag from a full image reference, leaving a `registry:port/repo`
|
||||||
|
/// host-port colon intact (only a colon AFTER the last `/` is a tag).
|
||||||
|
fn image_tag(image: &str) -> Option<String> {
|
||||||
|
let after_slash = image.rsplit_once('/').map(|(_, r)| r).unwrap_or(image);
|
||||||
|
after_slash
|
||||||
|
.rsplit_once(':')
|
||||||
|
.map(|(_, tag)| tag.to_string())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort: the version tag of the backend container actually running for
|
||||||
|
/// `app_id`, by inspecting its image. `None` when not installed or unreadable.
|
||||||
|
async fn installed_version(app_id: &str) -> Option<String> {
|
||||||
|
let containers = get_containers_for_app(app_id).await.ok()?;
|
||||||
|
// Prefer the backend container (exact id / `archy-<id>`) over UI companions.
|
||||||
|
let name = containers
|
||||||
|
.iter()
|
||||||
|
.find(|n| n.as_str() == app_id || n.as_str() == format!("archy-{app_id}"))
|
||||||
|
.or_else(|| containers.first())?;
|
||||||
|
let out = tokio::process::Command::new("podman")
|
||||||
|
.args(["inspect", name, "--format", "{{.ImageName}}"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let image = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
image_tag(&image)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// `package.versions` — what a runner can install / switch to for this app,
|
||||||
|
/// plus their current preference and the running version.
|
||||||
|
pub(in crate::api::rpc) async fn handle_package_versions(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let app_id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
validate_app_id(app_id)?;
|
||||||
|
|
||||||
|
let versions = app_catalog::catalog_versions(app_id);
|
||||||
|
let default = app_catalog::catalog_default_version(app_id);
|
||||||
|
let cfg = version_config::read(app_id);
|
||||||
|
let installed = installed_version(app_id).await;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"id": app_id,
|
||||||
|
"supportsVersions": supports_versions(app_id),
|
||||||
|
"default": default,
|
||||||
|
"installedVersion": installed,
|
||||||
|
"pinnedVersion": cfg.pinned_version,
|
||||||
|
"autoUpdate": cfg.auto_update,
|
||||||
|
"versions": versions.iter().map(|v| serde_json::json!({
|
||||||
|
"version": v.version,
|
||||||
|
"default": v.default,
|
||||||
|
"deprecated": v.deprecated,
|
||||||
|
"eol": v.eol,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `package.set-config` — persist version pin + auto-update preference and
|
||||||
|
/// recreate on an actual version change. Downgrades require `confirm:true`.
|
||||||
|
pub(in crate::api::rpc) async fn handle_package_set_config(
|
||||||
|
self: Arc<Self>,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let app_id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
|
||||||
|
.to_string();
|
||||||
|
validate_app_id(&app_id)?;
|
||||||
|
|
||||||
|
if !supports_versions(&app_id) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"{} has no selectable versions in the catalog",
|
||||||
|
app_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let confirm = params
|
||||||
|
.get("confirm")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let existing = version_config::read(&app_id);
|
||||||
|
let default = app_catalog::catalog_default_version(&app_id);
|
||||||
|
|
||||||
|
// ---- Resolve the requested pin (if a version was supplied) ----------
|
||||||
|
// Absent `version` => leave the pin unchanged (an auto-update-only edit).
|
||||||
|
// `version == default` => un-pin (track latest). Any other version must
|
||||||
|
// exist in the catalog and resolve to a same-repo image, else reject.
|
||||||
|
let version_param = params
|
||||||
|
.get("version")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
let mut new_pin = existing.pinned_version.clone();
|
||||||
|
let mut version_changed = false;
|
||||||
|
if let Some(req) = version_param.as_deref() {
|
||||||
|
let resolved_pin = if default.as_deref() == Some(req) {
|
||||||
|
None // selecting the default un-pins
|
||||||
|
} else {
|
||||||
|
// Validate the version is real + same-repo before pinning.
|
||||||
|
if !app_catalog::catalog_versions(&app_id)
|
||||||
|
.iter()
|
||||||
|
.any(|v| v.version == req)
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"version {} is not offered for {}",
|
||||||
|
req,
|
||||||
|
app_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(req.to_string())
|
||||||
|
};
|
||||||
|
version_changed = resolved_pin != existing.pinned_version;
|
||||||
|
new_pin = resolved_pin;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_auto_update = params
|
||||||
|
.get("autoUpdate")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(existing.auto_update);
|
||||||
|
|
||||||
|
// ---- Downgrade gate (design §4: warn + confirm + allow) -------------
|
||||||
|
// "Current" = what wrote the on-disk chainstate: the running version if
|
||||||
|
// we can read it, else the existing pin, else the catalog default.
|
||||||
|
if version_changed {
|
||||||
|
let target = version_param.as_deref().unwrap_or_default();
|
||||||
|
let current = installed_version(&app_id)
|
||||||
|
.await
|
||||||
|
.or_else(|| existing.pinned_version.clone())
|
||||||
|
.or_else(|| default.clone());
|
||||||
|
if let Some(current) = current {
|
||||||
|
if version_config::is_downgrade(¤t, target) && !confirm {
|
||||||
|
warn!(
|
||||||
|
"set-config {}: refusing un-confirmed downgrade {} -> {}",
|
||||||
|
app_id, current, target
|
||||||
|
);
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"status": "confirm_required",
|
||||||
|
"kind": "downgrade",
|
||||||
|
"id": app_id,
|
||||||
|
"currentVersion": current,
|
||||||
|
"targetVersion": target,
|
||||||
|
"warning": format!(
|
||||||
|
"Switching {app_id} from {current} down to {target} is a \
|
||||||
|
downgrade. Bitcoin may refuse to start on a chainstate \
|
||||||
|
written by the newer version without a full reindex, and \
|
||||||
|
a pruned node can lose block data. Re-confirm to proceed."
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Persist preference --------------------------------------------
|
||||||
|
version_config::write(
|
||||||
|
&app_id,
|
||||||
|
&version_config::AppVersionConfig {
|
||||||
|
pinned_version: new_pin.clone(),
|
||||||
|
auto_update: new_auto_update,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
install_log(&format!(
|
||||||
|
"SET-CONFIG {}: pinned={:?} autoUpdate={} (version_changed={})",
|
||||||
|
app_id, new_pin, new_auto_update, version_changed
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
info!(
|
||||||
|
app_id = %app_id,
|
||||||
|
pinned = ?new_pin,
|
||||||
|
auto_update = new_auto_update,
|
||||||
|
version_changed,
|
||||||
|
"package.set-config applied"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Recreate when the version actually changed + app is installed --
|
||||||
|
// The orchestrator's install/recreate path reads the pin we just wrote
|
||||||
|
// (prod_orchestrator image resolution), so reusing the update machinery
|
||||||
|
// pulls + recreates at the chosen image. An auto-update-only edit, or a
|
||||||
|
// change to a not-installed app, just persists the preference.
|
||||||
|
let mut recreating = false;
|
||||||
|
if version_changed {
|
||||||
|
let installed = get_containers_for_app(&app_id)
|
||||||
|
.await
|
||||||
|
.map(|c| !c.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if installed {
|
||||||
|
recreating = true;
|
||||||
|
// Fire the existing async update flow; it flips state to
|
||||||
|
// Updating and recreates honoring the new pin. The UI polls.
|
||||||
|
self.clone()
|
||||||
|
.spawn_package_update(Some(serde_json::json!({ "id": app_id })))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"id": app_id,
|
||||||
|
"pinnedVersion": new_pin,
|
||||||
|
"autoUpdate": new_auto_update,
|
||||||
|
"versionChanged": version_changed,
|
||||||
|
"recreating": recreating,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::image_tag;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_tag_keeps_registry_port_colon() {
|
||||||
|
assert_eq!(
|
||||||
|
image_tag("146.59.87.168:3000/lfg2025/bitcoin:28.4").as_deref(),
|
||||||
|
Some("28.4")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
image_tag("146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508").as_deref(),
|
||||||
|
Some("29.3.knots20260508")
|
||||||
|
);
|
||||||
|
// No tag => None (don't mistake the registry port for a tag).
|
||||||
|
assert_eq!(image_tag("146.59.87.168:3000/lfg2025/bitcoin"), None);
|
||||||
|
assert_eq!(
|
||||||
|
image_tag("docker.io/library/redis:7"),
|
||||||
|
Some("7".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,6 @@
|
|||||||
use crate::api::rpc::RpcHandler;
|
use crate::api::rpc::RpcHandler;
|
||||||
use crate::data_model::InstallPhase;
|
use crate::data_model::InstallPhase;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use base64::Engine;
|
|
||||||
use std::process::Output;
|
use std::process::Output;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@ -620,16 +619,25 @@ async fn install_stack_via_orchestrator(
|
|||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let mut installed = 0usize;
|
||||||
for app_id in app_ids {
|
for app_id in app_ids {
|
||||||
match orchestrator.install(app_id).await {
|
match orchestrator.install(app_id).await {
|
||||||
Ok(container_name) => {
|
Ok(container_name) => {
|
||||||
|
installed += 1;
|
||||||
install_log(&format!(
|
install_log(&format!(
|
||||||
"INSTALL ORCH: {} stack — app {} installed as {}",
|
"INSTALL ORCH: {} stack — app {} installed as {}",
|
||||||
stack_name, app_id, container_name
|
stack_name, app_id, container_name
|
||||||
))
|
))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Err(e) if e.to_string().contains("unknown app_id") => {
|
Err(e) if e.to_string().contains("unknown app_id") && installed == 0 => {
|
||||||
|
// None of the stack's manifests are known — the orchestrator
|
||||||
|
// can't render this stack at all, so defer to the legacy
|
||||||
|
// installer. Only safe when NOTHING was installed yet: once an
|
||||||
|
// earlier member is up, falling back would let the legacy path
|
||||||
|
// double-create containers on the same data dir (observed
|
||||||
|
// corrupting an immich postgres cluster — two postmasters, one
|
||||||
|
// PGDATA). A partial set means a deploy bug, not a legacy node.
|
||||||
install_log(&format!(
|
install_log(&format!(
|
||||||
"INSTALL ORCH SKIP: {} stack — app {} unknown, falling back to legacy stack installer",
|
"INSTALL ORCH SKIP: {} stack — app {} unknown, falling back to legacy stack installer",
|
||||||
stack_name, app_id
|
stack_name, app_id
|
||||||
@ -637,6 +645,17 @@ async fn install_stack_via_orchestrator(
|
|||||||
.await;
|
.await;
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
Err(e) if e.to_string().contains("unknown app_id") => {
|
||||||
|
install_log(&format!(
|
||||||
|
"INSTALL ORCH FAIL: {} stack — app {} unknown AFTER {} installed; refusing legacy fallback (would double-create on shared data)",
|
||||||
|
stack_name, app_id, installed
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
return Err(e.context(format!(
|
||||||
|
"orchestrator stack install {} aborted: app {} has no manifest but {} member(s) already installed — deploy all stack manifests",
|
||||||
|
stack_name, app_id, installed
|
||||||
|
)));
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
install_log(&format!(
|
install_log(&format!(
|
||||||
"INSTALL ORCH FAIL: {} stack — app {} failed: {}",
|
"INSTALL ORCH FAIL: {} stack — app {} failed: {}",
|
||||||
@ -668,11 +687,42 @@ fn mempool_stack_app_ids() -> &'static [&'static str] {
|
|||||||
&["archy-mempool-db", "mempool-api", "archy-mempool-web"]
|
&["archy-mempool-db", "mempool-api", "archy-mempool-web"]
|
||||||
}
|
}
|
||||||
|
|
||||||
const REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
fn immich_stack_app_ids() -> &'static [&'static str] {
|
||||||
|
// Install order = dependency order: db + cache before the server. The server
|
||||||
|
// app_id is the user-facing "immich" (canonical name + icon); its install is
|
||||||
|
// handled here (not recursively) since orchestrator.install bypasses the
|
||||||
|
// package.install routing that maps "immich" → this stack installer.
|
||||||
|
&["immich-postgres", "immich-redis", "immich"]
|
||||||
|
}
|
||||||
|
|
||||||
const NETBIRD_DASHBOARD_IMAGE: &str = "docker.io/netbirdio/dashboard:v2.38.0";
|
fn netbird_stack_app_ids() -> &'static [&'static str] {
|
||||||
const NETBIRD_SERVER_IMAGE: &str = "docker.io/netbirdio/netbird-server:0.71.2";
|
// Dependency/startup order: the combined management/signal/relay server
|
||||||
const NETBIRD_PROXY_IMAGE: &str = "docker.io/library/nginx:1.27-alpine";
|
// first (it owns the base64 relay/store secrets + the sqlite store, and is
|
||||||
|
// the OIDC issuer the others point at), then the dashboard SPA, then the
|
||||||
|
// user-facing TLS proxy ("netbird", which carries the self-signed cert +
|
||||||
|
// the templated nginx.conf and is the launcher). Mirrors the netbird
|
||||||
|
// startup_order in dependencies.rs.
|
||||||
|
&["netbird-server", "netbird-dashboard", "netbird"]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn indeedhub_stack_app_ids() -> &'static [&'static str] {
|
||||||
|
// Dependency order: backends + their generated secrets first, then the api
|
||||||
|
// (owns indeedhub-jwt; reads the db/minio secrets the backends materialised),
|
||||||
|
// then the ffmpeg worker, then the user-facing frontend ("indeedhub", which
|
||||||
|
// carries the post_install nginx hook). The frontend's nginx reaches the
|
||||||
|
// backends by their short network_aliases (api/minio/relay) on indeedhub-net.
|
||||||
|
&[
|
||||||
|
"indeedhub-postgres",
|
||||||
|
"indeedhub-redis",
|
||||||
|
"indeedhub-minio",
|
||||||
|
"indeedhub-relay",
|
||||||
|
"indeedhub-api",
|
||||||
|
"indeedhub-ffmpeg",
|
||||||
|
"indeedhub",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
||||||
|
|
||||||
/// Pull an image with retry and exponential backoff (3 attempts).
|
/// Pull an image with retry and exponential backoff (3 attempts).
|
||||||
async fn pull_image_with_retry(image: &str) -> Result<()> {
|
async fn pull_image_with_retry(image: &str) -> Result<()> {
|
||||||
@ -734,6 +784,17 @@ async fn pull_image_with_retry(image: &str) -> Result<()> {
|
|||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// Install Immich stack (postgres + redis + server).
|
/// Install Immich stack (postgres + redis + server).
|
||||||
pub(super) async fn install_immich_stack(&self) -> Result<serde_json::Value> {
|
pub(super) async fn install_immich_stack(&self) -> Result<serde_json::Value> {
|
||||||
|
// Manifest-driven path (workstream B/C): render the stack from
|
||||||
|
// apps/immich-*/manifest.yml via the orchestrator (rootless Quadlet
|
||||||
|
// units, generated_secrets, reboot-survivable). Falls back to the legacy
|
||||||
|
// installer below only when the orchestrator doesn't know these app_ids
|
||||||
|
// (manifests not yet deployed). See docs/PRODUCTION-MASTER-PLAN.md.
|
||||||
|
if let Some(orchestrated) =
|
||||||
|
install_stack_via_orchestrator(self, "immich", immich_stack_app_ids()).await?
|
||||||
|
{
|
||||||
|
return Ok(orchestrated);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(adopted) = adopt_stack_if_exists(
|
if let Some(adopted) = adopt_stack_if_exists(
|
||||||
"immich_server",
|
"immich_server",
|
||||||
"immich",
|
"immich",
|
||||||
@ -1383,6 +1444,20 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Install the IndeedHub multi-container stack.
|
/// Install the IndeedHub multi-container stack.
|
||||||
pub(super) async fn install_indeedhub_stack(&self) -> Result<serde_json::Value> {
|
pub(super) async fn install_indeedhub_stack(&self) -> Result<serde_json::Value> {
|
||||||
|
// Manifest-driven path (#20 phase 3): render the 7-member stack from
|
||||||
|
// apps/indeedhub-*/manifest.yml via the orchestrator (dedicated
|
||||||
|
// indeedhub-net + network_aliases, generated_secrets, the frontend's
|
||||||
|
// post_install nginx hook, reboot-survivable). The manifests use the exact
|
||||||
|
// live container names / named volumes, so on an existing node this ADOPTS
|
||||||
|
// the running stack rather than recreating it (data preserved). Falls back
|
||||||
|
// to the legacy installer below only when the orchestrator doesn't know
|
||||||
|
// these app_ids (manifests not yet deployed). See PRODUCTION-MASTER-PLAN.md.
|
||||||
|
if let Some(orchestrated) =
|
||||||
|
install_stack_via_orchestrator(self, "indeedhub", indeedhub_stack_app_ids()).await?
|
||||||
|
{
|
||||||
|
return Ok(orchestrated);
|
||||||
|
}
|
||||||
|
|
||||||
let registry = crate::container::registry::load_registries(&self.config.data_dir)
|
let registry = crate::container::registry::load_registries(&self.config.data_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@ -1758,6 +1833,27 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Install self-hosted NetBird (dashboard + combined management/signal/relay server).
|
/// Install self-hosted NetBird (dashboard + combined management/signal/relay server).
|
||||||
pub(super) async fn install_netbird_stack(&self) -> Result<serde_json::Value> {
|
pub(super) async fn install_netbird_stack(&self) -> Result<serde_json::Value> {
|
||||||
|
// Manifest-driven path (#20 phase 4): render the 3-member stack from
|
||||||
|
// apps/netbird-*/manifest.yml via the orchestrator — dedicated
|
||||||
|
// netbird-net + network_aliases, base64 generated_secrets, a self-signed
|
||||||
|
// TLS cert (generated_certs) so the dashboard gets a secure context for
|
||||||
|
// OIDC PKCE (#15), and templated config.yaml/nginx.conf rendered from
|
||||||
|
// host facts + the netbird-net gateway. The manifests use the exact live
|
||||||
|
// container names, so on an existing node this ADOPTS the running stack
|
||||||
|
// rather than recreating it (the sqlite store + base64 keys are
|
||||||
|
// preserved — ensure_generated_secrets no-ops on existing files).
|
||||||
|
//
|
||||||
|
// #20 ph4: the legacy hardcoded `podman run` installer was DELETED — the
|
||||||
|
// signed catalog always ships apps/netbird-*/manifest.yml, so there is no
|
||||||
|
// in-Rust fallback. If the orchestrator doesn't know these app_ids and no
|
||||||
|
// running stack exists to adopt, install errors rather than silently
|
||||||
|
// diverging from the manifest contract.
|
||||||
|
if let Some(orchestrated) =
|
||||||
|
install_stack_via_orchestrator(self, "netbird", netbird_stack_app_ids()).await?
|
||||||
|
{
|
||||||
|
return Ok(orchestrated);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(adopted) = adopt_stack_if_exists(
|
if let Some(adopted) = adopt_stack_if_exists(
|
||||||
"netbird",
|
"netbird",
|
||||||
"netbird",
|
"netbird",
|
||||||
@ -1768,452 +1864,12 @@ impl RpcHandler {
|
|||||||
return Ok(adopted);
|
return Ok(adopted);
|
||||||
}
|
}
|
||||||
|
|
||||||
install_log("INSTALL START: netbird stack (dashboard + server)").await;
|
anyhow::bail!(
|
||||||
info!("Installing self-hosted NetBird stack");
|
"netbird manifests not available on this node — the signed catalog must provide apps/netbird-*/manifest.yml (legacy hardcoded installer removed in #20 ph4)"
|
||||||
|
|
||||||
self.set_install_phase("netbird", InstallPhase::PullingImage)
|
|
||||||
.await;
|
|
||||||
for (i, image) in [
|
|
||||||
NETBIRD_DASHBOARD_IMAGE,
|
|
||||||
NETBIRD_SERVER_IMAGE,
|
|
||||||
NETBIRD_PROXY_IMAGE,
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
{
|
|
||||||
self.set_install_progress("netbird", i as u64, 3).await;
|
|
||||||
pull_image_with_retry(image)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("Failed to pull NetBird image: {}", image))?;
|
|
||||||
}
|
|
||||||
self.set_install_progress("netbird", 3, 3).await;
|
|
||||||
|
|
||||||
for name in ["netbird", "netbird-dashboard", "netbird-server"] {
|
|
||||||
let _ = podman_stack_status(&["rm", "-f", name], PODMAN_STACK_PROBE_TIMEOUT).await;
|
|
||||||
}
|
|
||||||
let _ = podman_stack_status(
|
|
||||||
&["network", "rm", "-f", "netbird-net"],
|
|
||||||
PODMAN_STACK_PROBE_TIMEOUT,
|
|
||||||
)
|
)
|
||||||
.await;
|
|
||||||
|
|
||||||
self.set_install_phase("netbird", InstallPhase::CreatingContainer)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
tokio::fs::create_dir_all("/var/lib/archipelago/netbird/data")
|
|
||||||
.await
|
|
||||||
.context("Failed to create NetBird data directory")?;
|
|
||||||
|
|
||||||
let host_ip = detect_netbird_public_host_ip()
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|| self.config.host_ip.clone());
|
|
||||||
|
|
||||||
// Create the network FIRST so we can read back the gateway it was
|
|
||||||
// assigned — that gateway is Podman's aardvark DNS, which the proxy's
|
|
||||||
// nginx needs as an explicit `resolver` to re-resolve container names
|
|
||||||
// (issue #15: without it nginx caches a container IP and 502s forever
|
|
||||||
// once that IP changes on restart/reboot).
|
|
||||||
let _ = podman_stack_status(
|
|
||||||
&["network", "create", "netbird-net"],
|
|
||||||
PODMAN_STACK_PROBE_TIMEOUT,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let resolver_ip = netbird_net_resolver_ip().await;
|
|
||||||
write_netbird_config_files(&host_ip, &self.config.host_ip, &resolver_ip).await?;
|
|
||||||
ensure_netbird_tls_cert(&host_ip).await?;
|
|
||||||
|
|
||||||
let mut server_cmd = tokio::process::Command::new("podman");
|
|
||||||
server_cmd.args([
|
|
||||||
"run",
|
|
||||||
"-d",
|
|
||||||
"--name",
|
|
||||||
"netbird-server",
|
|
||||||
"--network",
|
|
||||||
"netbird-net",
|
|
||||||
"--network-alias",
|
|
||||||
"netbird-server",
|
|
||||||
"--restart=unless-stopped",
|
|
||||||
"-p",
|
|
||||||
"8086:80",
|
|
||||||
"-p",
|
|
||||||
"3478:3478/udp",
|
|
||||||
"-v",
|
|
||||||
"/var/lib/archipelago/netbird/data:/var/lib/netbird",
|
|
||||||
"-v",
|
|
||||||
"/var/lib/archipelago/netbird/config.yaml:/etc/netbird/config.yaml:ro",
|
|
||||||
NETBIRD_SERVER_IMAGE,
|
|
||||||
"--config",
|
|
||||||
"/etc/netbird/config.yaml",
|
|
||||||
]);
|
|
||||||
run_required_stack_command("netbird", "create server", &mut server_cmd).await?;
|
|
||||||
|
|
||||||
self.set_install_phase("netbird", InstallPhase::StartingContainer)
|
|
||||||
.await;
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
||||||
|
|
||||||
let mut dashboard_cmd = tokio::process::Command::new("podman");
|
|
||||||
dashboard_cmd.args([
|
|
||||||
"run",
|
|
||||||
"-d",
|
|
||||||
"--name",
|
|
||||||
"netbird-dashboard",
|
|
||||||
"--network",
|
|
||||||
"netbird-net",
|
|
||||||
// Explicit alias so the proxy can always resolve `netbird-dashboard`
|
|
||||||
// via Podman DNS — don't rely on implicit container-name aliasing.
|
|
||||||
"--network-alias",
|
|
||||||
"netbird-dashboard",
|
|
||||||
"--restart=unless-stopped",
|
|
||||||
"--env-file",
|
|
||||||
"/var/lib/archipelago/netbird/dashboard.env",
|
|
||||||
NETBIRD_DASHBOARD_IMAGE,
|
|
||||||
]);
|
|
||||||
run_required_stack_command("netbird", "create dashboard", &mut dashboard_cmd).await?;
|
|
||||||
|
|
||||||
let mut proxy_cmd = tokio::process::Command::new("podman");
|
|
||||||
proxy_cmd.args([
|
|
||||||
"run",
|
|
||||||
"-d",
|
|
||||||
"--name",
|
|
||||||
"netbird",
|
|
||||||
"--network",
|
|
||||||
"netbird-net",
|
|
||||||
"--restart=unless-stopped",
|
|
||||||
// 8087 publishes the TLS listener — netbird's dashboard requires a
|
|
||||||
// secure context (window.crypto.subtle / OIDC PKCE), issue #15.
|
|
||||||
"-p",
|
|
||||||
"8087:443",
|
|
||||||
"-v",
|
|
||||||
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
|
|
||||||
"-v",
|
|
||||||
"/var/lib/archipelago/netbird/tls.crt:/etc/nginx/tls.crt:ro",
|
|
||||||
"-v",
|
|
||||||
"/var/lib/archipelago/netbird/tls.key:/etc/nginx/tls.key:ro",
|
|
||||||
NETBIRD_PROXY_IMAGE,
|
|
||||||
]);
|
|
||||||
run_required_stack_command("netbird", "create unified proxy", &mut proxy_cmd).await?;
|
|
||||||
|
|
||||||
wait_for_stack_containers(
|
|
||||||
"netbird",
|
|
||||||
&["netbird-server", "netbird-dashboard", "netbird"],
|
|
||||||
60,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
self.set_install_phase("netbird", InstallPhase::WaitingHealthy)
|
|
||||||
.await;
|
|
||||||
self.set_install_phase("netbird", InstallPhase::PostInstall)
|
|
||||||
.await;
|
|
||||||
self.set_install_phase("netbird", InstallPhase::Done).await;
|
|
||||||
self.clear_install_progress("netbird").await;
|
|
||||||
|
|
||||||
install_log("INSTALL OK: netbird stack").await;
|
|
||||||
info!("NetBird stack installed");
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"success": true,
|
|
||||||
"package_id": "netbird",
|
|
||||||
"message": "NetBird self-hosted stack installed",
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_or_generate_b64_secret(name: &str) -> String {
|
|
||||||
let path = format!("/var/lib/archipelago/secrets/{}", name);
|
|
||||||
if let Ok(val) = tokio::fs::read_to_string(&path).await {
|
|
||||||
let trimmed = val.trim().to_string();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut buf = [0u8; 32];
|
|
||||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf);
|
|
||||||
let secret = base64::engine::general_purpose::STANDARD.encode(buf);
|
|
||||||
let _ = tokio::fs::create_dir_all("/var/lib/archipelago/secrets").await;
|
|
||||||
let _ = tokio::fs::write(&path, &secret).await;
|
|
||||||
secret
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the gateway of the `netbird-net` bridge. Podman runs its aardvark DNS
|
|
||||||
/// resolver on this address, so nginx can use it as an explicit `resolver` to
|
|
||||||
/// re-resolve container names at request time. Falls back to Podman's usual
|
|
||||||
/// first-pool gateway if the inspect fails (best effort — config is rewritten
|
|
||||||
/// on every (re)install).
|
|
||||||
async fn netbird_net_resolver_ip() -> String {
|
|
||||||
let out = tokio::process::Command::new("podman")
|
|
||||||
.args([
|
|
||||||
"network",
|
|
||||||
"inspect",
|
|
||||||
"netbird-net",
|
|
||||||
"--format",
|
|
||||||
"{{range .Subnets}}{{.Gateway}}{{end}}",
|
|
||||||
])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
if let Ok(o) = out {
|
|
||||||
let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
|
||||||
if !gw.is_empty() && gw.parse::<std::net::IpAddr>().is_ok() {
|
|
||||||
return gw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"10.89.0.1".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a self-signed TLS cert for the netbird proxy if absent. The
|
|
||||||
/// dashboard needs a secure context (window.crypto.subtle / OIDC PKCE), so the
|
|
||||||
/// proxy serves HTTPS; a self-signed cert is sufficient (the user accepts it
|
|
||||||
/// once when opening netbird in a tab). SAN covers the LAN IP plus
|
|
||||||
/// localhost/127.0.0.1 so it's valid however the box is reached locally.
|
|
||||||
async fn ensure_netbird_tls_cert(host_ip: &str) -> Result<()> {
|
|
||||||
let dir = "/var/lib/archipelago/netbird";
|
|
||||||
let crt = format!("{dir}/tls.crt");
|
|
||||||
let key = format!("{dir}/tls.key");
|
|
||||||
if tokio::fs::metadata(&crt).await.is_ok() && tokio::fs::metadata(&key).await.is_ok() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let _ = tokio::fs::create_dir_all(dir).await;
|
|
||||||
let san = format!("subjectAltName=IP:{host_ip},IP:127.0.0.1,DNS:localhost");
|
|
||||||
let status = tokio::process::Command::new("openssl")
|
|
||||||
.args([
|
|
||||||
"req",
|
|
||||||
"-x509",
|
|
||||||
"-newkey",
|
|
||||||
"rsa:2048",
|
|
||||||
"-nodes",
|
|
||||||
"-keyout",
|
|
||||||
&key,
|
|
||||||
"-out",
|
|
||||||
&crt,
|
|
||||||
"-days",
|
|
||||||
"3650",
|
|
||||||
"-subj",
|
|
||||||
&format!("/CN={host_ip}"),
|
|
||||||
"-addext",
|
|
||||||
&san,
|
|
||||||
])
|
|
||||||
.status()
|
|
||||||
.await
|
|
||||||
.context("failed to run openssl for netbird TLS cert")?;
|
|
||||||
if !status.success() {
|
|
||||||
anyhow::bail!("openssl failed to generate netbird TLS cert");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn write_netbird_config_files(host_ip: &str, lan_ip: &str, resolver_ip: &str) -> Result<()> {
|
|
||||||
// netbird's dashboard uses window.crypto.subtle (OIDC PKCE), which browsers
|
|
||||||
// only expose in a SECURE context — so the proxy serves HTTPS and every
|
|
||||||
// origin here is https (issue #15: over plain http the dashboard threw
|
|
||||||
// "window.crypto.subtle is unavailable" and never reached login).
|
|
||||||
let public_origin = format!("https://{}:8087", host_ip);
|
|
||||||
let server_origin = format!("http://{}:8086", host_ip);
|
|
||||||
// A single box is reached via several addresses. Allow the OIDC login flow
|
|
||||||
// to redirect back to whichever origin the user actually used, otherwise
|
|
||||||
// post-login lands on the wrong host and the dashboard shows
|
|
||||||
// "Unauthenticated" (issue #15). The browser-side CORS is handled in the
|
|
||||||
// nginx proxy; this covers the redirect-URI allow-list.
|
|
||||||
let lan_origin = format!("https://{}:8087", lan_ip);
|
|
||||||
let mut redirect_origins = vec![public_origin.clone()];
|
|
||||||
if lan_origin != public_origin {
|
|
||||||
redirect_origins.push(lan_origin);
|
|
||||||
}
|
|
||||||
let dashboard_redirect_uris = redirect_origins
|
|
||||||
.iter()
|
|
||||||
.flat_map(|o| {
|
|
||||||
[
|
|
||||||
format!(" - \"{o}/nb-auth\""),
|
|
||||||
format!(" - \"{o}/nb-silent-auth\""),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
let dashboard_logout_uris = redirect_origins
|
|
||||||
.iter()
|
|
||||||
.map(|o| format!(" - \"{o}/\""))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n");
|
|
||||||
let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await;
|
|
||||||
let encryption_key = read_or_generate_b64_secret("netbird-store-encryption-key").await;
|
|
||||||
let config = format!(
|
|
||||||
r#"server:
|
|
||||||
listenAddress: ":80"
|
|
||||||
exposedAddress: "{public_origin}"
|
|
||||||
stunPorts:
|
|
||||||
- 3478
|
|
||||||
metricsPort: 9090
|
|
||||||
healthcheckAddress: ":9000"
|
|
||||||
logLevel: "info"
|
|
||||||
logFile: "console"
|
|
||||||
authSecret: "{relay_secret}"
|
|
||||||
dataDir: "/var/lib/netbird"
|
|
||||||
auth:
|
|
||||||
issuer: "{public_origin}/oauth2"
|
|
||||||
localAuthDisabled: false
|
|
||||||
signKeyRefreshEnabled: false
|
|
||||||
dashboardRedirectURIs:
|
|
||||||
{dashboard_redirect_uris}
|
|
||||||
dashboardPostLogoutRedirectURIs:
|
|
||||||
{dashboard_logout_uris}
|
|
||||||
cliRedirectURIs:
|
|
||||||
- "http://localhost:53000/"
|
|
||||||
store:
|
|
||||||
engine: "sqlite"
|
|
||||||
encryptionKey: "{encryption_key}"
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
tokio::fs::write("/var/lib/archipelago/netbird/config.yaml", config)
|
|
||||||
.await
|
|
||||||
.context("Failed to write NetBird config.yaml")?;
|
|
||||||
|
|
||||||
let dashboard_env = format!(
|
|
||||||
r#"NETBIRD_MGMT_API_ENDPOINT={public_origin}
|
|
||||||
NETBIRD_MGMT_GRPC_API_ENDPOINT={public_origin}
|
|
||||||
AUTH_AUDIENCE=netbird-dashboard
|
|
||||||
AUTH_CLIENT_ID=netbird-dashboard
|
|
||||||
AUTH_CLIENT_SECRET=
|
|
||||||
AUTH_AUTHORITY={public_origin}/oauth2
|
|
||||||
USE_AUTH0=false
|
|
||||||
AUTH_SUPPORTED_SCOPES=openid profile email groups
|
|
||||||
AUTH_REDIRECT_URI=/nb-auth
|
|
||||||
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
|
||||||
NETBIRD_TOKEN_SOURCE=idToken
|
|
||||||
NGINX_SSL_PORT=443
|
|
||||||
LETSENCRYPT_DOMAIN=none
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
tokio::fs::write("/var/lib/archipelago/netbird/dashboard.env", dashboard_env)
|
|
||||||
.await
|
|
||||||
.context("Failed to write NetBird dashboard.env")?;
|
|
||||||
|
|
||||||
let nginx_conf = format!(
|
|
||||||
r#"server {{
|
|
||||||
listen 443 ssl;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
# netbird's dashboard needs a secure context (window.crypto.subtle for OIDC
|
|
||||||
# PKCE), so the proxy terminates TLS with a self-signed cert (issue #15).
|
|
||||||
ssl_certificate /etc/nginx/tls.crt;
|
|
||||||
ssl_certificate_key /etc/nginx/tls.key;
|
|
||||||
|
|
||||||
# Rootless Podman can hand a container a new IP across restarts/reboots.
|
|
||||||
# nginx resolves a literal upstream name ONCE at startup and caches it, so
|
|
||||||
# after the IP moves every request 502s with "host unreachable" (issue #15,
|
|
||||||
# observed live on .198: nginx pinned to a dead netbird-dashboard IP). Fix:
|
|
||||||
# point `resolver` at the netbird-net gateway (Podman's aardvark DNS) and
|
|
||||||
# use VARIABLE upstreams, which forces nginx to re-resolve the container
|
|
||||||
# names at request time. Everything is reached container-to-container by
|
|
||||||
# name so nothing depends on host-published ports either.
|
|
||||||
resolver {resolver_ip} valid=10s ipv6=off;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
location ~ ^/(relay|ws-proxy/) {{
|
|
||||||
set $nb_server netbird-server;
|
|
||||||
proxy_pass http://$nb_server:80;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_read_timeout 1d;
|
|
||||||
}}
|
|
||||||
|
|
||||||
location ~ ^/(api|oauth2)(/|$) {{
|
|
||||||
# The dashboard is a SPA whose API/OIDC base URL is baked at build time
|
|
||||||
# to one host:port. A single box is reached via several addresses (LAN
|
|
||||||
# IP, Tailscale 100.x, hostname), so those fetches are cross-origin and
|
|
||||||
# the browser blocks them with no Access-Control-Allow-Origin (issue
|
|
||||||
# #15, observed live on .198). Reflect the caller's Origin so the
|
|
||||||
# self-hosted management/OIDC API is reachable from any of them, and
|
|
||||||
# answer the CORS preflight here.
|
|
||||||
if ($request_method = OPTIONS) {{
|
|
||||||
add_header Access-Control-Allow-Origin $http_origin always;
|
|
||||||
add_header Access-Control-Allow-Credentials true always;
|
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
|
||||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
|
||||||
add_header Access-Control-Max-Age 86400 always;
|
|
||||||
add_header Content-Length 0;
|
|
||||||
return 204;
|
|
||||||
}}
|
|
||||||
add_header Access-Control-Allow-Origin $http_origin always;
|
|
||||||
add_header Access-Control-Allow-Credentials true always;
|
|
||||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
|
||||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
|
||||||
set $nb_server netbird-server;
|
|
||||||
proxy_pass http://$nb_server:80;
|
|
||||||
}}
|
|
||||||
|
|
||||||
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {{
|
|
||||||
set $nb_server netbird-server;
|
|
||||||
grpc_pass grpc://$nb_server:80;
|
|
||||||
grpc_read_timeout 1d;
|
|
||||||
grpc_send_timeout 1d;
|
|
||||||
}}
|
|
||||||
|
|
||||||
# OIDC callback routes are client-side SPA routes with NO prebuilt page in
|
|
||||||
# the dashboard bundle, so proxying them straight through 404s — which
|
|
||||||
# crashes the dashboard's auth init and shows "Unauthenticated" with dead
|
|
||||||
# buttons (issue #15, confirmed live on .198: /nb-auth + /nb-silent-auth
|
|
||||||
# returned 404). Serve the dashboard's index.html at these paths (URL
|
|
||||||
# unchanged) so react-oidc boots and completes the login / silent-SSO.
|
|
||||||
location ~ ^/(nb-auth|nb-silent-auth) {{
|
|
||||||
set $nb_dashboard netbird-dashboard;
|
|
||||||
rewrite ^.*$ /index.html break;
|
|
||||||
proxy_pass http://$nb_dashboard:80;
|
|
||||||
}}
|
|
||||||
|
|
||||||
location / {{
|
|
||||||
set $nb_dashboard netbird-dashboard;
|
|
||||||
proxy_pass http://$nb_dashboard:80;
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
# Direct server remains available for diagnostics at {server_origin}.
|
|
||||||
"#
|
|
||||||
);
|
|
||||||
tokio::fs::write("/var/lib/archipelago/netbird/nginx.conf", nginx_conf)
|
|
||||||
.await
|
|
||||||
.context("Failed to write NetBird nginx.conf")?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn detect_netbird_public_host_ip() -> Option<String> {
|
|
||||||
let output = tokio::process::Command::new("hostname")
|
|
||||||
.args(["-I"])
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.ok()?;
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
let ips: Vec<&str> = stdout
|
|
||||||
.split_whitespace()
|
|
||||||
.filter(|s| s.contains('.'))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Prefer the LAN address as the canonical origin — that's what users browse
|
|
||||||
// to on the local network. Baking the Tailscale 100.x address here broke
|
|
||||||
// LAN access with cross-origin/redirect mismatches (issue #15). Tailscale
|
|
||||||
// (100.64.0.0/10 CGNAT) is only a fallback for nodes with no LAN IP.
|
|
||||||
let is_private_lan = |ip: &str| {
|
|
||||||
ip.starts_with("192.168.")
|
|
||||||
|| ip.starts_with("10.")
|
|
||||||
|| (ip.starts_with("172.")
|
|
||||||
&& ip
|
|
||||||
.split('.')
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|o| o.parse::<u8>().ok())
|
|
||||||
.map(|o| (16..=31).contains(&o))
|
|
||||||
.unwrap_or(false))
|
|
||||||
};
|
|
||||||
if let Some(lan) = ips.iter().find(|ip| is_private_lan(ip)) {
|
|
||||||
return Some(lan.to_string());
|
|
||||||
}
|
|
||||||
ips.iter()
|
|
||||||
.find(|ip| ip.starts_with("100."))
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{btcpay_stack_app_ids, mempool_stack_app_ids};
|
use super::{btcpay_stack_app_ids, mempool_stack_app_ids};
|
||||||
|
|||||||
@ -32,19 +32,27 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
validate_app_id(package_id)?;
|
validate_app_id(package_id)?;
|
||||||
|
|
||||||
// Verify an update is actually available. Prefer the remote app catalog
|
// Resolve the target image. Prefer the remote app catalog (decoupled
|
||||||
// (decoupled from the binary OTA), falling back to the image-versions.sh
|
// from the binary OTA), falling back to the image-versions.sh pin. This
|
||||||
// pin when the catalog is absent or doesn't cover this app.
|
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
|
||||||
|
// the image itself (manifest + catalog + version_config pin) in its
|
||||||
|
// upgrade path, so an app the catalog doesn't carry a primary image for
|
||||||
|
// (e.g. bitcoin-core, image lives in the embedded manifest + versions[])
|
||||||
|
// still upgrades. Only the legacy/stack path below hard-requires it.
|
||||||
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||||
.or_else(|| image_versions::pinned_image_for_app(package_id))
|
.or_else(|| image_versions::pinned_image_for_app(package_id));
|
||||||
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
|
|
||||||
|
|
||||||
// Note: the `already updating` guard lives in `spawn_package_update`
|
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||||
// (the async wrapper that dispatch actually routes to). By the time
|
// (the async wrapper that dispatch actually routes to). By the time
|
||||||
// this inner function runs, the wrapper has already flipped state to
|
// this inner function runs, the wrapper has already flipped state to
|
||||||
// `Updating`, so duplicating the check here would be a false positive.
|
// `Updating`, so duplicating the check here would be a false positive.
|
||||||
|
|
||||||
install_log(&format!("UPDATE: {} → {}", package_id, pinned)).await;
|
install_log(&format!(
|
||||||
|
"UPDATE: {} → {}",
|
||||||
|
package_id,
|
||||||
|
pinned.as_deref().unwrap_or("(orchestrator-resolved)")
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
|
||||||
// Set state to Updating
|
// Set state to Updating
|
||||||
{
|
{
|
||||||
@ -114,6 +122,16 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy/stack path hard-requires a concrete primary image (the
|
||||||
|
// orchestrator path above already returned for apps it manages).
|
||||||
|
let pinned = match pinned {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
self.clear_update_state(package_id).await;
|
||||||
|
return Err(anyhow::anyhow!("No pinned image found for {}", package_id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Resolve images to pull — either a stack or single container
|
// Resolve images to pull — either a stack or single container
|
||||||
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
|
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,33 @@
|
|||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use crate::wallet::{ecash, profits};
|
use crate::wallet::{ecash, fedimint_client, profits};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form)
|
||||||
|
/// always starts with `cashu`. Fedimint ecash notes never do, so a non-`cashu`
|
||||||
|
/// string is routed to the Fedimint reissue path.
|
||||||
|
fn is_cashu_token(token: &str) -> bool {
|
||||||
|
token.trim_start().starts_with("cashu")
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
||||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||||
|
let cashu_sats = wallet.balance();
|
||||||
|
// Spendable Fedimint balance too, so callers (e.g. the pay-for-file
|
||||||
|
// pre-check) see funds available across BOTH backends (#3). Best-effort:
|
||||||
|
// if fmcd isn't installed/joined this is just 0, never an error.
|
||||||
|
let fedimint_sats =
|
||||||
|
match fedimint_client::FedimintClient::from_node(&self.config.data_dir).await {
|
||||||
|
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
|
||||||
|
Err(_) => 0,
|
||||||
|
};
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"balance_sats": wallet.balance(),
|
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
|
||||||
|
// spendable amount across Cashu + Fedimint.
|
||||||
|
"balance_sats": cashu_sats,
|
||||||
|
"cashu_sats": cashu_sats,
|
||||||
|
"fedimint_sats": fedimint_sats,
|
||||||
|
"total_sats": cashu_sats + fedimint_sats,
|
||||||
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
|
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
|
||||||
"mint_url": wallet.mint_url,
|
"mint_url": wallet.mint_url,
|
||||||
}))
|
}))
|
||||||
@ -129,18 +150,42 @@ impl RpcHandler {
|
|||||||
let token = params
|
let token = params
|
||||||
.get("token")
|
.get("token")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing token"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing token"))?;
|
||||||
|
|
||||||
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
// Dual-ecash: one "Receive ecash" box accepts either a Cashu token
|
||||||
|
// (redeemed at the mint) or Fedimint notes (reissued via the fmcd
|
||||||
|
// sidecar). Detect by prefix and route accordingly.
|
||||||
|
if is_cashu_token(token) {
|
||||||
|
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"received_sats": amount,
|
||||||
|
"kind": "cashu",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (amount, federation_id) =
|
||||||
|
fedimint_client::reissue_into_any(&self.config.data_dir, token).await?;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"received_sats": amount,
|
"received_sats": amount,
|
||||||
|
"kind": "fedimint",
|
||||||
|
"federation_id": federation_id,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_wallet_ecash_history(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_wallet_ecash_history(&self) -> Result<serde_json::Value> {
|
||||||
|
// Unified history: Cashu transactions (tagged kind="cashu") + the local
|
||||||
|
// Fedimint transaction log (kind="fedimint"), newest first. Previously
|
||||||
|
// only Cashu was returned, so a Fedimint receive showed up nowhere.
|
||||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||||
|
let mut transactions = wallet.transactions;
|
||||||
|
transactions.extend(fedimint_client::load_fedimint_txs(&self.config.data_dir).await);
|
||||||
|
// Sort by RFC-3339 timestamp descending (string compare is valid for
|
||||||
|
// same-offset RFC-3339), newest first.
|
||||||
|
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"transactions": wallet.transactions,
|
"transactions": transactions,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,14 +13,32 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
const CACHE_REFRESH_SECS: u64 = 10;
|
// Poll frequently and recover fast so the cached snapshot tracks bitcoind's
|
||||||
const CACHE_ERROR_BACKOFF_SECS: u64 = 15;
|
// responsive windows during IBD. During heavy block-connection, getblockchaininfo
|
||||||
|
// can block briefly; a slow 10s/15s/20s cadence let one missed poll age the
|
||||||
|
// snapshot past the UI's 30s "stale" threshold, so the UI dwelled on
|
||||||
|
// "reconnecting…" long after bitcoind was answering again. Tight cadence + short
|
||||||
|
// timeout keeps last-known state fresh and clears the stale banner promptly.
|
||||||
|
const CACHE_REFRESH_SECS: u64 = 5;
|
||||||
|
const CACHE_ERROR_BACKOFF_SECS: u64 = 5;
|
||||||
|
|
||||||
|
// Grace window before a failing poll marks the snapshot "stale" for the UI.
|
||||||
|
// On a busy / swap-thrashing node (e.g. .198) getblockchaininfo intermittently
|
||||||
|
// exceeds the RPC timeout, so a single missed poll is normal and must NOT flip
|
||||||
|
// the UI to "reconnecting…". Only after the cached snapshot is genuinely old —
|
||||||
|
// several polls failed in a row — do we surface the banner.
|
||||||
|
const STALE_GRACE_MS: u64 = 20_000;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct BitcoinNodeStatus {
|
pub struct BitcoinNodeStatus {
|
||||||
pub ok: bool,
|
pub ok: bool,
|
||||||
pub stale: bool,
|
pub stale: bool,
|
||||||
pub updated_at_ms: u64,
|
pub updated_at_ms: u64,
|
||||||
|
// Server-computed age of the snapshot, filled in at serve time. The browser
|
||||||
|
// must not derive this itself (Date.now() - updated_at_ms) because that
|
||||||
|
// compares the browser clock against this node's clock — any skew made a
|
||||||
|
// fresh snapshot look stale and the "reconnecting…" banner never cleared.
|
||||||
|
pub age_ms: u64,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub blockchain_info: Option<serde_json::Value>,
|
pub blockchain_info: Option<serde_json::Value>,
|
||||||
pub network_info: Option<serde_json::Value>,
|
pub network_info: Option<serde_json::Value>,
|
||||||
@ -34,6 +52,7 @@ impl Default for BitcoinNodeStatus {
|
|||||||
ok: false,
|
ok: false,
|
||||||
stale: false,
|
stale: false,
|
||||||
updated_at_ms: 0,
|
updated_at_ms: 0,
|
||||||
|
age_ms: 0,
|
||||||
error: Some("Connecting to Bitcoin node...".to_string()),
|
error: Some("Connecting to Bitcoin node...".to_string()),
|
||||||
blockchain_info: None,
|
blockchain_info: None,
|
||||||
network_info: None,
|
network_info: None,
|
||||||
@ -122,7 +141,11 @@ pub fn spawn_status_cache() {
|
|||||||
|
|
||||||
if cached.blockchain_info.is_some() {
|
if cached.blockchain_info.is_some() {
|
||||||
cached.ok = false;
|
cached.ok = false;
|
||||||
cached.stale = true;
|
// Only flip to "stale" once the last good snapshot is older
|
||||||
|
// than the grace window. A brief RPC gap on a busy node keeps
|
||||||
|
// showing last-known state silently instead of a banner flicker.
|
||||||
|
let snapshot_age_ms = now_ms().saturating_sub(cached.updated_at_ms);
|
||||||
|
cached.stale = snapshot_age_ms > STALE_GRACE_MS;
|
||||||
cached.error = Some(friendly_transient_error(true, &err_msg));
|
cached.error = Some(friendly_transient_error(true, &err_msg));
|
||||||
} else {
|
} else {
|
||||||
*cached = BitcoinNodeStatus {
|
*cached = BitcoinNodeStatus {
|
||||||
@ -142,40 +165,46 @@ pub fn spawn_status_cache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
|
pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
|
||||||
cache().read().await.clone()
|
let mut status = cache().read().await.clone();
|
||||||
|
// Compute age here (server clock only) so the browser never has to subtract
|
||||||
|
// across clocks. A successful snapshot serves age_ms ≈ 0 → the UI clears the
|
||||||
|
// "reconnecting…" banner on its very next poll regardless of browser-clock skew.
|
||||||
|
if status.updated_at_ms > 0 {
|
||||||
|
status.age_ms = now_ms().saturating_sub(status.updated_at_ms);
|
||||||
|
}
|
||||||
|
status
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
||||||
|
// 12s (not 8s): on a swap-thrashing node getblockchaininfo can answer slowly
|
||||||
|
// but correctly; too tight a timeout turned working-but-slow polls into
|
||||||
|
// failures and tripped the "reconnecting…" banner. Stays under STALE_GRACE_MS.
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(20))
|
.timeout(Duration::from_secs(12))
|
||||||
.build()
|
.build()
|
||||||
.context("build Bitcoin status HTTP client")?;
|
.context("build Bitcoin status HTTP client")?;
|
||||||
|
|
||||||
let blockchain_info = bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([]))
|
// Fetch all four calls concurrently: getblockchaininfo gates freshness, so a
|
||||||
.await
|
// slow auxiliary call (network/index/zmq) must not delay the snapshot or block
|
||||||
.context("getblockchaininfo")?;
|
// the next refresh. Only getblockchaininfo failing marks the status stale.
|
||||||
let network_info = bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([]))
|
let (blockchain_info, network_info, index_info, zmq_notifications) = tokio::join!(
|
||||||
.await
|
bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([])),
|
||||||
.context("getnetworkinfo")
|
bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([])),
|
||||||
.ok();
|
bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([])),
|
||||||
let index_info = bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([]))
|
bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([])),
|
||||||
.await
|
);
|
||||||
.context("getindexinfo")
|
let blockchain_info = blockchain_info.context("getblockchaininfo")?;
|
||||||
.ok();
|
|
||||||
let zmq_notifications = bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([]))
|
|
||||||
.await
|
|
||||||
.context("getzmqnotifications")
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
Ok(BitcoinNodeStatus {
|
Ok(BitcoinNodeStatus {
|
||||||
ok: true,
|
ok: true,
|
||||||
stale: false,
|
stale: false,
|
||||||
updated_at_ms: now_ms(),
|
updated_at_ms: now_ms(),
|
||||||
|
age_ms: 0,
|
||||||
error: None,
|
error: None,
|
||||||
blockchain_info: Some(blockchain_info),
|
blockchain_info: Some(blockchain_info),
|
||||||
network_info,
|
network_info: network_info.ok(),
|
||||||
index_info,
|
index_info: index_info.ok(),
|
||||||
zmq_notifications,
|
zmq_notifications: zmq_notifications.ok(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -66,7 +66,7 @@ pub struct Config {
|
|||||||
/// through Quadlet (`.container` units in ~/.config/containers/systemd
|
/// through Quadlet (`.container` units in ~/.config/containers/systemd
|
||||||
/// + systemctl --user start) instead of `podman create + start`. Default
|
/// + systemctl --user start) instead of `podman create + start`. Default
|
||||||
/// off so the legacy path stays the production path until the harness
|
/// off so the legacy path stays the production path until the harness
|
||||||
/// at tests/lifecycle/run-20x.sh has gone green against the new path
|
/// at tests/lifecycle/run-gate.sh has gone green against the new path
|
||||||
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
|
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub use_quadlet_backends: bool,
|
pub use_quadlet_backends: bool,
|
||||||
@ -487,7 +487,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_config_use_quadlet_backends_defaults_off() {
|
fn test_config_use_quadlet_backends_defaults_off() {
|
||||||
// Phase 3.2 of v1.7.52 — the new path stays gated until the 20×
|
// Phase 3.2 of v1.7.52 — the new path stays gated until the 5×
|
||||||
// harness goes green on .228 and .198. Flipping this default
|
// harness goes green on .228 and .198. Flipping this default
|
||||||
// ahead of that would route every backend install through code
|
// ahead of that would route every backend install through code
|
||||||
// we haven't fleet-validated yet.
|
// we haven't fleet-validated yet.
|
||||||
|
|||||||
@ -86,6 +86,44 @@ pub struct AppCatalogEntry {
|
|||||||
/// Optional human-readable changelog lines for this version.
|
/// Optional human-readable changelog lines for this version.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub changelog: Vec<String>,
|
pub changelog: Vec<String>,
|
||||||
|
/// Multi-version support (`docs/bitcoin-multi-version-design.md`): the bounded
|
||||||
|
/// set of versions a user may install or switch to for this app. Empty for
|
||||||
|
/// single-version apps; `version`/`image` above remain the default/latest for
|
||||||
|
/// back-compat. Old nodes ignore this field (no `deny_unknown_fields`).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub versions: Vec<CatalogVersion>,
|
||||||
|
/// Full app manifest, embedded so the app installs from the registry alone —
|
||||||
|
/// no OTA-shipped `apps/<id>/manifest.yml`. Carried as the raw value the
|
||||||
|
/// publisher signed (so it stays part of the verified preimage) and
|
||||||
|
/// deserialized into an `AppManifest` by the orchestrator at load time, where
|
||||||
|
/// it overrides the disk manifest (origin-wins). Absent during the migration
|
||||||
|
/// window => the node falls back to the disk manifest. See
|
||||||
|
/// `docs/registry-manifest-design.md`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub manifest: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One selectable version in an app's `versions[]` list. The catalog carries a
|
||||||
|
/// curated, bounded set (current + a few majors back); see
|
||||||
|
/// `docs/bitcoin-multi-version-design.md` §3 Phase 1.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||||
|
pub struct CatalogVersion {
|
||||||
|
/// User-facing + tag-matching version string (e.g. `31.0`,
|
||||||
|
/// `29.3.knots20260508`). Treated as the image tag.
|
||||||
|
pub version: String,
|
||||||
|
/// Concrete image reference for this version. When omitted the orchestrator
|
||||||
|
/// falls back to composing `<default-repo>:<version>` from the entry image.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub image: Option<String>,
|
||||||
|
/// Marks the default / latest version pre-selected in the install modal.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub default: bool,
|
||||||
|
/// Deprecated versions are still installable but badged in the UI.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub deprecated: bool,
|
||||||
|
/// Optional end-of-life date (YYYY-MM-DD), surfaced in the UI.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub eol: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-side cache file search order. Mirrors `image_versions.rs`: the running
|
/// Read-side cache file search order. Mirrors `image_versions.rs`: the running
|
||||||
@ -166,6 +204,78 @@ pub fn catalog_stack_images(app_id: &str) -> HashMap<String, String> {
|
|||||||
entry_for(app_id).and_then(|e| e.images).unwrap_or_default()
|
entry_for(app_id).and_then(|e| e.images).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All `(app_id, manifest-value)` pairs the registry catalog carries. The
|
||||||
|
/// orchestrator deserializes + validates each into an `AppManifest` and prefers
|
||||||
|
/// it over the disk manifest (origin-wins); disk remains the migration fallback.
|
||||||
|
/// Empty when the catalog is absent or no entry embeds a manifest.
|
||||||
|
pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
|
||||||
|
load_catalog()
|
||||||
|
.apps
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(id, e)| e.manifest.map(|m| (id, m)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The catalog's default/latest version string for an app (the top-level
|
||||||
|
/// `version` field), if covered. Used to decide whether an install-time
|
||||||
|
/// selection should pin (older) or track-latest (default).
|
||||||
|
pub fn catalog_default_version(app_id: &str) -> Option<String> {
|
||||||
|
entry_for(app_id)
|
||||||
|
.map(|e| e.version)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Curated, selectable versions for an app per the remote catalog. Empty when
|
||||||
|
/// the catalog is absent or the app is single-version. The default entry (if
|
||||||
|
/// any) sorts first so callers can pre-select it.
|
||||||
|
pub fn catalog_versions(app_id: &str) -> Vec<CatalogVersion> {
|
||||||
|
let mut versions = entry_for(app_id).map(|e| e.versions).unwrap_or_default();
|
||||||
|
versions.sort_by_key(|v| !v.default); // default first, stable otherwise
|
||||||
|
versions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the image for a specific selectable `version` of `app_id`, validated
|
||||||
|
/// same-repo against `manifest_image` (the same guard `catalog_image_override`
|
||||||
|
/// applies). The version's explicit `image` is used when present; otherwise the
|
||||||
|
/// repo of `manifest_image` is retagged with `version`. Returns `None` when the
|
||||||
|
/// version is unknown or would point at a different repository — the caller then
|
||||||
|
/// keeps the default resolution and the switch is refused upstream.
|
||||||
|
pub fn catalog_image_for_version(
|
||||||
|
app_id: &str,
|
||||||
|
version: &str,
|
||||||
|
manifest_image: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let entry = catalog_versions(app_id)
|
||||||
|
.into_iter()
|
||||||
|
.find(|v| v.version == version)?;
|
||||||
|
let manifest_repo =
|
||||||
|
crate::container::image_versions::image_without_registry_or_tag(manifest_image);
|
||||||
|
let candidate = match entry.image {
|
||||||
|
Some(img) => img,
|
||||||
|
None => {
|
||||||
|
// Retag the manifest's full registry/repo with the requested version.
|
||||||
|
let repo = manifest_image
|
||||||
|
.rsplit_once(':')
|
||||||
|
// keep registry:port colons intact: only strip a tag after the last '/'
|
||||||
|
.filter(|(left, _)| left.contains('/'))
|
||||||
|
.map(|(left, _)| left)
|
||||||
|
.unwrap_or(manifest_image);
|
||||||
|
format!("{repo}:{version}")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let same_repo = crate::container::image_versions::image_without_registry_or_tag(&candidate)
|
||||||
|
== manifest_repo;
|
||||||
|
if same_repo {
|
||||||
|
Some(candidate)
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"app-catalog: ignoring version {} for {} — repo mismatch (candidate={}, manifest={})",
|
||||||
|
version, app_id, candidate, manifest_image
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Image override for the orchestrator's install/upgrade path. Returns the
|
/// Image override for the orchestrator's install/upgrade path. Returns the
|
||||||
/// catalog's primary image for `app_id` ONLY when it refers to the same
|
/// catalog's primary image for `app_id` ONLY when it refers to the same
|
||||||
/// repository as the manifest's current image — a guard so a catalog typo can
|
/// repository as the manifest's current image — a guard so a catalog typo can
|
||||||
@ -193,6 +303,12 @@ pub fn catalog_image_override(app_id: &str, manifest_image: &str) -> Option<Stri
|
|||||||
/// newer catalog, nor vice-versa). Falls back to the deployed pin only when the
|
/// newer catalog, nor vice-versa). Falls back to the deployed pin only when the
|
||||||
/// catalog is missing or doesn't cover the app.
|
/// catalog is missing or doesn't cover the app.
|
||||||
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
|
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
|
||||||
|
// A runner-pinned version is an explicit "stay here" choice — never advertise
|
||||||
|
// an update over it (design §3 Phase 3). Auto-update, when enabled, ignores
|
||||||
|
// the pin and is driven by the catalog tick, not this badge.
|
||||||
|
if crate::container::version_config::pinned_version(app_id).is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
||||||
// Catalog covers this app with a concrete image -> authoritative.
|
// Catalog covers this app with a concrete image -> authoritative.
|
||||||
return crate::container::image_versions::available_update_for_images(
|
return crate::container::image_versions::available_update_for_images(
|
||||||
@ -346,6 +462,30 @@ mod tests {
|
|||||||
assert_eq!(e.digest.as_deref(), Some("blake3:deadbeef"));
|
assert_eq!(e.digest.as_deref(), Some("blake3:deadbeef"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entry_carries_embedded_manifest() {
|
||||||
|
let json = r#"{
|
||||||
|
"schema": 1,
|
||||||
|
"apps": {
|
||||||
|
"demo": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"manifest": {
|
||||||
|
"app": {
|
||||||
|
"id": "demo",
|
||||||
|
"name": "Demo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"container": { "image": "registry/demo:1.0.0" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let cat: AppCatalog = serde_json::from_str(json).unwrap();
|
||||||
|
let e = cat.apps.get("demo").unwrap();
|
||||||
|
let m = e.manifest.as_ref().expect("manifest present");
|
||||||
|
assert_eq!(m["app"]["id"], "demo");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_catalog_when_absent_is_default() {
|
fn empty_catalog_when_absent_is_default() {
|
||||||
let cat = AppCatalog::default();
|
let cat = AppCatalog::default();
|
||||||
|
|||||||
@ -96,6 +96,35 @@ impl BootReconciler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Companion self-heal runs on its OWN cadence, decoupled from the
|
||||||
|
// per-app reconcile pass. On a heavily loaded node `reconcile_existing`
|
||||||
|
// over dozens of apps can take well over a minute, which would delay a
|
||||||
|
// companion-unit repair (deleted/lost unit file) past any reasonable
|
||||||
|
// safety window. Detecting + rewriting a companion unit is cheap, so it
|
||||||
|
// gets a dedicated `interval` loop. The handle is aborted when the main
|
||||||
|
// loop exits (shutdown uses `notify_one`, so we must NOT add a second
|
||||||
|
// waiter on `self.shutdown` — it would steal the single wake permit).
|
||||||
|
let companion_handle = if self.companion_stage {
|
||||||
|
let orchestrator = self.orchestrator.clone();
|
||||||
|
let interval = self.interval;
|
||||||
|
Some(tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let installed = orchestrator.manifest_ids().await;
|
||||||
|
for (companion, err) in crate::container::companion::reconcile(&installed).await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
companion = %companion,
|
||||||
|
error = %err,
|
||||||
|
"companion reconcile failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
time::sleep(interval).await;
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Initial pass: no delay.
|
// Initial pass: no delay.
|
||||||
self.tick().await;
|
self.tick().await;
|
||||||
|
|
||||||
@ -111,23 +140,15 @@ impl BootReconciler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(handle) = companion_handle {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn tick(&self) {
|
async fn tick(&self) {
|
||||||
let report = self.orchestrator.reconcile_existing().await;
|
let report = self.orchestrator.reconcile_existing().await;
|
||||||
Self::log_report(&report);
|
Self::log_report(&report);
|
||||||
|
|
||||||
if !self.companion_stage {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let installed = self.orchestrator.manifest_ids().await;
|
|
||||||
for (companion, err) in crate::container::companion::reconcile(&installed).await {
|
|
||||||
tracing::warn!(
|
|
||||||
companion = %companion,
|
|
||||||
error = %err,
|
|
||||||
"companion reconcile failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn log_report(report: &ReconcileReport) {
|
fn log_report(report: &ReconcileReport) {
|
||||||
@ -273,7 +294,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_status_calls(rt: &CountingRuntime, expected: u32) -> u32 {
|
async fn wait_for_status_calls(rt: &CountingRuntime, expected: u32) -> u32 {
|
||||||
for _ in 0..100 {
|
for _ in 0..1000 {
|
||||||
let count = rt.status_call_count();
|
let count = rt.status_call_count();
|
||||||
if count >= expected {
|
if count >= expected {
|
||||||
return count;
|
return count;
|
||||||
@ -320,11 +341,10 @@ mod tests {
|
|||||||
assert_eq!(wait_for_status_calls(&rt, 1).await, 1);
|
assert_eq!(wait_for_status_calls(&rt, 1).await, 1);
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
wait_for_status_calls(&rt, 2).await;
|
let count = wait_for_status_calls(&rt, 2).await;
|
||||||
|
|
||||||
assert_eq!(
|
assert!(
|
||||||
rt.status_call_count(),
|
count >= 2,
|
||||||
2,
|
|
||||||
"a second reconcile pass should fire after one interval"
|
"a second reconcile pass should fire after one interval"
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -382,9 +402,7 @@ mod tests {
|
|||||||
assert!(first >= 1, "initial pass should have touched the runtime");
|
assert!(first >= 1, "initial pass should have touched the runtime");
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
tokio::task::yield_now().await;
|
let second = wait_for_status_calls(&rt, first + 1).await;
|
||||||
tokio::task::yield_now().await;
|
|
||||||
let second = rt.status_call_count();
|
|
||||||
assert!(
|
assert!(
|
||||||
second > first,
|
second > first,
|
||||||
"loop should have fired a second pass after the interval"
|
"loop should have fired a second pass after the interval"
|
||||||
|
|||||||
@ -102,8 +102,15 @@ const LND_UI: &[CompanionSpec] = &[CompanionSpec {
|
|||||||
],
|
],
|
||||||
pre_start: None,
|
pre_start: None,
|
||||||
bind_mounts: &[],
|
bind_mounts: &[],
|
||||||
ports: &[(18083, 80)],
|
// Host networking so the app's own nginx can proxy the archipelago backend
|
||||||
host_network: false,
|
// same-origin (127.0.0.1:5678), exactly like fips-ui / electrs-ui. The
|
||||||
|
// previous bridge + 18083→80 mapping forced the browser to fetch the
|
||||||
|
// backend cross-origin from the app's port, which depended on the host
|
||||||
|
// nginx route + a CORS Origin/Host match and broke on http-only nodes
|
||||||
|
// (e.g. .116: blank fields, QR "failed to fetch"). The app's nginx now
|
||||||
|
// listens on 18083 directly (NOT 80 — that would collide with host nginx).
|
||||||
|
ports: &[],
|
||||||
|
host_network: true,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
|
const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
|
||||||
@ -214,13 +221,26 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
|||||||
for dir in spec.build_dir_candidates {
|
for dir in spec.build_dir_candidates {
|
||||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||||
|
// `:local` is a deliberate manual override — never auto-rebuild it.
|
||||||
if image_exists(&local_image_compat).await {
|
if image_exists(&local_image_compat).await {
|
||||||
return Ok(local_image_compat);
|
return Ok(local_image_compat);
|
||||||
}
|
}
|
||||||
|
// Reuse the auto-built `:latest` only when the build context has NOT
|
||||||
|
// changed since it was built. Without this staleness check an
|
||||||
|
// already-present image is reused forever, so edits to the baked-in
|
||||||
|
// context (Dockerfile, nginx.conf, …) never reach the node — this is
|
||||||
|
// exactly why the guardian-CSS nginx fix never reached the fleet.
|
||||||
if image_exists(&local_image).await {
|
if image_exists(&local_image).await {
|
||||||
return Ok(local_image);
|
if !context_is_newer_than_image(dir, &local_image).await {
|
||||||
|
return Ok(local_image);
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
companion = spec.name,
|
||||||
|
"build context changed since image built; rebuilding {dir}"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info!(companion = spec.name, "building locally from {dir}");
|
||||||
}
|
}
|
||||||
info!(companion = spec.name, "building locally from {dir}");
|
|
||||||
let out = command_output_with_timeout(
|
let out = command_output_with_timeout(
|
||||||
Command::new("podman").args(["build", "-t", &local_image, dir]),
|
Command::new("podman").args(["build", "-t", &local_image, dir]),
|
||||||
COMPANION_BUILD_TIMEOUT,
|
COMPANION_BUILD_TIMEOUT,
|
||||||
@ -265,7 +285,15 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
|||||||
|
|
||||||
async fn image_exists(image: &str) -> bool {
|
async fn image_exists(image: &str) -> bool {
|
||||||
let mut cmd = Command::new("podman");
|
let mut cmd = Command::new("podman");
|
||||||
cmd.args(["image", "inspect", image]);
|
// Only the exit status matters. WITHOUT a `--format`, `podman image inspect`
|
||||||
|
// prints the image's full multi-KB manifest JSON; `.status()` inherits the
|
||||||
|
// service's stdout, so on a hit that whole blob lands in the journal — once
|
||||||
|
// per companion image, every reconcile pass. That flood spikes journald +
|
||||||
|
// IO and starves the async runtime (UI websocket then drops → "connection
|
||||||
|
// lost"/reconnect). Discard the child's stdout/stderr; we read neither.
|
||||||
|
cmd.args(["image", "inspect", image])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null());
|
||||||
match tokio::time::timeout(COMPANION_IMAGE_CHECK_TIMEOUT, cmd.status()).await {
|
match tokio::time::timeout(COMPANION_IMAGE_CHECK_TIMEOUT, cmd.status()).await {
|
||||||
Ok(Ok(status)) => status.success(),
|
Ok(Ok(status)) => status.success(),
|
||||||
Ok(Err(err)) => {
|
Ok(Err(err)) => {
|
||||||
@ -279,6 +307,76 @@ async fn image_exists(image: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if any file in the build context `dir` is newer than the
|
||||||
|
/// already-built `image`, signalling the cached image is stale and must be
|
||||||
|
/// rebuilt. Conservative: if either timestamp can't be determined we return
|
||||||
|
/// false (reuse the cache) to avoid rebuild storms on every reconcile pass.
|
||||||
|
async fn context_is_newer_than_image(dir: &str, image: &str) -> bool {
|
||||||
|
let image_created = match image_created_unix(image).await {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
match newest_mtime_unix(PathBuf::from(dir)).await {
|
||||||
|
Some(ctx) => ctx > image_created,
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build timestamp of `image` as Unix seconds, via `podman image inspect`.
|
||||||
|
async fn image_created_unix(image: &str) -> Option<i64> {
|
||||||
|
let mut cmd = Command::new("podman");
|
||||||
|
cmd.args(["image", "inspect", "--format", "{{.Created.Unix}}", image]);
|
||||||
|
let out = command_output_with_timeout(
|
||||||
|
&mut cmd,
|
||||||
|
COMPANION_IMAGE_CHECK_TIMEOUT,
|
||||||
|
"podman image created time",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.trim()
|
||||||
|
.parse::<i64>()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newest modification time (Unix seconds) across all files under `dir`,
|
||||||
|
/// walked recursively. Runs on a blocking thread since it touches the fs.
|
||||||
|
async fn newest_mtime_unix(dir: PathBuf) -> Option<i64> {
|
||||||
|
tokio::task::spawn_blocking(move || newest_mtime_blocking(&dir))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn newest_mtime_blocking(dir: &std::path::Path) -> Option<i64> {
|
||||||
|
let mut newest: Option<i64> = None;
|
||||||
|
let mut stack = vec![dir.to_path_buf()];
|
||||||
|
while let Some(p) = stack.pop() {
|
||||||
|
let entries = match std::fs::read_dir(&p) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let meta = match entry.metadata() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if meta.is_dir() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
} else if let Ok(modified) = meta.modified() {
|
||||||
|
if let Ok(dur) = modified.duration_since(std::time::UNIX_EPOCH) {
|
||||||
|
let secs = dur.as_secs() as i64;
|
||||||
|
newest = Some(newest.map_or(secs, |n| n.max(secs)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newest
|
||||||
|
}
|
||||||
|
|
||||||
async fn command_output_with_timeout(
|
async fn command_output_with_timeout(
|
||||||
cmd: &mut Command,
|
cmd: &mut Command,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
@ -439,12 +537,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lnd_ui_uses_port_mapping_not_host_port_80() {
|
fn lnd_ui_uses_host_network_for_same_origin_backend_proxy() {
|
||||||
|
// lnd-ui is host-networked (its nginx listens on 18083 directly) so the
|
||||||
|
// app can proxy the archipelago backend same-origin instead of fetching
|
||||||
|
// it cross-origin from its app port — see the spec comment for why.
|
||||||
let spec = &LND_UI[0];
|
let spec = &LND_UI[0];
|
||||||
let u = build_unit(spec, "localhost/lnd-ui:latest");
|
let u = build_unit(spec, "localhost/lnd-ui:latest");
|
||||||
assert_eq!(u.name, "archy-lnd-ui");
|
assert_eq!(u.name, "archy-lnd-ui");
|
||||||
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "bridge"));
|
assert!(matches!(u.network, NetworkMode::Host));
|
||||||
assert_eq!(u.ports, vec![(18083, 80, "tcp".into())]);
|
assert!(u.ports.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -365,6 +365,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
|||||||
repo: "https://github.com/fedimint/fedimint".to_string(),
|
repo: "https://github.com/fedimint/fedimint".to_string(),
|
||||||
tier: "",
|
tier: "",
|
||||||
},
|
},
|
||||||
|
"fedimint-clientd" | "fmcd" => AppMetadata {
|
||||||
|
title: "Fedimint Client".to_string(),
|
||||||
|
description: "Fedimint ecash client daemon (fmcd) — lets your node hold Fedimint ecash and join federations".to_string(),
|
||||||
|
icon: "/assets/img/app-icons/fedimint.png".to_string(),
|
||||||
|
repo: "https://github.com/minmoto/fmcd".to_string(),
|
||||||
|
tier: "",
|
||||||
|
},
|
||||||
"morphos" | "morphos-server" => AppMetadata {
|
"morphos" | "morphos-server" => AppMetadata {
|
||||||
title: "Morphos".to_string(),
|
title: "Morphos".to_string(),
|
||||||
description: "Self-hosted file converter".to_string(),
|
description: "Self-hosted file converter".to_string(),
|
||||||
@ -684,16 +691,37 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// netbird's dashboard launch URL: HTTPS on 8087 (the proxy terminates TLS —
|
||||||
|
/// the dashboard needs a secure context for OIDC PKCE, issue #15) at the node's
|
||||||
|
/// primary host IP so it's reachable from the LAN. Manifest-driven netbird no
|
||||||
|
/// longer writes `dashboard.env`, so this is derived from host facts (the same
|
||||||
|
/// `{{HOST_IP}}` the orchestrator bakes into the cert/config); it falls back to
|
||||||
|
/// the static localhost mapping when the host IP can't be read. URL shape is
|
||||||
|
/// identical to the legacy installer's, so the existing https reachability
|
||||||
|
/// wrapper still applies.
|
||||||
async fn netbird_configured_launch_url() -> Option<String> {
|
async fn netbird_configured_launch_url() -> Option<String> {
|
||||||
let env = tokio::fs::read_to_string("/var/lib/archipelago/netbird/dashboard.env")
|
if let Some(ip) = first_host_ip().await {
|
||||||
|
return Some(format!("https://{ip}:8087"));
|
||||||
|
}
|
||||||
|
PodmanClient::lan_address_for("netbird")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First address from `hostname -I` — the node's primary host IP. Mirrors the
|
||||||
|
/// orchestrator's `detect_host_ip` so launch URLs match the cert/config the
|
||||||
|
/// orchestrator renders for `{{HOST_IP}}`.
|
||||||
|
async fn first_host_ip() -> Option<String> {
|
||||||
|
let out = tokio::process::Command::new("hostname")
|
||||||
|
.arg("-I")
|
||||||
|
.output()
|
||||||
.await
|
.await
|
||||||
.ok()?;
|
.ok()?;
|
||||||
env.lines()
|
if !out.status.success() {
|
||||||
.find_map(|line| line.strip_prefix("NETBIRD_MGMT_API_ENDPOINT="))
|
return None;
|
||||||
.map(str::trim)
|
}
|
||||||
.filter(|s| !s.is_empty())
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.or_else(|| PodmanClient::lan_address_for("netbird"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
|
async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Option<String> {
|
||||||
|
|||||||
198
core/archipelago/src/container/hooks.rs
Normal file
198
core/archipelago/src/container/hooks.rs
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
//! Manifest-driven lifecycle hook executor (Task #20).
|
||||||
|
//!
|
||||||
|
//! Runs an app's declarative `post_install` hooks against its **own** running
|
||||||
|
//! container. Hooks are an allowlisted, reviewed escape hatch — NOT arbitrary
|
||||||
|
//! host scripts:
|
||||||
|
//!
|
||||||
|
//! - `exec` runs *inside the container* (`podman exec`), never on the host, and
|
||||||
|
//! inherits the container's (already dropped) capabilities.
|
||||||
|
//! - `copy_from_host.src` is resolved against an allowlist root, canonicalised,
|
||||||
|
//! and rejected on any escape; only then is it `podman cp`'d into the container.
|
||||||
|
//! - Execution is **best-effort + idempotent**: each step is logged, a failure is
|
||||||
|
//! warned and the remaining steps still run, so a transient hook error never
|
||||||
|
//! bricks an install. Authors must make steps safe to re-run (e.g. `grep -q … ||`).
|
||||||
|
//!
|
||||||
|
//! See `docs/manifest-hooks-design.md`.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use archipelago_container::{AppManifest, HookStep};
|
||||||
|
|
||||||
|
/// Upper bound on a single hook command. Generous — config rewrites + nginx
|
||||||
|
/// reloads are fast, but an image with a hung entrypoint shouldn't wedge install.
|
||||||
|
const HOOK_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// Roots a `copy_from_host.src` may resolve within. A src is joined onto each
|
||||||
|
/// root, canonicalised, and accepted only if it stays inside that root:
|
||||||
|
/// - the app's own data dir (`<data_dir>/<app_id>`), and
|
||||||
|
/// - `/opt/archipelago` (covers the orchestrator's bundled `web-ui/` assets,
|
||||||
|
/// e.g. indeedhub's `web-ui/nostr-provider.js`).
|
||||||
|
fn allowlist_roots(app_id: &str, data_dir: &Path) -> Vec<PathBuf> {
|
||||||
|
vec![data_dir.join(app_id), PathBuf::from("/opt/archipelago")]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a hook copy source against the allowlist. Returns the canonical
|
||||||
|
/// absolute path iff it exists and lies within an allowlist root. Defence in
|
||||||
|
/// depth: `AppManifest::validate` already rejects absolute / `..` srcs, but we
|
||||||
|
/// re-check here and canonicalise so a symlink inside a root can't escape it.
|
||||||
|
fn resolve_copy_src(src: &str, app_id: &str, data_dir: &Path) -> Result<PathBuf> {
|
||||||
|
if src.is_empty() || src.starts_with('/') || src.contains("..") {
|
||||||
|
bail!("hook copy src '{src}' is not an allowlisted relative path");
|
||||||
|
}
|
||||||
|
for root in allowlist_roots(app_id, data_dir) {
|
||||||
|
let Ok(root_canon) = root.canonicalize() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(canon) = root.join(src).canonicalize() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if canon.starts_with(&root_canon) {
|
||||||
|
return Ok(canon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("hook copy src '{src}' did not resolve inside an allowlist root")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run an app's declarative `post_install` hooks against its running container.
|
||||||
|
/// Best-effort: never returns an error — a failed step is warned and skipped.
|
||||||
|
/// Called from the install path after the container is created + running, and
|
||||||
|
/// only when a fresh container was created (see `install_fresh`).
|
||||||
|
pub async fn run_post_install(manifest: &AppManifest, container_name: &str, data_dir: &Path) {
|
||||||
|
let steps = &manifest.app.hooks.post_install;
|
||||||
|
if steps.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let app_id = &manifest.app.id;
|
||||||
|
tracing::info!(
|
||||||
|
app_id = %app_id,
|
||||||
|
container = %container_name,
|
||||||
|
steps = steps.len(),
|
||||||
|
"running manifest post_install hooks"
|
||||||
|
);
|
||||||
|
for (i, step) in steps.iter().enumerate() {
|
||||||
|
match run_step(step, container_name, app_id, data_dir).await {
|
||||||
|
Ok(()) => tracing::debug!(app_id = %app_id, step = i, "post_install hook step ok"),
|
||||||
|
Err(err) => tracing::warn!(
|
||||||
|
app_id = %app_id,
|
||||||
|
container = %container_name,
|
||||||
|
step = i,
|
||||||
|
error = %err,
|
||||||
|
"post_install hook step failed (continuing best-effort)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_step(step: &HookStep, container: &str, app_id: &str, data_dir: &Path) -> Result<()> {
|
||||||
|
match step {
|
||||||
|
HookStep::Exec { exec } => {
|
||||||
|
let mut args: Vec<&str> = Vec::with_capacity(exec.len() + 2);
|
||||||
|
args.push("exec");
|
||||||
|
args.push(container);
|
||||||
|
args.extend(exec.iter().map(String::as_str));
|
||||||
|
// `exec` spawns a process INSIDE the container's cgroup. When the
|
||||||
|
// container was started by archipelago.service, that cgroup is under
|
||||||
|
// the service's slice and a bare `podman exec` from the service can't
|
||||||
|
// write its `cgroup.procs` ("crun: ... Permission denied / OCI
|
||||||
|
// permission denied"). Run it in a transient user scope (its own
|
||||||
|
// delegated cgroup) — mirrors `podman_user_scope` for pasta starts.
|
||||||
|
run_podman(&args, /* scoped */ true).await
|
||||||
|
}
|
||||||
|
HookStep::CopyFromHost { copy_from_host } => {
|
||||||
|
let abs = resolve_copy_src(©_from_host.src, app_id, data_dir)?;
|
||||||
|
let abs = abs.to_string_lossy().into_owned();
|
||||||
|
let dest = format!("{container}:{}", copy_from_host.dest);
|
||||||
|
// `cp` is a host-side copy (no in-container process), so no scope needed.
|
||||||
|
run_podman(&["cp", &abs, &dest], /* scoped */ false).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a podman command, optionally inside a transient systemd user scope. The
|
||||||
|
/// scope gives the invocation its own delegated cgroup so `podman exec` can
|
||||||
|
/// place its child process — without it, an exec launched from the service's
|
||||||
|
/// own cgroup is denied write to the container's `cgroup.procs`.
|
||||||
|
async fn run_podman(args: &[&str], scoped: bool) -> Result<()> {
|
||||||
|
let rendered = args.join(" ");
|
||||||
|
let mut cmd = if scoped {
|
||||||
|
let mut c = tokio::process::Command::new("systemd-run");
|
||||||
|
c.args(["--user", "--scope", "--quiet", "--collect", "podman"]);
|
||||||
|
c.args(args);
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
let mut c = tokio::process::Command::new("podman");
|
||||||
|
c.args(args);
|
||||||
|
c
|
||||||
|
};
|
||||||
|
let out = tokio::time::timeout(HOOK_TIMEOUT, cmd.output())
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("podman {rendered} timed out after {:?}", HOOK_TIMEOUT))?
|
||||||
|
.map_err(|e| anyhow::anyhow!("podman {rendered}: {e}"))?;
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!(
|
||||||
|
"podman {rendered} exited {}: {}",
|
||||||
|
out.status,
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_copy_src_accepts_file_in_app_data_dir() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let data_dir = tmp.path();
|
||||||
|
let app_dir = data_dir.join("myapp/web-ui");
|
||||||
|
std::fs::create_dir_all(&app_dir).unwrap();
|
||||||
|
std::fs::write(app_dir.join("provider.js"), b"x").unwrap();
|
||||||
|
|
||||||
|
let got = resolve_copy_src("web-ui/provider.js", "myapp", data_dir).unwrap();
|
||||||
|
assert!(got.ends_with("myapp/web-ui/provider.js"));
|
||||||
|
assert!(got.is_absolute());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_copy_src_rejects_absolute() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
assert!(resolve_copy_src("/etc/passwd", "myapp", tmp.path()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_copy_src_rejects_traversal() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
assert!(resolve_copy_src("web-ui/../../etc/shadow", "myapp", tmp.path()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_copy_src_rejects_missing_file() {
|
||||||
|
// Inside the allowlist shape but the file doesn't exist → canonicalize fails.
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(tmp.path().join("myapp")).unwrap();
|
||||||
|
assert!(resolve_copy_src("nope.js", "myapp", tmp.path()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_copy_src_rejects_symlink_escape() {
|
||||||
|
// A symlink inside the app dir pointing outside it must be rejected by
|
||||||
|
// the post-canonicalisation prefix check.
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let app_dir = tmp.path().join("myapp");
|
||||||
|
std::fs::create_dir_all(&app_dir).unwrap();
|
||||||
|
let secret = tmp.path().join("secret.txt");
|
||||||
|
std::fs::write(&secret, b"s").unwrap();
|
||||||
|
let link = app_dir.join("link.js");
|
||||||
|
if std::os::unix::fs::symlink(&secret, &link).is_ok() {
|
||||||
|
// `secret.txt` lives in the tmp root, NOT under <data_dir>/myapp, so
|
||||||
|
// the canonical target escapes the app-data root. It also isn't under
|
||||||
|
// /opt/archipelago. Must be rejected.
|
||||||
|
assert!(resolve_copy_src("link.js", "myapp", tmp.path()).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,12 +6,15 @@ pub mod data_manager;
|
|||||||
pub mod dev_orchestrator;
|
pub mod dev_orchestrator;
|
||||||
pub mod docker_packages;
|
pub mod docker_packages;
|
||||||
pub mod filebrowser;
|
pub mod filebrowser;
|
||||||
|
pub mod hooks;
|
||||||
pub mod image_versions;
|
pub mod image_versions;
|
||||||
pub mod lnd;
|
pub mod lnd;
|
||||||
pub mod prod_orchestrator;
|
pub mod prod_orchestrator;
|
||||||
pub mod quadlet;
|
pub mod quadlet;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
|
pub mod secrets;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
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};
|
||||||
pub use dev_orchestrator::DevContainerOrchestrator;
|
pub use dev_orchestrator::DevContainerOrchestrator;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -227,13 +227,20 @@ impl QuadletUnit {
|
|||||||
mode
|
mode
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (host, container, proto) in &self.ports {
|
// Host networking exposes the container's ports on the host directly.
|
||||||
let p = if proto.is_empty() {
|
// Podman rejects PublishPort combined with Network=host ("published
|
||||||
"tcp"
|
// ports cannot be used with host network") and the unit crash-loops
|
||||||
} else {
|
// (exit 125). Skip publishing in host mode — matches the NetworkMode
|
||||||
proto.as_str()
|
// doc note that Podman discards port mappings under host networking.
|
||||||
};
|
if !matches!(self.network, NetworkMode::Host) {
|
||||||
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
|
for (host, container, proto) in &self.ports {
|
||||||
|
let p = if proto.is_empty() {
|
||||||
|
"tcp"
|
||||||
|
} else {
|
||||||
|
proto.as_str()
|
||||||
|
};
|
||||||
|
let _ = writeln!(s, "PublishPort={host}:{container}/{p}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for env in &self.environment {
|
for env in &self.environment {
|
||||||
// env entries already arrive shaped as "KEY=VALUE"; quadlet
|
// env entries already arrive shaped as "KEY=VALUE"; quadlet
|
||||||
@ -261,14 +268,21 @@ impl QuadletUnit {
|
|||||||
let _ = writeln!(s, "HealthTimeout={}", h.timeout);
|
let _ = writeln!(s, "HealthTimeout={}", h.timeout);
|
||||||
let _ = writeln!(s, "HealthRetries={}", h.retries);
|
let _ = writeln!(s, "HealthRetries={}", h.retries);
|
||||||
}
|
}
|
||||||
if let Some(ep) = &self.entrypoint {
|
if let Some((first, rest)) = self.entrypoint.as_deref().and_then(<[String]>::split_first) {
|
||||||
// Quadlet's Exec= replaces the image entrypoint+cmd. When
|
// Quadlet's Exec= sets only the command (the args passed to the
|
||||||
// the manifest provides both entrypoint and command we
|
// image's ENTRYPOINT) — it does NOT replace the entrypoint. So a
|
||||||
// concatenate; if only command is set we'll emit that on
|
// manifest entrypoint like `sh -lc` must be emitted as a real
|
||||||
// its own below.
|
// Entrypoint= override; otherwise it gets appended to whatever
|
||||||
let mut parts: Vec<String> = ep.clone();
|
// ENTRYPOINT the image baked in (e.g. the versioned bitcoind
|
||||||
|
// images use `ENTRYPOINT ["bitcoind"]`, which turned the wrapper
|
||||||
|
// into `bitcoind sh -lc ...` and crash-looped). Emitting
|
||||||
|
// Entrypoint= makes the unit independent of the image's entrypoint.
|
||||||
|
let _ = writeln!(s, "Entrypoint={first}");
|
||||||
|
let mut parts: Vec<String> = rest.to_vec();
|
||||||
parts.extend(self.command.iter().cloned());
|
parts.extend(self.command.iter().cloned());
|
||||||
let _ = writeln!(s, "Exec={}", shell_join(&parts));
|
if !parts.is_empty() {
|
||||||
|
let _ = writeln!(s, "Exec={}", shell_join(&parts));
|
||||||
|
}
|
||||||
} else if !self.command.is_empty() {
|
} else if !self.command.is_empty() {
|
||||||
let _ = writeln!(s, "Exec={}", shell_join(&self.command));
|
let _ = writeln!(s, "Exec={}", shell_join(&self.command));
|
||||||
}
|
}
|
||||||
@ -403,7 +417,18 @@ impl QuadletUnit {
|
|||||||
environment: app.environment.clone(),
|
environment: app.environment.clone(),
|
||||||
devices: app.devices.clone(),
|
devices: app.devices.clone(),
|
||||||
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
||||||
network_aliases: vec![name.to_string()],
|
// Container always answers to its own name; manifest extras add the
|
||||||
|
// short hostnames peers bake in (e.g. indeedhub api/minio/relay).
|
||||||
|
// Only emitted for Bridge networks (slirp/pasta reject aliases).
|
||||||
|
network_aliases: {
|
||||||
|
let mut a = vec![name.to_string()];
|
||||||
|
for extra in &app.container.network_aliases {
|
||||||
|
if !a.iter().any(|x| x == extra) {
|
||||||
|
a.push(extra.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a
|
||||||
|
},
|
||||||
entrypoint: app.container.entrypoint.clone(),
|
entrypoint: app.container.entrypoint.clone(),
|
||||||
command: app.container.custom_args.clone(),
|
command: app.container.custom_args.clone(),
|
||||||
read_only_root: app.security.readonly_root,
|
read_only_root: app.security.readonly_root,
|
||||||
@ -563,11 +588,12 @@ pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result<bool> {
|
|||||||
/// Reload the user systemd manager. Required after any quadlet write
|
/// Reload the user systemd manager. Required after any quadlet write
|
||||||
/// or removal so systemd picks up the generated `.service` translation.
|
/// or removal so systemd picks up the generated `.service` translation.
|
||||||
pub async fn daemon_reload_user() -> Result<()> {
|
pub async fn daemon_reload_user() -> Result<()> {
|
||||||
let status = Command::new("systemctl")
|
// Bounded: a wedged user manager (e.g. a unit stuck "deactivating" while
|
||||||
.args(["--user", "daemon-reload"])
|
// podman hangs) could otherwise block daemon-reload indefinitely and freeze
|
||||||
.status()
|
// any caller — notably uninstall teardown.
|
||||||
|
let status = systemctl_user_status(&["daemon-reload"], Duration::from_secs(30))
|
||||||
.await
|
.await
|
||||||
.context("spawn systemctl --user daemon-reload")?;
|
.context("systemctl --user daemon-reload")?;
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
return Err(anyhow!("systemctl --user daemon-reload exited {status}"));
|
return Err(anyhow!("systemctl --user daemon-reload exited {status}"));
|
||||||
}
|
}
|
||||||
@ -624,7 +650,17 @@ pub async fn restart_service(service: &str) -> Result<()> {
|
|||||||
|
|
||||||
/// Stop a generated Quadlet service without removing its unit file.
|
/// Stop a generated Quadlet service without removing its unit file.
|
||||||
pub async fn stop_service(service: &str) -> Result<()> {
|
pub async fn stop_service(service: &str) -> Result<()> {
|
||||||
match systemctl_user_status(&["stop", service], QUADLET_STOP_TIMEOUT).await {
|
stop_service_with_timeout(service, QUADLET_STOP_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop a user service, waiting up to `timeout` for a graceful stop before
|
||||||
|
/// force-killing the app-scoped unit. Slow-to-SIGTERM apps (bitcoin-core ~600s,
|
||||||
|
/// lnd ~330s) must not be SIGKILLed at the default 45s — that risks data
|
||||||
|
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
||||||
|
/// less than `QUADLET_STOP_TIMEOUT`.
|
||||||
|
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
||||||
|
let timeout = timeout.max(QUADLET_STOP_TIMEOUT);
|
||||||
|
match systemctl_user_status(&["stop", service], timeout).await {
|
||||||
Ok(status) if status.success() => Ok(()),
|
Ok(status) if status.success() => Ok(()),
|
||||||
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@ -740,9 +776,11 @@ pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn exec_changed(old_body: &str, new_body: &str) -> bool {
|
pub fn exec_changed(old_body: &str, new_body: &str) -> bool {
|
||||||
let old_exec = directive_values(old_body, "Exec=");
|
// Entrypoint= and Exec= together define what the container runs, so a drift
|
||||||
let new_exec = directive_values(new_body, "Exec=");
|
// in either must recreate the container (e.g. when this renderer first
|
||||||
old_exec != new_exec
|
// splits a folded `Exec=sh -lc ...` into `Entrypoint=sh` + `Exec=-lc ...`).
|
||||||
|
directive_values(old_body, "Exec=") != directive_values(new_body, "Exec=")
|
||||||
|
|| directive_values(old_body, "Entrypoint=") != directive_values(new_body, "Entrypoint=")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
||||||
@ -759,11 +797,19 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
|||||||
/// that systemd no longer knows about.
|
/// that systemd no longer knows about.
|
||||||
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||||
let svc = format!("{unit_name}.service");
|
let svc = format!("{unit_name}.service");
|
||||||
// Stop first; ignore failure (unit may already be down).
|
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
||||||
let _ = Command::new("systemctl")
|
// rootless podman a generated unit can wedge in "deactivating" while
|
||||||
.args(["--user", "stop", &svc])
|
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
||||||
.status()
|
// would block the entire uninstall forever: the progress bar freezes and
|
||||||
.await;
|
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
||||||
|
// blocks reinstall). If the graceful stop times out, escalate to
|
||||||
|
// SIGKILL + reset-failed so teardown always proceeds.
|
||||||
|
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
let _ = kill_and_reset_service(&svc).await;
|
||||||
|
}
|
||||||
let path = dir.join(format!("{unit_name}.container"));
|
let path = dir.join(format!("{unit_name}.container"));
|
||||||
if fs::try_exists(&path).await.unwrap_or(false) {
|
if fs::try_exists(&path).await.unwrap_or(false) {
|
||||||
match fs::remove_file(&path).await {
|
match fs::remove_file(&path).await {
|
||||||
@ -774,10 +820,15 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
|||||||
}
|
}
|
||||||
daemon_reload_user().await.ok();
|
daemon_reload_user().await.ok();
|
||||||
// Defensive: kill the actual container too, in case quadlet left it.
|
// Defensive: kill the actual container too, in case quadlet left it.
|
||||||
let _ = Command::new("podman")
|
// Bounded so a hung podman store can't re-introduce the stall this function
|
||||||
.args(["rm", "-f", unit_name])
|
// exists to avoid.
|
||||||
.status()
|
let _ = tokio::time::timeout(
|
||||||
.await;
|
QUADLET_STOP_TIMEOUT,
|
||||||
|
Command::new("podman")
|
||||||
|
.args(["rm", "-f", unit_name])
|
||||||
|
.status(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -852,6 +903,26 @@ mod tests {
|
|||||||
assert!(!s.contains("Network=host"));
|
assert!(!s.contains("Network=host"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_host_network_omits_publish_ports() {
|
||||||
|
// Podman rejects PublishPort with Network=host (crash-loop exit 125).
|
||||||
|
let mut u = sample_unit();
|
||||||
|
u.network = NetworkMode::Host;
|
||||||
|
u.ports = vec![(3000, 3000, "tcp".into())];
|
||||||
|
let s = u.render();
|
||||||
|
assert!(s.contains("Network=host"));
|
||||||
|
assert!(!s.contains("PublishPort"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_non_host_network_emits_publish_ports() {
|
||||||
|
let mut u = sample_unit();
|
||||||
|
u.network = NetworkMode::Bridge("archy-net".into());
|
||||||
|
u.ports = vec![(3000, 3000, "tcp".into())];
|
||||||
|
let s = u.render();
|
||||||
|
assert!(s.contains("PublishPort=3000:3000/tcp"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_filename_and_service_name_are_consistent() {
|
fn unit_filename_and_service_name_are_consistent() {
|
||||||
let u = sample_unit();
|
let u = sample_unit();
|
||||||
@ -1001,7 +1072,10 @@ mod tests {
|
|||||||
assert!(s.contains("ReadOnly=true"));
|
assert!(s.contains("ReadOnly=true"));
|
||||||
assert!(s.contains("NoNewPrivileges=true"));
|
assert!(s.contains("NoNewPrivileges=true"));
|
||||||
assert!(s.contains("PodmanArgs=--cpus=2"));
|
assert!(s.contains("PodmanArgs=--cpus=2"));
|
||||||
assert!(s.contains("Exec=/usr/local/bin/bitcoind -server=1 -rpcbind=0.0.0.0"));
|
// Manifest entrypoint becomes a real Entrypoint= override (not folded
|
||||||
|
// into Exec=), so the unit doesn't depend on the image's own ENTRYPOINT.
|
||||||
|
assert!(s.contains("Entrypoint=/usr/local/bin/bitcoind"));
|
||||||
|
assert!(s.contains("Exec=-server=1 -rpcbind=0.0.0.0"));
|
||||||
assert!(s.contains("Restart=on-failure"));
|
assert!(s.contains("Restart=on-failure"));
|
||||||
assert!(s.contains("Network=archy-net"));
|
assert!(s.contains("Network=archy-net"));
|
||||||
}
|
}
|
||||||
@ -1033,6 +1107,7 @@ app:
|
|||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
container:
|
container:
|
||||||
image: registry/bitcoin-knots:1.0
|
image: registry/bitcoin-knots:1.0
|
||||||
|
network: archy-net
|
||||||
entrypoint: ["/usr/local/bin/bitcoind"]
|
entrypoint: ["/usr/local/bin/bitcoind"]
|
||||||
custom_args: ["-server=1", "-rpcbind=0.0.0.0"]
|
custom_args: ["-server=1", "-rpcbind=0.0.0.0"]
|
||||||
ports:
|
ports:
|
||||||
@ -1053,7 +1128,7 @@ app:
|
|||||||
security:
|
security:
|
||||||
capabilities: ["NET_BIND_SERVICE"]
|
capabilities: ["NET_BIND_SERVICE"]
|
||||||
readonly_root: true
|
readonly_root: true
|
||||||
network_policy: archy-net
|
network_policy: isolated
|
||||||
"#;
|
"#;
|
||||||
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
||||||
let u = QuadletUnit::from_manifest(&m, "bitcoin-knots");
|
let u = QuadletUnit::from_manifest(&m, "bitcoin-knots");
|
||||||
@ -1193,7 +1268,7 @@ app:
|
|||||||
image: x:latest
|
image: x:latest
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /etc/host-conf
|
source: /var/lib/archipelago/x-conf
|
||||||
target: /etc/conf
|
target: /etc/conf
|
||||||
options: ["ro"]
|
options: ["ro"]
|
||||||
"#;
|
"#;
|
||||||
@ -1217,7 +1292,7 @@ app:
|
|||||||
target: /tmp
|
target: /tmp
|
||||||
tmpfs_options: "rw,size=64m"
|
tmpfs_options: "rw,size=64m"
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /var/lib/x
|
source: /var/lib/archipelago/x
|
||||||
target: /data
|
target: /data
|
||||||
options: []
|
options: []
|
||||||
"#;
|
"#;
|
||||||
@ -1225,7 +1300,10 @@ app:
|
|||||||
let u = QuadletUnit::from_manifest(&m, "x");
|
let u = QuadletUnit::from_manifest(&m, "x");
|
||||||
// tmpfs entry is dropped from bind_mounts; bind entry survives.
|
// tmpfs entry is dropped from bind_mounts; bind entry survives.
|
||||||
assert_eq!(u.bind_mounts.len(), 1);
|
assert_eq!(u.bind_mounts.len(), 1);
|
||||||
assert_eq!(u.bind_mounts[0].host, PathBuf::from("/var/lib/x"));
|
assert_eq!(
|
||||||
|
u.bind_mounts[0].host,
|
||||||
|
PathBuf::from("/var/lib/archipelago/x")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1404,6 +1482,31 @@ app:
|
|||||||
assert!(!publish_ports_changed(new, new));
|
assert!(!publish_ports_changed(new, new));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_manifest_appends_manifest_network_aliases_for_bridge() {
|
||||||
|
let yaml = r#"
|
||||||
|
app:
|
||||||
|
id: indeedhub-api
|
||||||
|
name: IndeedHub API
|
||||||
|
version: 1.0.0
|
||||||
|
container:
|
||||||
|
image: registry/indeedhub-api:1.0.0
|
||||||
|
network: indeedhub-net
|
||||||
|
network_aliases: [api]
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
network_policy: isolated
|
||||||
|
"#;
|
||||||
|
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
||||||
|
let u = QuadletUnit::from_manifest(&m, "indeedhub-api");
|
||||||
|
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "indeedhub-net"));
|
||||||
|
// Own name first, then the baked-in short alias the frontend nginx uses.
|
||||||
|
assert_eq!(u.network_aliases, vec!["indeedhub-api", "api"]);
|
||||||
|
let s = u.render();
|
||||||
|
assert!(s.contains("NetworkAlias=api"));
|
||||||
|
assert!(s.contains("PodmanArgs=--network-alias=api"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn network_aliases_changed_detects_service_discovery_drift() {
|
fn network_aliases_changed_detects_service_discovery_drift() {
|
||||||
let old = "[Container]\nNetwork=archy-net\n";
|
let old = "[Container]\nNetwork=archy-net\n";
|
||||||
@ -1462,6 +1565,7 @@ app:
|
|||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
container:
|
container:
|
||||||
image: registry/lnd:latest
|
image: registry/lnd:latest
|
||||||
|
network: archy-net
|
||||||
ports:
|
ports:
|
||||||
- host: 10009
|
- host: 10009
|
||||||
container: 10009
|
container: 10009
|
||||||
@ -1477,7 +1581,7 @@ app:
|
|||||||
memory_limit: 1g
|
memory_limit: 1g
|
||||||
security:
|
security:
|
||||||
capabilities: []
|
capabilities: []
|
||||||
network_policy: archy-net
|
network_policy: isolated
|
||||||
"#;
|
"#;
|
||||||
let m = AppManifest::parse(yaml).unwrap();
|
let m = AppManifest::parse(yaml).unwrap();
|
||||||
let body = QuadletUnit::from_manifest(&m, "lnd").render();
|
let body = QuadletUnit::from_manifest(&m, "lnd").render();
|
||||||
|
|||||||
214
core/archipelago/src/container/secrets.rs
Normal file
214
core/archipelago/src/container/secrets.rs
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
//! Declarative, self-healing generation of app secrets.
|
||||||
|
//!
|
||||||
|
//! An app declares `generated_secrets` in its manifest; this module materialises
|
||||||
|
//! them just before `secret_env` is resolved. That keeps the migration's
|
||||||
|
//! data-driven bar: an app installs from its manifest alone — no host
|
||||||
|
//! provisioning and no per-app Rust — and every secret lands `0600`, owned by
|
||||||
|
//! the unprivileged (rootless) service user.
|
||||||
|
//!
|
||||||
|
//! Two properties make it safe to call on every install/reconcile tick:
|
||||||
|
//!
|
||||||
|
//! * **Idempotent** — a target file that already exists, is readable and
|
||||||
|
//! non-empty is left untouched, so values are stable across ticks.
|
||||||
|
//! * **Self-healing without privilege** — a target file that exists but is
|
||||||
|
//! *unreadable* (the classic `root:root`-owned secret left by some earlier
|
||||||
|
//! path) is unlinked and rewritten. Unlinking needs write on the
|
||||||
|
//! service-owned secrets dir, not on the file, so this recovers the broken
|
||||||
|
//! state with no `chown` and no root — exactly what a rootless node needs.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use archipelago_container::{AppManifest, GeneratedSecret, SecretGenKind};
|
||||||
|
use rand::RngCore;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Plaintext-password length (bytes of entropy) for [`SecretGenKind::Bcrypt`].
|
||||||
|
const BCRYPT_PASSWORD_BYTES: usize = 24;
|
||||||
|
|
||||||
|
/// Materialise every declared generated secret for `manifest` under
|
||||||
|
/// `secrets_dir`. No-op when the manifest declares none. Safe to call on every
|
||||||
|
/// reconcile/install tick (idempotent + self-healing).
|
||||||
|
pub fn ensure_generated_secrets(secrets_dir: &Path, manifest: &AppManifest) -> Result<()> {
|
||||||
|
let specs = &manifest.app.container.generated_secrets;
|
||||||
|
if specs.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
fs::create_dir_all(secrets_dir)
|
||||||
|
.with_context(|| format!("creating secrets dir {}", secrets_dir.display()))?;
|
||||||
|
for gs in specs {
|
||||||
|
ensure_one(secrets_dir, gs).with_context(|| format!("generating secret '{}'", gs.name))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_one(dir: &Path, gs: &GeneratedSecret) -> Result<()> {
|
||||||
|
let files = gs.target_files();
|
||||||
|
|
||||||
|
// Idempotent fast path: every target file present, readable and non-empty.
|
||||||
|
if files.iter().all(|f| readable_nonempty(&dir.join(f))) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-heal: drop any stale/unreadable target so the write below recreates
|
||||||
|
// it owned by us. Unlinking uses the (service-owned) dir's write bit, so a
|
||||||
|
// wrongly root-owned secret is recovered with no privilege escalation.
|
||||||
|
for f in &files {
|
||||||
|
let p = dir.join(f);
|
||||||
|
if p.exists() && !readable_nonempty(&p) {
|
||||||
|
tracing::warn!("regenerating unreadable/stale secret {}", p.display());
|
||||||
|
fs::remove_file(&p)
|
||||||
|
.with_context(|| format!("removing stale secret {}", p.display()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match gs.kind {
|
||||||
|
SecretGenKind::Hex16 => write_secret(&dir.join(&gs.name), &random_hex(16))?,
|
||||||
|
SecretGenKind::Hex32 => write_secret(&dir.join(&gs.name), &random_hex(32))?,
|
||||||
|
SecretGenKind::Base64 => write_secret(&dir.join(&gs.name), &random_base64(32))?,
|
||||||
|
SecretGenKind::Bcrypt => {
|
||||||
|
let password = random_hex(BCRYPT_PASSWORD_BYTES);
|
||||||
|
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)
|
||||||
|
.context("bcrypt-hashing generated password")?;
|
||||||
|
// Primary (server-facing hash) first, then the plaintext sibling.
|
||||||
|
write_secret(&dir.join(&gs.name), &hash)?;
|
||||||
|
write_secret(&dir.join(format!("{}.pw", gs.name)), &password)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `path` exists, is readable by this process, and is non-empty after
|
||||||
|
/// trimming. Any error (missing, permission denied, empty) reads as false.
|
||||||
|
fn readable_nonempty(path: &Path) -> bool {
|
||||||
|
fs::read_to_string(path)
|
||||||
|
.map(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_hex(bytes: usize) -> String {
|
||||||
|
let mut buf = vec![0u8; bytes];
|
||||||
|
rand::thread_rng().fill_bytes(&mut buf);
|
||||||
|
hex::encode(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `bytes` of entropy, standard base64 (with padding). For keys that a service
|
||||||
|
/// base64-decodes to recover the raw bytes (e.g. netbird's store encryptionKey).
|
||||||
|
fn random_base64(bytes: usize) -> String {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let mut buf = vec![0u8; bytes];
|
||||||
|
rand::thread_rng().fill_bytes(&mut buf);
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically write a `0600` secret: a temp file in the same dir (so the rename
|
||||||
|
/// is atomic), fsynced, then renamed over the target.
|
||||||
|
fn write_secret(path: &Path, value: &str) -> Result<()> {
|
||||||
|
let dir = path
|
||||||
|
.parent()
|
||||||
|
.context("secret path has no parent directory")?;
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.context("secret path has no filename")?;
|
||||||
|
let tmp = dir.join(format!(".{name}.tmp"));
|
||||||
|
|
||||||
|
let mut f = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(&tmp)
|
||||||
|
.with_context(|| format!("creating temp secret {}", tmp.display()))?;
|
||||||
|
f.write_all(value.as_bytes())
|
||||||
|
.with_context(|| format!("writing temp secret {}", tmp.display()))?;
|
||||||
|
f.sync_all()
|
||||||
|
.with_context(|| format!("fsync temp secret {}", tmp.display()))?;
|
||||||
|
drop(f);
|
||||||
|
|
||||||
|
fs::rename(&tmp, path)
|
||||||
|
.with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use archipelago_container::SecretGenKind;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
fn manifest_with(secrets: Vec<GeneratedSecret>) -> AppManifest {
|
||||||
|
let mut m: AppManifest = serde_yaml::from_str(
|
||||||
|
"app:\n id: t\n name: t\n version: 1.0.0\n container:\n image: x:y\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
m.app.container.generated_secrets = secrets;
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gs(name: &str, kind: SecretGenKind) -> GeneratedSecret {
|
||||||
|
GeneratedSecret {
|
||||||
|
name: name.to_string(),
|
||||||
|
kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generates_hex_and_bcrypt_with_0600() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let m = manifest_with(vec![
|
||||||
|
gs("tok", SecretGenKind::Hex16),
|
||||||
|
gs("admin", SecretGenKind::Bcrypt),
|
||||||
|
]);
|
||||||
|
ensure_generated_secrets(dir.path(), &m).unwrap();
|
||||||
|
|
||||||
|
let tok = std::fs::read_to_string(dir.path().join("tok")).unwrap();
|
||||||
|
assert_eq!(tok.trim().len(), 32, "hex16 = 16 bytes = 32 hex chars");
|
||||||
|
|
||||||
|
let hash = std::fs::read_to_string(dir.path().join("admin")).unwrap();
|
||||||
|
let pw = std::fs::read_to_string(dir.path().join("admin.pw")).unwrap();
|
||||||
|
assert!(hash.starts_with("$2"), "bcrypt hash shape");
|
||||||
|
assert!(
|
||||||
|
bcrypt::verify(pw.trim(), hash.trim()).unwrap(),
|
||||||
|
"pw matches hash"
|
||||||
|
);
|
||||||
|
|
||||||
|
for f in ["tok", "admin", "admin.pw"] {
|
||||||
|
let mode = std::fs::metadata(dir.path().join(f))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777;
|
||||||
|
assert_eq!(mode, 0o600, "{f} must be 0600");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idempotent_value_is_stable() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let m = manifest_with(vec![gs("tok", SecretGenKind::Hex32)]);
|
||||||
|
ensure_generated_secrets(dir.path(), &m).unwrap();
|
||||||
|
let first = std::fs::read_to_string(dir.path().join("tok")).unwrap();
|
||||||
|
ensure_generated_secrets(dir.path(), &m).unwrap();
|
||||||
|
let second = std::fs::read_to_string(dir.path().join("tok")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
first, second,
|
||||||
|
"a present readable secret is never rewritten"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_heals_unreadable_secret() {
|
||||||
|
// Simulate the root-owned case: a present-but-unreadable file. We can't
|
||||||
|
// chmod-away read as the owner in a unit test, so emulate "unreadable"
|
||||||
|
// via the empty-file branch (readable_nonempty == false), which drives
|
||||||
|
// the same unlink+regenerate path.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("tok"), "").unwrap();
|
||||||
|
let m = manifest_with(vec![gs("tok", SecretGenKind::Hex16)]);
|
||||||
|
ensure_generated_secrets(dir.path(), &m).unwrap();
|
||||||
|
let v = std::fs::read_to_string(dir.path().join("tok")).unwrap();
|
||||||
|
assert_eq!(v.trim().len(), 32, "stale/empty secret was regenerated");
|
||||||
|
}
|
||||||
|
}
|
||||||
272
core/archipelago/src/container/version_config.rs
Normal file
272
core/archipelago/src/container/version_config.rs
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
//! Per-app version preferences — the persistence layer for multi-version support.
|
||||||
|
//!
|
||||||
|
//! Multi-version support (`docs/bitcoin-multi-version-design.md`) lets a node
|
||||||
|
//! runner pin Bitcoin Core / Knots to a specific version and opt into
|
||||||
|
//! auto-update-to-latest. Both choices live in the existing per-app config file
|
||||||
|
//! at `/var/lib/archipelago/app-configs/<id>.json` as two keys:
|
||||||
|
//!
|
||||||
|
//! ```jsonc
|
||||||
|
//! { "pinnedVersion": "29.3.knots20260508", "autoUpdate": false }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! This is the single source of truth the orchestrator's install path reads to
|
||||||
|
//! resolve the image, and that the auto-update tick + "available update" badge
|
||||||
|
//! consult. Reads/writes are merge-preserving so they never clobber any
|
||||||
|
//! `containerConfig` (ports/volumes/env) a generic app may also store here.
|
||||||
|
//!
|
||||||
|
//! Platform-managed apps (bitcoin-core/knots/…) never use the
|
||||||
|
//! `containerConfig`-style keys (see `config.rs::dynamic_app_config`, which
|
||||||
|
//! returns early for them), so adding these keys to their file is collision-free.
|
||||||
|
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Resolved version preferences for one app. Defaults: no pin, auto-update off
|
||||||
|
/// (consensus-critical apps opt in explicitly — design open-question #4).
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct AppVersionConfig {
|
||||||
|
/// The version string the runner pinned, if any. Suppresses the update badge
|
||||||
|
/// and overrides the catalog default at install/recreate time.
|
||||||
|
pub pinned_version: Option<String>,
|
||||||
|
/// When true, the hourly catalog tick updates this app to the catalog
|
||||||
|
/// default automatically. Ignored while a version is pinned.
|
||||||
|
pub auto_update: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_dir() -> PathBuf {
|
||||||
|
let base = std::env::var("ARCHIPELAGO_DATA_DIR")
|
||||||
|
.unwrap_or_else(|_| "/var/lib/archipelago".to_string());
|
||||||
|
PathBuf::from(base).join("app-configs")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path(app_id: &str) -> PathBuf {
|
||||||
|
config_dir().join(format!("{app_id}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App ids that have opted into auto-update-to-latest AND are not pinned (a pin
|
||||||
|
/// is an explicit "stay here"). Drives the hourly per-app auto-update tick. The
|
||||||
|
/// app id is the config file stem. Returns empty when the dir is absent.
|
||||||
|
pub fn auto_update_apps() -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir(config_dir()) else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(app_id) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let cfg = read(app_id);
|
||||||
|
if cfg.auto_update && cfg.pinned_version.is_none() {
|
||||||
|
out.push(app_id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_raw(app_id: &str) -> Map<String, Value> {
|
||||||
|
let path = config_path(app_id);
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(s) => serde_json::from_str::<Value>(&s)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_object().cloned())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Err(_) => Map::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the version preferences for `app_id`. Returns defaults when the file is
|
||||||
|
/// absent or the keys are unset.
|
||||||
|
pub fn read(app_id: &str) -> AppVersionConfig {
|
||||||
|
let obj = read_raw(app_id);
|
||||||
|
AppVersionConfig {
|
||||||
|
pinned_version: obj
|
||||||
|
.get("pinnedVersion")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
auto_update: obj
|
||||||
|
.get("autoUpdate")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pinned version for `app_id`, if set. Convenience for the hot path.
|
||||||
|
pub fn pinned_version(app_id: &str) -> Option<String> {
|
||||||
|
read(app_id).pinned_version
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the leading numeric `major.minor.patch` of a version string into a
|
||||||
|
/// comparable tuple. Stops at the first non-numeric component, so Bitcoin Core
|
||||||
|
/// (`31.0`, `28.4`) and the Knots date-suffixed form (`29.3.knots20260508` →
|
||||||
|
/// `(29, 3, 0)`) both compare on their consensus-relevant major/minor. The
|
||||||
|
/// Knots build-date suffix is intentionally ignored — a same-major.minor Knots
|
||||||
|
/// rebuild is not a chainstate downgrade.
|
||||||
|
fn version_key(version: &str) -> (u64, u64, u64) {
|
||||||
|
let mut it = version.split('.').map(|c| {
|
||||||
|
// Take the leading digit run of each dotted component (`knots20260508`
|
||||||
|
// yields no leading digits → 0; `3` → 3).
|
||||||
|
c.chars()
|
||||||
|
.take_while(|ch| ch.is_ascii_digit())
|
||||||
|
.collect::<String>()
|
||||||
|
.parse::<u64>()
|
||||||
|
.unwrap_or(0)
|
||||||
|
});
|
||||||
|
(
|
||||||
|
it.next().unwrap_or(0),
|
||||||
|
it.next().unwrap_or(0),
|
||||||
|
it.next().unwrap_or(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when installing `candidate` over `current` is a DOWNGRADE — an older
|
||||||
|
/// Bitcoin release over a chainstate written by a newer one. This is the
|
||||||
|
/// highest-risk operation (Core refuses to start on a newer chainstate without
|
||||||
|
/// an expensive reindex; pruned nodes can lose data), so the UI must warn and
|
||||||
|
/// the switch must be explicitly confirmed (design §4). Equal or newer → false.
|
||||||
|
pub fn is_downgrade(current: &str, candidate: &str) -> bool {
|
||||||
|
version_key(candidate) < version_key(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge `cfg` into the on-disk config, preserving every other key. A
|
||||||
|
/// `pinned_version` of `None` removes the `pinnedVersion` key (un-pins / "track
|
||||||
|
/// latest"). Creates the directory and file on first write.
|
||||||
|
pub fn write(app_id: &str, cfg: &AppVersionConfig) -> std::io::Result<()> {
|
||||||
|
let path = config_path(app_id);
|
||||||
|
let mut obj = read_raw(app_id);
|
||||||
|
match &cfg.pinned_version {
|
||||||
|
Some(v) => {
|
||||||
|
obj.insert("pinnedVersion".to_string(), Value::String(v.clone()));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
obj.remove("pinnedVersion");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
obj.insert("autoUpdate".to_string(), Value::Bool(cfg.auto_update));
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let serialized = serde_json::to_string_pretty(&Value::Object(obj))
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
// Atomic-ish write: temp + rename so a crash mid-write can't truncate config.
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, serialized.as_bytes())?;
|
||||||
|
std::fs::rename(&tmp, &path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// `ARCHIPELAGO_DATA_DIR` is process-global, so the write/read tests must not
|
||||||
|
// run concurrently — serialize them and give each a unique dir. Without this
|
||||||
|
// lock, parallel `cargo test` races on the env var (poisoning is fine: a
|
||||||
|
// panicking test still releases a usable guard).
|
||||||
|
static ENV_LOCK: std::sync::Mutex<u64> = std::sync::Mutex::new(0);
|
||||||
|
|
||||||
|
fn with_tmp_data_dir<F: FnOnce()>(f: F) {
|
||||||
|
let mut counter = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
*counter += 1;
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("archy-vc-test-{}-{}", std::process::id(), *counter));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::env::set_var("ARCHIPELAGO_DATA_DIR", &dir);
|
||||||
|
f();
|
||||||
|
std::env::remove_var("ARCHIPELAGO_DATA_DIR");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
// `counter` guard drops here, releasing the lock for the next test.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_when_absent() {
|
||||||
|
with_tmp_data_dir(|| {
|
||||||
|
let cfg = read("bitcoin-core");
|
||||||
|
assert_eq!(cfg.pinned_version, None);
|
||||||
|
assert!(!cfg.auto_update);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_then_read_roundtrips() {
|
||||||
|
with_tmp_data_dir(|| {
|
||||||
|
write(
|
||||||
|
"bitcoin-knots",
|
||||||
|
&AppVersionConfig {
|
||||||
|
pinned_version: Some("29.3.knots20260508".into()),
|
||||||
|
auto_update: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cfg = read("bitcoin-knots");
|
||||||
|
assert_eq!(cfg.pinned_version.as_deref(), Some("29.3.knots20260508"));
|
||||||
|
assert!(!cfg.auto_update);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_preserves_existing_keys() {
|
||||||
|
with_tmp_data_dir(|| {
|
||||||
|
// Simulate a generic app's containerConfig already on disk.
|
||||||
|
let path = config_path("someapp");
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(&path, r#"{"ports":["80:80"],"autoUpdate":false}"#).unwrap();
|
||||||
|
write(
|
||||||
|
"someapp",
|
||||||
|
&AppVersionConfig {
|
||||||
|
pinned_version: Some("1.2.3".into()),
|
||||||
|
auto_update: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let raw = read_raw("someapp");
|
||||||
|
assert!(raw.contains_key("ports"), "ports key must survive");
|
||||||
|
assert_eq!(raw.get("pinnedVersion").unwrap(), "1.2.3");
|
||||||
|
assert_eq!(raw.get("autoUpdate").unwrap(), &Value::Bool(true));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downgrade_detection() {
|
||||||
|
// Older over newer = downgrade.
|
||||||
|
assert!(is_downgrade("31.0", "30.0"));
|
||||||
|
assert!(is_downgrade("28.4", "27.2"));
|
||||||
|
// Same or newer = not a downgrade.
|
||||||
|
assert!(!is_downgrade("30.0", "31.0"));
|
||||||
|
assert!(!is_downgrade("28.4", "28.4"));
|
||||||
|
// Knots date-suffixed strings compare on major.minor only.
|
||||||
|
assert!(is_downgrade("29.3.knots20260508", "28.1.knots20251010"));
|
||||||
|
assert!(!is_downgrade("29.3.knots20260101", "29.3.knots20260508"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unpin_removes_key() {
|
||||||
|
with_tmp_data_dir(|| {
|
||||||
|
write(
|
||||||
|
"bitcoin-core",
|
||||||
|
&AppVersionConfig {
|
||||||
|
pinned_version: Some("31.0".into()),
|
||||||
|
auto_update: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write(
|
||||||
|
"bitcoin-core",
|
||||||
|
&AppVersionConfig {
|
||||||
|
pinned_version: None,
|
||||||
|
auto_update: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let raw = read_raw("bitcoin-core");
|
||||||
|
assert!(!raw.contains_key("pinnedVersion"));
|
||||||
|
assert_eq!(read("bitcoin-core").pinned_version, None);
|
||||||
|
assert!(read("bitcoin-core").auto_update);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
167
core/archipelago/src/content_owned.rs
Normal file
167
core/archipelago/src/content_owned.rs
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
//! Buyer-side store of paid content the node has purchased.
|
||||||
|
//!
|
||||||
|
//! A paid peer download used to be ephemeral: the bytes were handed to the
|
||||||
|
//! browser as a one-shot `<a download>` and then thrown away. On the mobile
|
||||||
|
//! companion that download silently fails, so the item appeared to never
|
||||||
|
//! "unlock" even though the ecash was spent. This module persists every
|
||||||
|
//! successful purchase — bytes + metadata — keyed by (seller onion, content_id),
|
||||||
|
//! so the gallery can render owned items unblurred and play/view them in-app
|
||||||
|
//! from the local cache, with no re-payment and no reliance on a browser
|
||||||
|
//! download. The buyer can still save the file later from the cached copy.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
const OWNED_DIR: &str = "purchased-content";
|
||||||
|
const OWNED_INDEX: &str = "owned.json";
|
||||||
|
|
||||||
|
/// One purchased item. `onion` + `content_id` are the identity; everything else
|
||||||
|
/// is display/metadata captured at purchase time.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OwnedItem {
|
||||||
|
pub onion: String,
|
||||||
|
pub content_id: String,
|
||||||
|
pub filename: String,
|
||||||
|
pub mime_type: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub paid_sats: u64,
|
||||||
|
pub ecash_backend: String,
|
||||||
|
/// RFC3339 timestamp; best-effort, empty if the clock was unavailable.
|
||||||
|
pub purchased_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
struct OwnedIndex {
|
||||||
|
items: Vec<OwnedItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owned_root(data_dir: &Path) -> PathBuf {
|
||||||
|
data_dir.join(OWNED_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_path(data_dir: &Path) -> PathBuf {
|
||||||
|
owned_root(data_dir).join(OWNED_INDEX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize an onion into a safe directory component (it's already [a-z2-7].onion
|
||||||
|
/// for valid v3, but be defensive against path traversal regardless).
|
||||||
|
fn sanitize(component: &str) -> String {
|
||||||
|
component
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_path(data_dir: &Path, onion: &str, content_id: &str) -> PathBuf {
|
||||||
|
owned_root(data_dir)
|
||||||
|
.join(sanitize(onion))
|
||||||
|
.join(sanitize(content_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_index(data_dir: &Path) -> OwnedIndex {
|
||||||
|
match fs::read_to_string(index_path(data_dir)).await {
|
||||||
|
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||||
|
Err(_) => OwnedIndex::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
|
||||||
|
let root = owned_root(data_dir);
|
||||||
|
fs::create_dir_all(&root)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("creating {}", root.display()))?;
|
||||||
|
let content = serde_json::to_string_pretty(index).context("serializing owned index")?;
|
||||||
|
fs::write(index_path(data_dir), content)
|
||||||
|
.await
|
||||||
|
.context("writing owned index")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a successful purchase: write the bytes to disk and upsert the index
|
||||||
|
/// entry. Idempotent on (onion, content_id) — re-buying overwrites with the
|
||||||
|
/// latest copy/metadata rather than duplicating.
|
||||||
|
pub async fn record_purchase(
|
||||||
|
data_dir: &Path,
|
||||||
|
onion: &str,
|
||||||
|
content_id: &str,
|
||||||
|
filename: &str,
|
||||||
|
mime_type: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
paid_sats: u64,
|
||||||
|
ecash_backend: &str,
|
||||||
|
purchased_at: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let path = bytes_path(data_dir, onion, content_id);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("creating {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
fs::write(&path, bytes)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("writing purchased bytes to {}", path.display()))?;
|
||||||
|
|
||||||
|
let mut index = load_index(data_dir).await;
|
||||||
|
let entry = OwnedItem {
|
||||||
|
onion: onion.to_string(),
|
||||||
|
content_id: content_id.to_string(),
|
||||||
|
filename: filename.to_string(),
|
||||||
|
mime_type: mime_type.to_string(),
|
||||||
|
size_bytes: bytes.len() as u64,
|
||||||
|
paid_sats,
|
||||||
|
ecash_backend: ecash_backend.to_string(),
|
||||||
|
purchased_at: purchased_at.to_string(),
|
||||||
|
};
|
||||||
|
if let Some(existing) = index
|
||||||
|
.items
|
||||||
|
.iter_mut()
|
||||||
|
.find(|i| i.onion == onion && i.content_id == content_id)
|
||||||
|
{
|
||||||
|
*existing = entry;
|
||||||
|
} else {
|
||||||
|
index.items.push(entry);
|
||||||
|
}
|
||||||
|
save_index(data_dir, &index).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every item this node owns.
|
||||||
|
pub async fn list_owned(data_dir: &Path) -> Vec<OwnedItem> {
|
||||||
|
load_index(data_dir).await.items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the node has already purchased this (onion, content_id).
|
||||||
|
#[allow(dead_code)] // used by the upcoming seller-side signed-entitlement path (#8)
|
||||||
|
pub async fn is_owned(data_dir: &Path, onion: &str, content_id: &str) -> bool {
|
||||||
|
bytes_path(data_dir, onion, content_id).exists()
|
||||||
|
&& load_index(data_dir)
|
||||||
|
.await
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|i| i.onion == onion && i.content_id == content_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a purchased item's bytes + mime type from the local cache, if present.
|
||||||
|
pub async fn read_owned(
|
||||||
|
data_dir: &Path,
|
||||||
|
onion: &str,
|
||||||
|
content_id: &str,
|
||||||
|
) -> Option<(String, Vec<u8>)> {
|
||||||
|
let bytes = fs::read(bytes_path(data_dir, onion, content_id))
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
let mime = load_index(data_dir)
|
||||||
|
.await
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.find(|i| i.onion == onion && i.content_id == content_id)
|
||||||
|
.map(|i| i.mime_type)
|
||||||
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
|
Some((mime, bytes))
|
||||||
|
}
|
||||||
@ -61,6 +61,22 @@ pub async fn load_user_stopped(data_dir: &Path) -> std::collections::HashSet<Str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Names of the containers that were running at the last periodic snapshot
|
||||||
|
/// (`running-containers.json`, saved every ~120s by `save_container_snapshot`).
|
||||||
|
/// Unlike `check_for_crash`, this reads the snapshot unconditionally (no PID/crash
|
||||||
|
/// gate) — it's the durable "what was running" signal the boot reconciler uses to
|
||||||
|
/// recreate a previously-running app whose container vanished. Empty if absent.
|
||||||
|
pub async fn load_last_running_names(data_dir: &Path) -> std::collections::HashSet<String> {
|
||||||
|
let path = data_dir.join(CONTAINER_STATE_FILE);
|
||||||
|
match fs::read_to_string(&path).await {
|
||||||
|
Ok(content) => match serde_json::from_str::<ContainerSnapshot>(&content) {
|
||||||
|
Ok(snapshot) => snapshot.containers.into_iter().map(|c| c.name).collect(),
|
||||||
|
Err(_) => std::collections::HashSet::new(),
|
||||||
|
},
|
||||||
|
Err(_) => std::collections::HashSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Save the set of user-stopped containers to disk.
|
/// Save the set of user-stopped containers to disk.
|
||||||
pub async fn save_user_stopped(data_dir: &Path, stopped: &std::collections::HashSet<String>) {
|
pub async fn save_user_stopped(data_dir: &Path, stopped: &std::collections::HashSet<String>) {
|
||||||
let path = data_dir.join(USER_STOPPED_FILE);
|
let path = data_dir.join(USER_STOPPED_FILE);
|
||||||
@ -898,6 +914,43 @@ mod tests {
|
|||||||
assert_eq!(containers[1].name, "archy-mempool-web");
|
assert_eq!(containers[1].name, "archy-mempool-web");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_load_last_running_names_reads_snapshot_without_pid_gate() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
// No PID file written — load_last_running_names must NOT require a crash.
|
||||||
|
let snapshot = ContainerSnapshot {
|
||||||
|
timestamp: 1000,
|
||||||
|
containers: vec![
|
||||||
|
RunningContainerRecord {
|
||||||
|
name: "immich_server".to_string(),
|
||||||
|
image: "immich:2.7".to_string(),
|
||||||
|
},
|
||||||
|
RunningContainerRecord {
|
||||||
|
name: "immich_postgres".to_string(),
|
||||||
|
image: "postgres:16".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
fs::write(
|
||||||
|
tmp.path().join(CONTAINER_STATE_FILE),
|
||||||
|
serde_json::to_string(&snapshot).unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let names = load_last_running_names(tmp.path()).await;
|
||||||
|
assert_eq!(names.len(), 2);
|
||||||
|
assert!(names.contains("immich_server"));
|
||||||
|
assert!(names.contains("immich_postgres"));
|
||||||
|
assert!(!names.contains("immich_redis"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_load_last_running_names_empty_when_absent() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
assert!(load_last_running_names(tmp.path()).await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_write_and_remove_pid_marker() {
|
async fn test_write_and_remove_pid_marker() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
@ -254,11 +254,59 @@ pub(crate) async fn notify_join(
|
|||||||
"params": params,
|
"params": params,
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = crate::fips::dial::PeerRequest::new(remote_fips_npub, remote_onion, "/rpc/v1")
|
// Deliver the notification in the BACKGROUND with retries, and return
|
||||||
.service(crate::settings::transport::PeerService::Federation)
|
// immediately. Two reasons:
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
// 1. The join RPC must not block on this. Awaiting a cold FIPS overlay
|
||||||
.send_json(&body)
|
// (no shared FIPS path between LAN and remote/Tailscale peers) stalled
|
||||||
.await;
|
// the whole join until FIPS timed out, surfacing as "Request timeout"
|
||||||
|
// in the UI even though the local membership was already saved.
|
||||||
|
// 2. If this single best-effort POST failed, the inviter never learned
|
||||||
|
// about us → asymmetric federation (they couldn't see us). Retrying in
|
||||||
|
// the background until it lands makes federation converge to symmetric.
|
||||||
|
// `fips_timeout` fast-fails a dead FIPS path so the Tor fallback (which
|
||||||
|
// answers an onion in ~3-5s) is reached quickly on each attempt.
|
||||||
|
let remote_onion = remote_onion.to_string();
|
||||||
|
let remote_fips_npub = remote_fips_npub.map(|s| s.to_string());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// ~5 attempts with linear backoff: 0s, 10s, 20s, 30s, 40s — covers a
|
||||||
|
// peer that is briefly unreachable (restarting, publishing its onion)
|
||||||
|
// without hammering it.
|
||||||
|
for attempt in 1..=5u32 {
|
||||||
|
let res = crate::fips::dial::PeerRequest::new(
|
||||||
|
remote_fips_npub.as_deref(),
|
||||||
|
&remote_onion,
|
||||||
|
"/rpc/v1",
|
||||||
|
)
|
||||||
|
.service(crate::settings::transport::PeerService::Federation)
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
|
.send_json(&body)
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok((resp, transport)) if resp.status().is_success() => {
|
||||||
|
tracing::info!(
|
||||||
|
attempt,
|
||||||
|
transport = %transport,
|
||||||
|
"peer-joined notification delivered to inviter"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok((resp, _)) => tracing::warn!(
|
||||||
|
attempt,
|
||||||
|
status = %resp.status(),
|
||||||
|
"peer-joined notification rejected; will retry"
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(attempt, error = %e, "peer-joined notification failed; will retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(10 * attempt as u64)).await;
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
onion = %remote_onion,
|
||||||
|
"peer-joined notification gave up after retries — peer may not see us until next sync"
|
||||||
|
);
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,9 @@ mod types;
|
|||||||
|
|
||||||
// Re-export all public items so `crate::federation::*` continues to work.
|
// Re-export all public items so `crate::federation::*` continues to work.
|
||||||
pub use invites::{accept_invite, create_invite};
|
pub use invites::{accept_invite, create_invite};
|
||||||
|
// Crate-internal: used by the periodic federation auto-sync to re-assert
|
||||||
|
// membership to peers that don't list us back (asymmetry self-heal).
|
||||||
|
pub(crate) use invites::notify_join;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use storage::{
|
pub use storage::{
|
||||||
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
|
||||||
|
|||||||
@ -33,6 +33,12 @@ pub async fn sync_with_peer(
|
|||||||
.header("X-Federation-Sig", signature)
|
.header("X-Federation-Sig", signature)
|
||||||
.header("X-Federation-Timestamp", timestamp)
|
.header("X-Federation-Timestamp", timestamp)
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
// Fast-fail a cold/unreachable FIPS overlay (common between LAN and
|
||||||
|
// remote/Tailscale peers that share no FIPS path) so the Tor fallback —
|
||||||
|
// which answers an onion in ~3-5s — isn't stuck behind the full 30s FIPS
|
||||||
|
// budget. Without this, a state sync to a FIPS-unreachable peer "took
|
||||||
|
// ages" and join/sync appeared to time out even though Tor was healthy.
|
||||||
|
.fips_timeout(std::time::Duration::from_secs(6))
|
||||||
.send_json(&body)
|
.send_json(&body)
|
||||||
.await
|
.await
|
||||||
.context("Failed to reach federated peer")?;
|
.context("Failed to reach federated peer")?;
|
||||||
|
|||||||
@ -216,6 +216,44 @@ pub struct ApplyResult {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated
|
||||||
|
/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port.
|
||||||
|
const FIPS_UDP_PORT: u16 = 8668;
|
||||||
|
|
||||||
|
/// Build transient seed-anchor entries that dial LAN-discovered federation peers
|
||||||
|
/// directly over their FIPS UDP transport. For each peer the registry knows both
|
||||||
|
/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at
|
||||||
|
/// `<lan-ip>:8668`. This lets co-located federation nodes form a DIRECT FIPS link
|
||||||
|
/// instead of depending on the global anchor's spanning tree to route between
|
||||||
|
/// them (the cause of every dial falling back to Tor when the anchor link flaps).
|
||||||
|
///
|
||||||
|
/// This is FIPS's own UDP transport over the LAN — not Tailscale, not the LAN
|
||||||
|
/// HTTP messaging port. NOT persisted to `seed-anchors.json`: recomputed each
|
||||||
|
/// apply tick from live LAN discovery, so a peer's changing IP self-corrects and
|
||||||
|
/// stale entries never accumulate. `fipsctl connect` is idempotent, so
|
||||||
|
/// re-applying just keeps the link warm.
|
||||||
|
pub fn lan_fips_anchors(peers: &[crate::transport::PeerRecord]) -> Vec<SeedAnchor> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for p in peers {
|
||||||
|
let (Some(lan), Some(npub)) = (p.lan_address.as_deref(), p.fips_npub.as_deref()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// lan_address is the peer's HTTP/LAN socket ("ip:port"); reuse only its IP
|
||||||
|
// and target the FIPS UDP port. SocketAddr::new(...).to_string() formats
|
||||||
|
// IPv6 with brackets correctly.
|
||||||
|
let Ok(sa) = lan.parse::<std::net::SocketAddr>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
out.push(SeedAnchor {
|
||||||
|
npub: npub.to_string(),
|
||||||
|
address: std::net::SocketAddr::new(sa.ip(), FIPS_UDP_PORT).to_string(),
|
||||||
|
transport: "udp".to_string(),
|
||||||
|
label: "LAN federation peer (direct FIPS)".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -308,6 +308,14 @@ pub struct PeerRequest<'a> {
|
|||||||
pub path: &'a str,
|
pub path: &'a str,
|
||||||
pub headers: Vec<(&'a str, String)>,
|
pub headers: Vec<(&'a str, String)>,
|
||||||
pub timeout: std::time::Duration,
|
pub timeout: std::time::Duration,
|
||||||
|
/// Optional shorter cap on the FIPS *attempt* only. When set, a cold or hung
|
||||||
|
/// FIPS overlay fails fast within this budget so the Tor fallback still gets
|
||||||
|
/// its full `timeout` — without it, a stuck FIPS dial can consume the whole
|
||||||
|
/// caller budget (e.g. a 60s frontend RPC) and the request "times out" even
|
||||||
|
/// though Tor would have answered (#6, the Pay-with-QR invoice request).
|
||||||
|
/// `None` keeps the legacy behavior (FIPS uses the full `timeout`), which a
|
||||||
|
/// large content download needs so its long FIPS transfer isn't truncated.
|
||||||
|
pub fips_timeout: Option<std::time::Duration>,
|
||||||
pub service: Option<crate::settings::transport::PeerService>,
|
pub service: Option<crate::settings::transport::PeerService>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,10 +327,26 @@ impl<'a> PeerRequest<'a> {
|
|||||||
path,
|
path,
|
||||||
headers: Vec::new(),
|
headers: Vec::new(),
|
||||||
timeout: std::time::Duration::from_secs(30),
|
timeout: std::time::Duration::from_secs(30),
|
||||||
|
fips_timeout: None,
|
||||||
service: None,
|
service: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cap the FIPS attempt to a shorter budget than the overall `timeout`, so a
|
||||||
|
/// cold/hung overlay path fails fast and the Tor fallback keeps its full
|
||||||
|
/// budget. Use on short request/response calls (invoice, status); leave
|
||||||
|
/// unset for large downloads that legitimately need a long FIPS transfer.
|
||||||
|
pub fn fips_timeout(mut self, t: std::time::Duration) -> Self {
|
||||||
|
self.fips_timeout = Some(t);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Timeout to apply to the FIPS attempt — the explicit cap if set, else the
|
||||||
|
/// overall request timeout.
|
||||||
|
fn fips_attempt_timeout(&self) -> std::time::Duration {
|
||||||
|
self.fips_timeout.unwrap_or(self.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
/// Tie this request to a user-configurable service preference. If
|
/// Tie this request to a user-configurable service preference. If
|
||||||
/// the user has set that service to `Fips` or `Tor`, the builder
|
/// the user has set that service to `Fips` or `Tor`, the builder
|
||||||
/// respects it.
|
/// respects it.
|
||||||
@ -423,7 +447,7 @@ impl<'a> PeerRequest<'a> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let url = format!("{}{}", base, self.path);
|
let url = format!("{}{}", base, self.path);
|
||||||
let c = client_with_timeout(self.timeout);
|
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||||
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);
|
||||||
@ -456,7 +480,7 @@ impl<'a> PeerRequest<'a> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let url = format!("{}{}", base, self.path);
|
let url = format!("{}{}", base, self.path);
|
||||||
let c = client_with_timeout(self.timeout);
|
let c = client_with_timeout(self.fips_attempt_timeout());
|
||||||
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);
|
||||||
|
|||||||
@ -60,14 +60,23 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
|||||||
tracing::info!("FIPS auto-activated");
|
tracing::info!("FIPS auto-activated");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn the FIPS supervisor: every 45s it (1) auto-activates FIPS if onboarding
|
/// Spawn the FIPS supervisor: every 25s it (1) auto-activates FIPS if onboarding
|
||||||
/// is done but the service is down — so it comes up with zero user interaction,
|
/// is done but the service is down — so it comes up with zero user interaction,
|
||||||
/// and (2) keeps hole-punched paths to known federation peers warm, so on-demand
|
/// and (2) keeps hole-punched paths to known federation peers warm, so on-demand
|
||||||
/// dials land on FIPS instead of falling back to Tor. Warms peers concurrently
|
/// dials land on FIPS instead of falling back to Tor. Warms peers concurrently
|
||||||
/// so one slow/offline peer doesn't delay the rest.
|
/// so one slow/offline peer doesn't delay the rest.
|
||||||
|
///
|
||||||
|
/// The interval MUST be shorter than the NAT/hole-punch cold window
|
||||||
|
/// (`warm_path` docs it at ~30-60s). The previous 45s sat at the edge of that
|
||||||
|
/// window: a path that went cold at ~30s stayed cold until the next 45s tick,
|
||||||
|
/// so real peer dials in that gap hit a cold path and fell back to Tor (~18s
|
||||||
|
/// onion latency instead of FIPS's ~2-3s). 25s keeps every path refreshed
|
||||||
|
/// inside the minimum cold window, which is what actually makes FIPS — not Tor —
|
||||||
|
/// the transport peer requests land on. Measured: warm FIPS browse ~2.6s vs a
|
||||||
|
/// cold-path fallback browse ~18-22s over Tor to the same peer.
|
||||||
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(45));
|
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||||
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.
|
||||||
|
|||||||
@ -1358,6 +1358,14 @@ mod tests {
|
|||||||
host_port_ready: None,
|
host_port_ready: None,
|
||||||
healthy: true,
|
healthy: true,
|
||||||
},
|
},
|
||||||
|
ContainerHealth {
|
||||||
|
name: "indeedhub-minio".into(),
|
||||||
|
app_id: "indeedhub-minio".into(),
|
||||||
|
state: "running".into(),
|
||||||
|
podman_health: None,
|
||||||
|
host_port_ready: None,
|
||||||
|
healthy: true,
|
||||||
|
},
|
||||||
ContainerHealth {
|
ContainerHealth {
|
||||||
name: "indeedhub-api".into(),
|
name: "indeedhub-api".into(),
|
||||||
app_id: "indeedhub-api".into(),
|
app_id: "indeedhub-api".into(),
|
||||||
|
|||||||
@ -39,6 +39,7 @@ mod constants;
|
|||||||
mod container;
|
mod container;
|
||||||
mod content_hash;
|
mod content_hash;
|
||||||
mod content_invoice;
|
mod content_invoice;
|
||||||
|
mod content_owned;
|
||||||
mod content_server;
|
mod content_server;
|
||||||
mod crash_recovery;
|
mod crash_recovery;
|
||||||
mod credentials;
|
mod credentials;
|
||||||
@ -197,14 +198,53 @@ async fn main() -> Result<()> {
|
|||||||
(Some(trait_obj), Some(dev))
|
(Some(trait_obj), Some(dev))
|
||||||
} else {
|
} else {
|
||||||
let prod = Arc::new(ProdContainerOrchestrator::new(config.clone()).await?);
|
let prod = Arc::new(ProdContainerOrchestrator::new(config.clone()).await?);
|
||||||
|
// Pull the freshest signed app-catalog BEFORE loading manifests, so any
|
||||||
|
// registry-embedded manifest (the origin-wins overlay in load_manifests)
|
||||||
|
// is in place on THIS boot — not a restart later. Without this the boot
|
||||||
|
// would overlay the previous run's cached catalog and a newly-published
|
||||||
|
// app (e.g. a registry-only install) wouldn't appear until the next
|
||||||
|
// restart. Bounded + best-effort: on timeout/unreachable origin the
|
||||||
|
// last-cached catalog (or the disk manifests) still load — registry is
|
||||||
|
// an overlay on top of disk, never a hard dependency.
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(25),
|
||||||
|
crate::container::app_catalog::refresh_catalog(&config.data_dir),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(n)) => info!("🛰️ app-catalog refreshed before manifest load ({n} apps)"),
|
||||||
|
Ok(Err(e)) => tracing::debug!("app-catalog pre-load refresh failed (using cache): {e}"),
|
||||||
|
Err(_) => tracing::debug!("app-catalog pre-load refresh timed out (using cache)"),
|
||||||
|
}
|
||||||
// Best-effort manifest load; a missing /opt/archipelago/apps is
|
// Best-effort manifest load; a missing /opt/archipelago/apps is
|
||||||
// logged inside load_manifests and not fatal.
|
// logged inside load_manifests and not fatal.
|
||||||
match prod.load_manifests().await {
|
match prod.load_manifests().await {
|
||||||
Ok(n) => info!("📦 Loaded {n} app manifest(s) from disk"),
|
Ok(n) => info!("📦 Loaded {n} app manifest(s) (disk + registry catalog)"),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "prod orchestrator: load_manifests failed at startup");
|
tracing::error!(error = %e, "prod orchestrator: load_manifests failed at startup");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Reboot-survival safety net for the podman `--restart` path: ensure the
|
||||||
|
// user's podman-restart.service is enabled so `unless-stopped` containers
|
||||||
|
// come back after a reboot even when the Quadlet backend path is off
|
||||||
|
// (orchestrator-installed backends like immich/btcpay run as plain podman
|
||||||
|
// containers until the Phase-3 Quadlet rollout). Idempotent + best-effort.
|
||||||
|
{
|
||||||
|
let out = tokio::process::Command::new("systemctl")
|
||||||
|
.args(["--user", "enable", "--now", "podman-restart.service"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
match out {
|
||||||
|
Ok(o) if o.status.success() => {
|
||||||
|
info!("🔁 podman-restart.service enabled (reboot-survival for --restart containers)")
|
||||||
|
}
|
||||||
|
Ok(o) => tracing::debug!(
|
||||||
|
"podman-restart.service enable skipped: {}",
|
||||||
|
String::from_utf8_lossy(&o.stderr).trim()
|
||||||
|
),
|
||||||
|
Err(e) => tracing::debug!("podman-restart.service enable skipped: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
// Adoption pass: link existing podman containers back to their
|
// Adoption pass: link existing podman containers back to their
|
||||||
// manifests so the reconciler doesn't recreate them.
|
// manifests so the reconciler doesn't recreate them.
|
||||||
match tokio::time::timeout(Duration::from_secs(35), prod.adopt_existing()).await {
|
match tokio::time::timeout(Duration::from_secs(35), prod.adopt_existing()).await {
|
||||||
@ -248,7 +288,9 @@ async fn main() -> Result<()> {
|
|||||||
// via auth.setup RPC. The Login page detects is_setup=false and shows
|
// via auth.setup RPC. The Login page detects is_setup=false and shows
|
||||||
// "Create Password" form instead of login form.
|
// "Create Password" form instead of login form.
|
||||||
|
|
||||||
// Create server
|
// Create server. Keep a clone of the orchestrator handle for the background
|
||||||
|
// update scheduler (per-app auto-update applies via the orchestrator).
|
||||||
|
let update_orchestrator = orchestrator.clone();
|
||||||
let server = Server::new(config.clone(), orchestrator, dev_orchestrator).await?;
|
let server = Server::new(config.clone(), orchestrator, dev_orchestrator).await?;
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
@ -273,10 +315,12 @@ async fn main() -> Result<()> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn background update scheduler
|
// Spawn background update scheduler. Pass the orchestrator so the scheduler
|
||||||
|
// can apply per-app auto-update-to-latest (multi-version support) via the
|
||||||
|
// safe orchestrator upgrade path; None in dev mode disables it.
|
||||||
let update_data_dir = config.data_dir.clone();
|
let update_data_dir = config.data_dir.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
update::run_update_scheduler(update_data_dir).await;
|
update::run_update_scheduler(update_data_dir, update_orchestrator).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Synchronize host-side doctor artifacts (script + systemd units) with
|
// Synchronize host-side doctor artifacts (script + systemd units) with
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
//! asker is limited to one in-flight query.
|
//! asker is limited to one in-flight query.
|
||||||
|
|
||||||
use super::super::message_types::{self, AssistResponsePayload, MeshMessageType};
|
use super::super::message_types::{self, AssistResponsePayload, MeshMessageType};
|
||||||
|
use super::super::types::MeshEvent;
|
||||||
use super::bitcoin::send_to_peer;
|
use super::bitcoin::send_to_peer;
|
||||||
use super::{MeshCommand, MeshState};
|
use super::{MeshCommand, MeshState};
|
||||||
use crate::federation::TrustLevel;
|
use crate::federation::TrustLevel;
|
||||||
@ -42,28 +43,46 @@ pub(super) enum AssistReply {
|
|||||||
/// Plain-text broadcast on a mesh channel — the bare `!ai` path, so any
|
/// Plain-text broadcast on a mesh channel — the bare `!ai` path, so any
|
||||||
/// client (including non-archipelago meshcore/Meshtastic nodes) sees it.
|
/// client (including non-archipelago meshcore/Meshtastic nodes) sees it.
|
||||||
ChannelText { channel: u8 },
|
ChannelText { channel: u8 },
|
||||||
|
/// Normal `Text` chat bubble sent back into the 1:1 thread — the
|
||||||
|
/// archipelago `!ai`-in-chat path. The asker typed `!ai …` as a regular
|
||||||
|
/// direct message, so the answer lands inline in that same conversation
|
||||||
|
/// (encrypted, peer-addressed) rather than as a separate widget.
|
||||||
|
ChatText { contact_id: u32 },
|
||||||
|
/// Plain-text NATIVE direct message back to the asker's radio contact —
|
||||||
|
/// the bare `!ai` path for a stock meshcore client (e.g. a phone). The
|
||||||
|
/// answer goes as a real unicast DM (not a public-channel broadcast), so
|
||||||
|
/// only the asker sees it and a stock client can read it.
|
||||||
|
RadioDm { dest_prefix: [u8; 6] },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Entry point: gate the query, run the model, send the answer back via the
|
/// Entry point: gate the query, run the model, send the answer back via the
|
||||||
/// requested reply path. Spawned off the radio loop so it never blocks.
|
/// requested reply path. Spawned off the radio loop so it never blocks.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(super) async fn run_assist(
|
pub(super) async fn run_assist(
|
||||||
prompt: String,
|
prompt: String,
|
||||||
model_override: Option<String>,
|
model_override: Option<String>,
|
||||||
req_id: u64,
|
req_id: u64,
|
||||||
asker_contact_id: u32,
|
asker_contact_id: u32,
|
||||||
sender_name: String,
|
sender_name: String,
|
||||||
|
// Whether the asker's message was cryptographically authenticated (a
|
||||||
|
// verified signature, or arrival over the federation transport). Required
|
||||||
|
// for any identity-based allow under `trusted_only`/the allowlist.
|
||||||
|
authenticated: bool,
|
||||||
reply: AssistReply,
|
reply: AssistReply,
|
||||||
state: Arc<MeshState>,
|
state: Arc<MeshState>,
|
||||||
) {
|
) {
|
||||||
let asker = asker_contact_id;
|
let asker = asker_contact_id;
|
||||||
|
|
||||||
// Trust + block gate.
|
// Trust + block gate.
|
||||||
if !is_sender_allowed(&state, asker).await {
|
if !is_sender_allowed(&state, asker, authenticated).await {
|
||||||
warn!(
|
warn!(
|
||||||
from = asker,
|
from = asker,
|
||||||
name = %sender_name,
|
name = %sender_name,
|
||||||
"AssistQuery denied — sender not permitted by assistant policy"
|
"AssistQuery denied — sender not permitted by assistant policy"
|
||||||
);
|
);
|
||||||
|
// Record who was turned away so the operator can find + allow them from
|
||||||
|
// the UI (the silent-on-wire denial otherwise only shows in the journal).
|
||||||
|
record_denied(&state, asker, &sender_name).await;
|
||||||
// Silent on the wire (no airtime spent on denials); surface to the UI.
|
// Silent on the wire (no airtime spent on denials); surface to the UI.
|
||||||
let _ = state
|
let _ = state
|
||||||
.event_tx
|
.event_tx
|
||||||
@ -144,13 +163,28 @@ pub(super) async fn run_assist(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `sender_contact_id` may invoke the assistant under the node's policy.
|
/// Whether `sender_contact_id` may invoke the assistant under the node's policy.
|
||||||
/// Always denies user-blocked contacts. With `trusted_only`, requires a
|
///
|
||||||
/// federation-Trusted match on the peer's pubkey or DID.
|
/// Always denies user-blocked contacts. Identity-based allows (the per-contact
|
||||||
async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bool {
|
/// allowlist and the federation-Trusted match) require `authenticated == true` —
|
||||||
|
/// i.e. the asker's message carried a signature that verified against its known
|
||||||
|
/// key (or it arrived over the federation transport, which verifies upstream).
|
||||||
|
/// A bare radio packet can CLAIM any key or DID, so without that proof the
|
||||||
|
/// allowlist and trust list are spoofable; only the explicit "anyone on the
|
||||||
|
/// mesh" policy (`trusted_only == false`) admits an unauthenticated asker.
|
||||||
|
async fn is_sender_allowed(
|
||||||
|
state: &Arc<MeshState>,
|
||||||
|
sender_contact_id: u32,
|
||||||
|
authenticated: bool,
|
||||||
|
) -> bool {
|
||||||
let (pubkey_hex, did) = {
|
let (pubkey_hex, did) = {
|
||||||
let peers = state.peers.read().await;
|
let peers = state.peers.read().await;
|
||||||
match peers.get(&sender_contact_id) {
|
match peers.get(&sender_contact_id) {
|
||||||
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
|
// Match identity on the bound archipelago key (stable, advert/
|
||||||
|
// federation-verified), not the firmware routing key.
|
||||||
|
Some(p) => (
|
||||||
|
p.identity_pubkey_hex().map(|s| s.to_string()),
|
||||||
|
p.did.clone(),
|
||||||
|
),
|
||||||
None => (None, None),
|
None => (None, None),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -169,11 +203,35 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Explicit per-contact allowlist: the operator deliberately ticked THIS
|
||||||
|
// contact, so honour it even for an unauthenticated radio asker. A stock
|
||||||
|
// meshcore client (e.g. a phone) can't sign our typed envelopes, so it can
|
||||||
|
// never be `authenticated` — gating the allowlist on authentication made
|
||||||
|
// ticking such a contact have no effect. We match the asker's resolved
|
||||||
|
// identity key: the bound archipelago key if we know it, else the firmware
|
||||||
|
// routing key (`pubkey_hex`), which is how meshcore addresses the contact
|
||||||
|
// and what the UI adds to the allowlist for a keyless radio peer. This is a
|
||||||
|
// narrow, explicit opt-in for a specific key — the spoofable federation-
|
||||||
|
// trust-list match below still requires authentication.
|
||||||
|
if let Some(ref pk) = pubkey_hex {
|
||||||
|
let allowed = state.assistant.read().await.allowed_contacts.clone();
|
||||||
|
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !state.assistant.read().await.trusted_only {
|
if !state.assistant.read().await.trusted_only {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trusted-only: match against the federation trust list.
|
// Trusted-only from here: an unauthenticated asker can never match the trust
|
||||||
|
// list (it could otherwise just claim a trusted node's public key/DID).
|
||||||
|
if !authenticated {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match against the federation trust list by the asker's verified archipelago
|
||||||
|
// pubkey or DID (a radio peer gets these from its signed identity advert).
|
||||||
let nodes = crate::federation::load_nodes(&state.data_dir)
|
let nodes = crate::federation::load_nodes(&state.data_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@ -183,6 +241,36 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Newest-first cap on the denied-asker buffer — enough to surface the people
|
||||||
|
/// who recently tried, without unbounded growth from a spammer.
|
||||||
|
const MAX_DENIED_ASKERS: usize = 25;
|
||||||
|
|
||||||
|
/// Record a turned-away `!ai` asker so the UI can offer a one-click "Allow".
|
||||||
|
/// Dedupes by contact id (moves an existing entry to the front and refreshes its
|
||||||
|
/// timestamp/name) so repeated denials from one device don't flood the list.
|
||||||
|
async fn record_denied(state: &Arc<MeshState>, asker_contact_id: u32, sender_name: &str) {
|
||||||
|
// Capture the bound archipelago identity key (NOT the firmware routing key):
|
||||||
|
// one-click "Allow" adds this to the allowlist, which the gate matches on the
|
||||||
|
// archipelago key. A peer with no advert has no arch key → None → the UI shows
|
||||||
|
// "no key" (only the "anyone on the mesh" policy can admit it).
|
||||||
|
let pubkey_hex = {
|
||||||
|
let peers = state.peers.read().await;
|
||||||
|
peers
|
||||||
|
.get(&asker_contact_id)
|
||||||
|
.and_then(|p| p.arch_pubkey_hex.clone())
|
||||||
|
};
|
||||||
|
let entry = super::DeniedAsker {
|
||||||
|
contact_id: asker_contact_id,
|
||||||
|
name: sender_name.to_string(),
|
||||||
|
pubkey_hex,
|
||||||
|
at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
};
|
||||||
|
let mut denied = state.assist_denied.write().await;
|
||||||
|
denied.retain(|d| d.contact_id != asker_contact_id);
|
||||||
|
denied.push_front(entry);
|
||||||
|
denied.truncate(MAX_DENIED_ASKERS);
|
||||||
|
}
|
||||||
|
|
||||||
/// Cap the answer to `MAX_REPLY_CHARS`, appending a marker when truncated.
|
/// Cap the answer to `MAX_REPLY_CHARS`, appending a marker when truncated.
|
||||||
/// Returns (text_to_send, was_truncated).
|
/// Returns (text_to_send, was_truncated).
|
||||||
fn cap_reply(answer: &str) -> (String, bool) {
|
fn cap_reply(answer: &str) -> (String, bool) {
|
||||||
@ -205,6 +293,19 @@ async fn send_reply(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, an
|
|||||||
let text = cap_channel(answer);
|
let text = cap_channel(answer);
|
||||||
send_channel_text(state, *channel, &text).await;
|
send_channel_text(state, *channel, &text).await;
|
||||||
}
|
}
|
||||||
|
AssistReply::ChatText { contact_id } => {
|
||||||
|
let (text, _) = cap_reply(answer);
|
||||||
|
send_chat_text(state, *contact_id, &text).await;
|
||||||
|
}
|
||||||
|
AssistReply::RadioDm { dest_prefix } => {
|
||||||
|
let text = cap_channel(answer);
|
||||||
|
let _ = state
|
||||||
|
.send_cmd(MeshCommand::SendNativeText {
|
||||||
|
dest_pubkey_prefix: *dest_prefix,
|
||||||
|
payload: text.into_bytes(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,6 +325,17 @@ async fn send_failure(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64,
|
|||||||
AssistReply::ChannelText { channel } => {
|
AssistReply::ChannelText { channel } => {
|
||||||
send_channel_text(state, *channel, &format!("AI: {msg}")).await;
|
send_channel_text(state, *channel, &format!("AI: {msg}")).await;
|
||||||
}
|
}
|
||||||
|
AssistReply::ChatText { contact_id } => {
|
||||||
|
send_chat_text(state, *contact_id, &format!("AI: {msg}")).await;
|
||||||
|
}
|
||||||
|
AssistReply::RadioDm { dest_prefix } => {
|
||||||
|
let _ = state
|
||||||
|
.send_cmd(MeshCommand::SendNativeText {
|
||||||
|
dest_pubkey_prefix: *dest_prefix,
|
||||||
|
payload: format!("AI: {msg}").into_bytes(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -272,6 +384,23 @@ async fn send_typed_response(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send the answer back into the 1:1 chat thread as a normal chat bubble.
|
||||||
|
/// Used for the `!ai`-in-chat path. We emit an `AssistChatReply` event rather
|
||||||
|
/// than sending here, because the reply must be routed transport-aware:
|
||||||
|
/// `!ai` can arrive over LoRa OR over federation (Tor), and only
|
||||||
|
/// `MeshService::send_message` (which owns the signing key + Tor client) knows
|
||||||
|
/// to POST over the peer's onion for a federation-synthetic contact_id. The
|
||||||
|
/// radio-only path used to drop the reply for federation askers — the answer
|
||||||
|
/// showed on the answering node but never reached the asker. A server-layer
|
||||||
|
/// consumer fulfils this event via `send_message`, which also records the
|
||||||
|
/// Sent bubble and allocates the seq.
|
||||||
|
async fn send_chat_text(state: &Arc<MeshState>, contact_id: u32, text: &str) {
|
||||||
|
let _ = state.event_tx.send(MeshEvent::AssistChatReply {
|
||||||
|
contact_id,
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Broadcast a plain-text answer on a channel for bare `!ai` clients.
|
/// Broadcast a plain-text answer on a channel for bare `!ai` clients.
|
||||||
async fn send_channel_text(state: &Arc<MeshState>, channel: u8, text: &str) {
|
async fn send_channel_text(state: &Arc<MeshState>, channel: u8, text: &str) {
|
||||||
let _ = state
|
let _ = state
|
||||||
|
|||||||
@ -314,17 +314,66 @@ pub(super) async fn try_chunk_reassemble(
|
|||||||
|
|
||||||
/// Look up a peer by pubkey hex prefix. Returns (contact_id, display_name).
|
/// Look up a peer by pubkey hex prefix. Returns (contact_id, display_name).
|
||||||
pub(super) async fn resolve_peer(state: &Arc<MeshState>, sender_prefix: &str) -> (u32, String) {
|
pub(super) async fn resolve_peer(state: &Arc<MeshState>, sender_prefix: &str) -> (u32, String) {
|
||||||
let peers = state.peers.read().await;
|
{
|
||||||
peers
|
let peers = state.peers.read().await;
|
||||||
.values()
|
if let Some(peer) = peers.values().find(|p| {
|
||||||
.find(|p| {
|
|
||||||
p.pubkey_hex
|
p.pubkey_hex
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|k| k.starts_with(sender_prefix))
|
.map(|k| k.starts_with(sender_prefix))
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
})
|
}) {
|
||||||
.map(|p| (p.contact_id, p.advert_name.clone()))
|
return (peer.contact_id, peer.advert_name.clone());
|
||||||
.unwrap_or((0, sender_prefix.to_string()))
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((node_num, pubkey_hex, name)) = meshtastic_peer_from_prefix(sender_prefix) {
|
||||||
|
let peer = MeshPeer {
|
||||||
|
contact_id: node_num,
|
||||||
|
advert_name: name.clone(),
|
||||||
|
did: None,
|
||||||
|
pubkey_hex: Some(pubkey_hex),
|
||||||
|
arch_pubkey_hex: None,
|
||||||
|
x25519_pubkey: None,
|
||||||
|
rssi: None,
|
||||||
|
snr: None,
|
||||||
|
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||||
|
hops: 0xff,
|
||||||
|
last_advert: 0,
|
||||||
|
reachable: true,
|
||||||
|
};
|
||||||
|
let is_new = {
|
||||||
|
let mut peers = state.peers.write().await;
|
||||||
|
peers.insert(node_num, peer.clone()).is_none()
|
||||||
|
};
|
||||||
|
state.update_peer_count().await;
|
||||||
|
let _ = state.event_tx.send(if is_new {
|
||||||
|
MeshEvent::PeerDiscovered(peer)
|
||||||
|
} else {
|
||||||
|
MeshEvent::PeerUpdated(peer)
|
||||||
|
});
|
||||||
|
return (node_num, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
(0, sender_prefix.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meshtastic_peer_from_prefix(sender_prefix: &str) -> Option<(u32, String, String)> {
|
||||||
|
if sender_prefix.len() < 12 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bytes = hex::decode(&sender_prefix[..12]).ok()?;
|
||||||
|
if bytes.len() != 6 || bytes[4] != b'm' || bytes[5] != b'e' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let node_num = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
||||||
|
if node_num == 0 || node_num == u32::MAX {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut full_key = [0u8; 32];
|
||||||
|
full_key[..4].copy_from_slice(&node_num.to_le_bytes());
|
||||||
|
full_key[4..15].copy_from_slice(b"meshtastic:");
|
||||||
|
let name = format!("Meshtastic !{:08x}", node_num);
|
||||||
|
Some((node_num, hex::encode(full_key), name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store a plain-text (non-typed) message and emit an event.
|
/// Store a plain-text (non-typed) message and emit an event.
|
||||||
@ -333,6 +382,16 @@ pub(super) async fn store_plain_message(
|
|||||||
contact_id: u32,
|
contact_id: u32,
|
||||||
peer_name: &str,
|
peer_name: &str,
|
||||||
text: &str,
|
text: &str,
|
||||||
|
) {
|
||||||
|
store_plain_message_with_encryption(state, contact_id, peer_name, text, false).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn store_plain_message_with_encryption(
|
||||||
|
state: &Arc<MeshState>,
|
||||||
|
contact_id: u32,
|
||||||
|
peer_name: &str,
|
||||||
|
text: &str,
|
||||||
|
encrypted: bool,
|
||||||
) {
|
) {
|
||||||
let msg_id = state.next_id().await;
|
let msg_id = state.next_id().await;
|
||||||
let msg = MeshMessage {
|
let msg = MeshMessage {
|
||||||
@ -343,7 +402,8 @@ pub(super) async fn store_plain_message(
|
|||||||
plaintext: text.to_string(),
|
plaintext: text.to_string(),
|
||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
delivered: true,
|
delivered: true,
|
||||||
encrypted: false,
|
encrypted,
|
||||||
|
transport: Some("lora".to_string()),
|
||||||
message_type: "text".to_string(),
|
message_type: "text".to_string(),
|
||||||
typed_payload: None,
|
typed_payload: None,
|
||||||
sender_pubkey: None,
|
sender_pubkey: None,
|
||||||
@ -353,26 +413,40 @@ pub(super) async fn store_plain_message(
|
|||||||
state.status.write().await.messages_received += 1;
|
state.status.write().await.messages_received += 1;
|
||||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||||
|
|
||||||
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` on the
|
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` is answered
|
||||||
// channel is answered by this node's local model when the assistant is on.
|
// by this node's local model when the assistant is on. The trust/rate gate
|
||||||
// Reply goes back as plain channel text so bare (non-archipelago) clients
|
// lives in run_assist. The reply goes back as a private NATIVE DM to the
|
||||||
// see it. The trust/rate gate lives in run_assist.
|
// asker whenever we know its radio pubkey (so it does NOT land on the public
|
||||||
|
// channel and a stock meshcore client can read it); we only fall back to a
|
||||||
|
// channel reply if the sender has no resolvable pubkey (rare).
|
||||||
if state.assistant.read().await.enabled {
|
if state.assistant.read().await.enabled {
|
||||||
if let Some(prompt) = strip_ai_trigger(text) {
|
if let Some(prompt) = strip_ai_trigger(text) {
|
||||||
if !prompt.is_empty() {
|
if !prompt.is_empty() {
|
||||||
|
let reply = {
|
||||||
|
let peers = state.peers.read().await;
|
||||||
|
peers
|
||||||
|
.get(&contact_id)
|
||||||
|
.and_then(|p| p.pubkey_hex.clone())
|
||||||
|
.filter(|h| h.len() >= 12)
|
||||||
|
.and_then(|h| hex::decode(&h[..12]).ok())
|
||||||
|
.filter(|b| b.len() == 6)
|
||||||
|
.map(|b| {
|
||||||
|
let mut pre = [0u8; 6];
|
||||||
|
pre.copy_from_slice(&b);
|
||||||
|
super::assist::AssistReply::RadioDm { dest_prefix: pre }
|
||||||
|
})
|
||||||
|
.unwrap_or(super::assist::AssistReply::ChannelText { channel: 0 })
|
||||||
|
};
|
||||||
let req_id = state.next_id().await;
|
let req_id = state.next_id().await;
|
||||||
let prompt = prompt.to_string();
|
let prompt = prompt.to_string();
|
||||||
let name = peer_name.to_string();
|
let name = peer_name.to_string();
|
||||||
let st = Arc::clone(state);
|
let st = Arc::clone(state);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
// A bare plain-text channel `!ai` carries no signature, so it
|
||||||
|
// is NOT authenticated — under trusted_only it'll be denied,
|
||||||
|
// and it can only be answered under the "anyone" policy.
|
||||||
super::assist::run_assist(
|
super::assist::run_assist(
|
||||||
prompt,
|
prompt, None, req_id, contact_id, name, false, reply, st,
|
||||||
None,
|
|
||||||
req_id,
|
|
||||||
contact_id,
|
|
||||||
name,
|
|
||||||
super::assist::AssistReply::ChannelText { channel: 0 },
|
|
||||||
st,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
@ -383,7 +457,7 @@ pub(super) async fn store_plain_message(
|
|||||||
|
|
||||||
/// Recognise a `!ai`/`!ask ` command prefix (case-insensitive) and return the
|
/// Recognise a `!ai`/`!ask ` command prefix (case-insensitive) and return the
|
||||||
/// trimmed question after it, or `None` if the text isn't an AI command.
|
/// trimmed question after it, or `None` if the text isn't an AI command.
|
||||||
fn strip_ai_trigger(text: &str) -> Option<&str> {
|
pub(super) fn strip_ai_trigger(text: &str) -> Option<&str> {
|
||||||
let t = text.trim_start();
|
let t = text.trim_start();
|
||||||
for p in ["!ai ", "!ask "] {
|
for p in ["!ai ", "!ask "] {
|
||||||
if t.len() >= p.len() && t[..p.len()].eq_ignore_ascii_case(p) {
|
if t.len() >= p.len() && t[..p.len()].eq_ignore_ascii_case(p) {
|
||||||
@ -475,11 +549,18 @@ pub(super) async fn handle_identity_received(
|
|||||||
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
|
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
|
||||||
did: Some(did.to_string()),
|
did: Some(did.to_string()),
|
||||||
pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||||
|
// The advert signature was verified above, so this is an authenticated
|
||||||
|
// archipelago identity. Bind it separately so a later refresh_contacts
|
||||||
|
// (which rewrites pubkey_hex to the firmware routing key) can't drop it.
|
||||||
|
arch_pubkey_hex: Some(ed_pubkey_hex.to_string()),
|
||||||
x25519_pubkey: Some(x25519_bytes),
|
x25519_pubkey: Some(x25519_bytes),
|
||||||
rssi: Some(rssi),
|
rssi: Some(rssi),
|
||||||
snr: None,
|
snr: None,
|
||||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||||
hops: 0,
|
hops: 0,
|
||||||
|
last_advert: 0,
|
||||||
|
// We just heard this peer's identity advert, so it's reachable.
|
||||||
|
reachable: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_new = {
|
let is_new = {
|
||||||
@ -555,6 +636,7 @@ pub(super) async fn handle_received_message(
|
|||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
delivered: true,
|
delivered: true,
|
||||||
encrypted,
|
encrypted,
|
||||||
|
transport: Some("lora".to_string()),
|
||||||
message_type: "text".to_string(),
|
message_type: "text".to_string(),
|
||||||
typed_payload: None,
|
typed_payload: None,
|
||||||
sender_pubkey: None,
|
sender_pubkey: None,
|
||||||
|
|||||||
@ -34,7 +34,10 @@ async fn store_typed_message(
|
|||||||
plaintext: display_text.to_string(),
|
plaintext: display_text.to_string(),
|
||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
delivered: true,
|
delivered: true,
|
||||||
|
// transport + E2E are stamped post-dispatch by
|
||||||
|
// handle_typed_envelope_direct, which alone knows the receive transport.
|
||||||
encrypted: false,
|
encrypted: false,
|
||||||
|
transport: None,
|
||||||
message_type: type_label.to_string(),
|
message_type: type_label.to_string(),
|
||||||
typed_payload,
|
typed_payload,
|
||||||
sender_pubkey,
|
sender_pubkey,
|
||||||
@ -70,7 +73,67 @@ pub(super) async fn handle_typed_message(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Radio-delivered → "lora". Stamp after dispatch (see stamp helper).
|
||||||
|
let before = max_message_id(state).await;
|
||||||
handle_typed_envelope_direct(state, sender_contact_id, sender_name, envelope).await;
|
handle_typed_envelope_direct(state, sender_contact_id, sender_name, envelope).await;
|
||||||
|
stamp_received_transport(state, sender_contact_id, before, "lora", false).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highest stored message id right now. Paired with `stamp_received_transport`
|
||||||
|
/// to identify messages a dispatch call just stored (ids are monotonic).
|
||||||
|
pub(crate) async fn max_message_id(state: &Arc<MeshState>) -> u64 {
|
||||||
|
state
|
||||||
|
.messages
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.id)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stamp the per-message transport pill (and E2E flag) onto every RECEIVED
|
||||||
|
/// message from `sender_contact_id` stored since `after_id` — i.e. the ones the
|
||||||
|
/// just-completed `handle_typed_envelope_direct` produced. This is how both the
|
||||||
|
/// radio path ("lora") and the federation path ("fips"/"tor") tag inbound
|
||||||
|
/// messages without threading transport through all 20 typed-dispatch sites.
|
||||||
|
/// `encrypted` only ever sets the flag true (a federation envelope is E2E),
|
||||||
|
/// never clears a true set elsewhere.
|
||||||
|
pub(crate) async fn stamp_received_transport(
|
||||||
|
state: &Arc<MeshState>,
|
||||||
|
sender_contact_id: u32,
|
||||||
|
after_id: u64,
|
||||||
|
transport: &str,
|
||||||
|
encrypted: bool,
|
||||||
|
) {
|
||||||
|
let mut messages = state.messages.write().await;
|
||||||
|
for m in messages.iter_mut() {
|
||||||
|
if m.id > after_id
|
||||||
|
&& matches!(m.direction, MessageDirection::Received)
|
||||||
|
&& m.peer_contact_id == sender_contact_id
|
||||||
|
{
|
||||||
|
if m.transport.is_none() {
|
||||||
|
m.transport = Some(transport.to_string());
|
||||||
|
}
|
||||||
|
if encrypted {
|
||||||
|
m.encrypted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark every RECEIVED message stored since `after_id` as end-to-end encrypted.
|
||||||
|
/// Used by the session loop to stamp the E2E pill on a meshtastic frame the radio
|
||||||
|
/// reported PKI-encrypted (the synthetic frame can't carry that flag, and the
|
||||||
|
/// typed-dispatch store path defaults `encrypted` to false). One inbound frame
|
||||||
|
/// yields at most one received message, so no sender filter is needed.
|
||||||
|
pub(crate) async fn stamp_received_encrypted(state: &Arc<MeshState>, after_id: u64) {
|
||||||
|
let mut messages = state.messages.write().await;
|
||||||
|
for m in messages.iter_mut() {
|
||||||
|
if m.id > after_id && matches!(m.direction, MessageDirection::Received) {
|
||||||
|
m.encrypted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch a pre-decoded TypedEnvelope. Shared between the radio receive
|
/// Dispatch a pre-decoded TypedEnvelope. Shared between the radio receive
|
||||||
@ -83,14 +146,22 @@ pub(crate) async fn handle_typed_envelope_direct(
|
|||||||
sender_name: &str,
|
sender_name: &str,
|
||||||
envelope: TypedEnvelope,
|
envelope: TypedEnvelope,
|
||||||
) {
|
) {
|
||||||
// Verify envelope signature if present, using the sender's known Ed25519 key
|
// Verify the envelope signature (if present) against the sender's known
|
||||||
|
// Ed25519 key, and record whether the sender is cryptographically
|
||||||
|
// authenticated. A federation peer (synthetic high-half contact_id) arrived
|
||||||
|
// over the Tor relay, which verifies the sender signature upstream before
|
||||||
|
// injecting here, so it counts as authenticated. This flag gates the
|
||||||
|
// identity-based `!ai` allows (allowlist / federation-trust) downstream.
|
||||||
|
let mut authenticated = sender_contact_id >= crate::mesh::FEDERATION_CONTACT_ID_BASE;
|
||||||
if envelope.sig.is_some() {
|
if envelope.sig.is_some() {
|
||||||
let peer_pubkey = state
|
let peer_pubkey = state
|
||||||
.peers
|
.peers
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.get(&sender_contact_id)
|
.get(&sender_contact_id)
|
||||||
.and_then(|p| p.pubkey_hex.as_ref())
|
// Verify against the bound archipelago identity key, not the
|
||||||
|
// firmware routing key — only the former is what the peer signs with.
|
||||||
|
.and_then(|p| p.identity_pubkey_hex())
|
||||||
.and_then(|hex_str| hex::decode(hex_str).ok())
|
.and_then(|hex_str| hex::decode(hex_str).ok())
|
||||||
.and_then(|bytes| {
|
.and_then(|bytes| {
|
||||||
if bytes.len() == 32 {
|
if bytes.len() == 32 {
|
||||||
@ -103,7 +174,9 @@ pub(crate) async fn handle_typed_envelope_direct(
|
|||||||
});
|
});
|
||||||
if let Some(vk) = peer_pubkey {
|
if let Some(vk) = peer_pubkey {
|
||||||
match envelope.verify_signature(&vk) {
|
match envelope.verify_signature(&vk) {
|
||||||
Ok(true) => {}
|
Ok(true) => {
|
||||||
|
authenticated = true;
|
||||||
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
warn!(
|
warn!(
|
||||||
peer = sender_contact_id,
|
peer = sender_contact_id,
|
||||||
@ -679,6 +752,37 @@ pub(crate) async fn handle_typed_envelope_direct(
|
|||||||
Some(envelope.seq),
|
Some(envelope.seq),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// Mesh-AI assistant (issue #50): a `!ai`/`!ask <question>` typed in
|
||||||
|
// the normal 1:1 chat triggers this node's assistant, with the
|
||||||
|
// answer sent back as a chat bubble in the same thread. The typed
|
||||||
|
// DM carries the peer's federation identity (via sender_contact_id),
|
||||||
|
// so the `trusted_only` gate in run_assist resolves correctly —
|
||||||
|
// unlike the bare channel-text path, which only knows the radio key.
|
||||||
|
if state.assistant.read().await.enabled {
|
||||||
|
if let Some(prompt) = super::decode::strip_ai_trigger(&text) {
|
||||||
|
if !prompt.is_empty() {
|
||||||
|
let req_id = state.next_id().await;
|
||||||
|
let prompt = prompt.to_string();
|
||||||
|
let name = sender_name.to_string();
|
||||||
|
let cid = sender_contact_id;
|
||||||
|
let st = Arc::clone(state);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
super::assist::run_assist(
|
||||||
|
prompt,
|
||||||
|
None,
|
||||||
|
req_id,
|
||||||
|
cid,
|
||||||
|
name,
|
||||||
|
authenticated,
|
||||||
|
super::assist::AssistReply::ChatText { contact_id: cid },
|
||||||
|
st,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(MeshMessageType::AssistQuery) => {
|
Some(MeshMessageType::AssistQuery) => {
|
||||||
@ -718,6 +822,7 @@ pub(crate) async fn handle_typed_envelope_direct(
|
|||||||
query.req_id,
|
query.req_id,
|
||||||
sender_contact_id,
|
sender_contact_id,
|
||||||
name,
|
name,
|
||||||
|
authenticated,
|
||||||
super::assist::AssistReply::Typed {
|
super::assist::AssistReply::Typed {
|
||||||
contact_id: sender_contact_id,
|
contact_id: sender_contact_id,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,7 +4,8 @@ use super::super::message_types::TypedEnvelope;
|
|||||||
use super::super::protocol;
|
use super::super::protocol;
|
||||||
use super::decode::{
|
use super::decode::{
|
||||||
handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message,
|
handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message,
|
||||||
try_base64_typed, try_chunk_reassemble, try_decrypt_base64, try_decrypt_ratchet_base64,
|
store_plain_message_with_encryption, try_base64_typed, try_chunk_reassemble,
|
||||||
|
try_decrypt_base64, try_decrypt_ratchet_base64,
|
||||||
};
|
};
|
||||||
use super::dispatch::handle_typed_message;
|
use super::dispatch::handle_typed_message;
|
||||||
use super::MeshState;
|
use super::MeshState;
|
||||||
@ -22,8 +23,33 @@ pub(super) async fn handle_frame(
|
|||||||
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
|
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
|
||||||
info!(
|
info!(
|
||||||
code = frame.code,
|
code = frame.code,
|
||||||
|
data_len = frame.data.len(),
|
||||||
"Contact discovery event — refreshing contacts"
|
"Contact discovery event — refreshing contacts"
|
||||||
);
|
);
|
||||||
|
// Auto-import: a PUSH_CONTACT_ADVERT (0x80) carries the 32-byte
|
||||||
|
// pubkey of a node we just heard. If it isn't already a contact,
|
||||||
|
// add it to the firmware table so it shows up immediately — no
|
||||||
|
// flood-advert dance required. (PUSH_NEW_CONTACT/0x8A is already
|
||||||
|
// added by the firmware, so we skip it.)
|
||||||
|
if frame.code == protocol::PUSH_CONTACT_ADVERT && frame.data.len() >= 32 {
|
||||||
|
let mut pubkey = [0u8; 32];
|
||||||
|
pubkey.copy_from_slice(&frame.data[..32]);
|
||||||
|
let pk_hex = hex::encode(pubkey);
|
||||||
|
let known = state
|
||||||
|
.peers
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.any(|p| p.pubkey_hex.as_deref() == Some(pk_hex.as_str()));
|
||||||
|
if !known {
|
||||||
|
let _ = state
|
||||||
|
.send_cmd(super::MeshCommand::AddContact {
|
||||||
|
pubkey,
|
||||||
|
name: String::new(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true; // Signal caller to fetch contacts
|
return true; // Signal caller to fetch contacts
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,11 +63,12 @@ pub(super) async fn handle_frame(
|
|||||||
return true; // Signal caller to sync immediately
|
return true; // Signal caller to sync immediately
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol::RESP_CONTACT_MSG_V3 => {
|
protocol::RESP_CONTACT_MSG_V3 | protocol::RESP_CONTACT_MSG_V3_E2E => {
|
||||||
// Direct message received (v3 format) — check for typed envelope first
|
// Direct message received (v3 format) — check for typed envelope first
|
||||||
match protocol::parse_contact_msg_v3_raw(&frame.data) {
|
match protocol::parse_contact_msg_v3_raw(&frame.data) {
|
||||||
Ok((sender_prefix, payload, _snr)) => {
|
Ok((sender_prefix, payload, _snr)) => {
|
||||||
if !payload.is_empty() {
|
if !payload.is_empty() {
|
||||||
|
let encrypted = frame.code == protocol::RESP_CONTACT_MSG_V3_E2E;
|
||||||
let (contact_id, name) = resolve_peer(state, &sender_prefix).await;
|
let (contact_id, name) = resolve_peer(state, &sender_prefix).await;
|
||||||
if TypedEnvelope::is_typed(&payload) {
|
if TypedEnvelope::is_typed(&payload) {
|
||||||
handle_typed_message(&payload, contact_id, &name, state).await;
|
handle_typed_message(&payload, contact_id, &name, state).await;
|
||||||
@ -61,7 +88,10 @@ pub(super) async fn handle_frame(
|
|||||||
handle_typed_message(&decoded, contact_id, &name, state).await;
|
handle_typed_message(&decoded, contact_id, &name, state).await;
|
||||||
} else if !payload.starts_with(b"MC") {
|
} else if !payload.starts_with(b"MC") {
|
||||||
let text = String::from_utf8_lossy(&payload).to_string();
|
let text = String::from_utf8_lossy(&payload).to_string();
|
||||||
store_plain_message(state, contact_id, &name, &text).await;
|
store_plain_message_with_encryption(
|
||||||
|
state, contact_id, &name, &text, encrypted,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
info!(from = %sender_prefix, "Received mesh DM (v3)");
|
info!(from = %sender_prefix, "Received mesh DM (v3)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -108,8 +138,14 @@ pub(super) async fn handle_frame(
|
|||||||
match protocol::parse_channel_msg_v3_raw(&frame.data) {
|
match protocol::parse_channel_msg_v3_raw(&frame.data) {
|
||||||
Ok((channel_idx, payload)) => {
|
Ok((channel_idx, payload)) => {
|
||||||
if !payload.is_empty() {
|
if !payload.is_empty() {
|
||||||
handle_channel_payload(state, channel_idx, &payload, our_x25519_secret)
|
handle_channel_payload(
|
||||||
.await;
|
state,
|
||||||
|
channel_idx,
|
||||||
|
&payload,
|
||||||
|
our_x25519_secret,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => warn!("Failed to parse v3 channel message: {}", e),
|
Err(e) => warn!("Failed to parse v3 channel message: {}", e),
|
||||||
@ -121,14 +157,44 @@ pub(super) async fn handle_frame(
|
|||||||
match protocol::parse_channel_msg_v1_raw(&frame.data) {
|
match protocol::parse_channel_msg_v1_raw(&frame.data) {
|
||||||
Ok((channel_idx, payload)) => {
|
Ok((channel_idx, payload)) => {
|
||||||
if !payload.is_empty() {
|
if !payload.is_empty() {
|
||||||
handle_channel_payload(state, channel_idx, &payload, our_x25519_secret)
|
handle_channel_payload(
|
||||||
.await;
|
state,
|
||||||
|
channel_idx,
|
||||||
|
&payload,
|
||||||
|
our_x25519_secret,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => warn!("Failed to parse channel message: {}", e),
|
Err(e) => warn!("Failed to parse channel message: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Synthetic Meshtastic channel broadcast that carries its sender:
|
||||||
|
// `[channel_idx: u8][sender_pubkey_prefix: 6 bytes][text…]`. Resolve the
|
||||||
|
// sender to a friendly name, then file the message under the channel
|
||||||
|
// thread attributed to them — this is what makes the default public
|
||||||
|
// LongFast channel actually show inbound traffic (and who sent it).
|
||||||
|
protocol::RESP_MESHTASTIC_CHANNEL_TEXT => {
|
||||||
|
if frame.data.len() > 7 {
|
||||||
|
let channel_idx = frame.data[0];
|
||||||
|
let sender_prefix_hex = hex::encode(&frame.data[1..7]);
|
||||||
|
let payload = frame.data[7..].to_vec();
|
||||||
|
if !payload.is_empty() {
|
||||||
|
let (_cid, name) = resolve_peer(state, &sender_prefix_hex).await;
|
||||||
|
handle_channel_payload(
|
||||||
|
state,
|
||||||
|
channel_idx,
|
||||||
|
&payload,
|
||||||
|
our_x25519_secret,
|
||||||
|
Some(name),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protocol::PUSH_LOG_DATA | protocol::PUSH_PATH_UPDATE | protocol::PUSH_RAW_DATA => {
|
protocol::PUSH_LOG_DATA | protocol::PUSH_PATH_UPDATE | protocol::PUSH_RAW_DATA => {
|
||||||
// Internal device logging/path data — safe to ignore
|
// Internal device logging/path data — safe to ignore
|
||||||
}
|
}
|
||||||
@ -152,6 +218,12 @@ async fn handle_channel_payload(
|
|||||||
channel_idx: u8,
|
channel_idx: u8,
|
||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
our_x25519_secret: &[u8; 32],
|
our_x25519_secret: &[u8; 32],
|
||||||
|
// When the transport knows who sent this channel broadcast (Meshtastic
|
||||||
|
// packets carry the originating node), the plain-text/typed message is filed
|
||||||
|
// under the channel thread but attributed to this sender name. Meshcore
|
||||||
|
// channel frames carry no sender, so they pass `None` and fall back to a
|
||||||
|
// generic "Channel N" label.
|
||||||
|
sender_name: Option<String>,
|
||||||
) {
|
) {
|
||||||
// DM-via-channel wrapper (text form): the channel text carries an
|
// DM-via-channel wrapper (text form): the channel text carries an
|
||||||
// ASCII "@DM:<base64>" token somewhere in the body. We locate the
|
// ASCII "@DM:<base64>" token somewhere in the body. We locate the
|
||||||
@ -360,15 +432,18 @@ async fn handle_channel_payload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular channel broadcast (not DM-wrapped)
|
// Regular channel broadcast (not DM-wrapped). File it under the channel
|
||||||
|
// thread (contact_id = u32::MAX - idx) but label it with the real sender
|
||||||
|
// when the transport gave us one (Meshtastic), so the channel view shows who
|
||||||
|
// said what. Meshcore frames have no sender → generic "Channel N".
|
||||||
let chan_contact_id = u32::MAX - (channel_idx as u32);
|
let chan_contact_id = u32::MAX - (channel_idx as u32);
|
||||||
let chan_name = format!("Channel {}", channel_idx);
|
let chan_name = sender_name.unwrap_or_else(|| format!("Channel {}", channel_idx));
|
||||||
if TypedEnvelope::is_typed(payload) {
|
if TypedEnvelope::is_typed(payload) {
|
||||||
handle_typed_message(payload, chan_contact_id, &chan_name, state).await;
|
handle_typed_message(payload, chan_contact_id, &chan_name, state).await;
|
||||||
} else {
|
} else {
|
||||||
let text = String::from_utf8_lossy(payload).to_string();
|
let text = String::from_utf8_lossy(payload).to_string();
|
||||||
store_plain_message(state, chan_contact_id, &chan_name, &text).await;
|
store_plain_message(state, chan_contact_id, &chan_name, &text).await;
|
||||||
info!(channel = channel_idx, "Received mesh channel message");
|
info!(channel = channel_idx, sender = %chan_name, "Received mesh channel message");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -63,14 +63,37 @@ pub enum MeshCommand {
|
|||||||
dest_pubkey_prefix: [u8; 6],
|
dest_pubkey_prefix: [u8; 6],
|
||||||
payload: Vec<u8>,
|
payload: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// Send PLAIN text as one or more native meshcore DMs to a stock client
|
||||||
|
/// (e.g. a phone). Long text is split into multiple readable plain messages
|
||||||
|
/// — never MC-chunked — because stock clients can't reassemble archy's
|
||||||
|
/// chunk framing. Used for chat/AI replies to non-archipelago contacts.
|
||||||
|
SendNativeText {
|
||||||
|
dest_pubkey_prefix: [u8; 6],
|
||||||
|
payload: Vec<u8>,
|
||||||
|
},
|
||||||
/// Broadcast pre-encoded binary on a mesh channel.
|
/// Broadcast pre-encoded binary on a mesh channel.
|
||||||
BroadcastChannel {
|
BroadcastChannel {
|
||||||
channel: u8,
|
channel: u8,
|
||||||
payload: Vec<u8>,
|
payload: Vec<u8>,
|
||||||
},
|
},
|
||||||
SendAdvert,
|
SendAdvert,
|
||||||
|
/// Reboot the locally-connected radio firmware to recover a wedged /
|
||||||
|
/// RX-deaf radio. Meshtastic-only; meshcore ignores it.
|
||||||
|
RebootRadio {
|
||||||
|
seconds: i64,
|
||||||
|
},
|
||||||
/// Re-fetch contact list from the radio device.
|
/// Re-fetch contact list from the radio device.
|
||||||
RefreshContacts,
|
RefreshContacts,
|
||||||
|
/// Delete a contact from the firmware table (clear-all / unreachable wipe).
|
||||||
|
RemoveContact {
|
||||||
|
pubkey: [u8; 32],
|
||||||
|
},
|
||||||
|
/// Import/add a heard advert as a firmware contact so it shows up without
|
||||||
|
/// needing a flood advert. Name may be empty (firmware fills from advert).
|
||||||
|
AddContact {
|
||||||
|
pubkey: [u8; 32],
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared state for the mesh listener, accessible from RPC handlers.
|
/// Shared state for the mesh listener, accessible from RPC handlers.
|
||||||
@ -135,6 +158,28 @@ pub struct MeshState {
|
|||||||
/// Contact-ids with an AI query currently being answered. Caps each asker to
|
/// Contact-ids with an AI query currently being answered. Caps each asker to
|
||||||
/// one in-flight query so a peer can't flood the node's compute / airtime.
|
/// one in-flight query so a peer can't flood the node's compute / airtime.
|
||||||
pub assist_inflight: RwLock<HashSet<u32>>,
|
pub assist_inflight: RwLock<HashSet<u32>>,
|
||||||
|
/// Recently-denied `!ai` askers (newest first, capped). When `trusted_only`
|
||||||
|
/// rejects a sender — typically a radio (meshcore) device that presents a
|
||||||
|
/// firmware key rather than an archipelago DID — we record who tried so the
|
||||||
|
/// UI can surface them and let the operator one-click allow their key.
|
||||||
|
/// Silent on the wire (no airtime spent), visible to the operator here.
|
||||||
|
pub assist_denied: RwLock<VecDeque<DeniedAsker>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `!ai` asker that the assistant policy turned away. Surfaced to the UI so
|
||||||
|
/// the operator can add their key to the allowlist without hunting the journal.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DeniedAsker {
|
||||||
|
/// Meshcore contact id of the asker.
|
||||||
|
pub contact_id: u32,
|
||||||
|
/// Best-known display name (advert name) at denial time.
|
||||||
|
pub name: String,
|
||||||
|
/// The asker's ed25519 pubkey hex, if known. `None` for a raw radio device
|
||||||
|
/// that hasn't advertised an archipelago key — such a sender can only be
|
||||||
|
/// admitted by switching the policy to "anyone", not via the allowlist.
|
||||||
|
pub pubkey_hex: Option<String>,
|
||||||
|
/// ISO-8601 timestamp of the (most recent) denial.
|
||||||
|
pub at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mesh-AI assistant configuration, snapshotted from `MeshConfig` at startup.
|
/// Mesh-AI assistant configuration, snapshotted from `MeshConfig` at startup.
|
||||||
@ -148,6 +193,10 @@ pub struct AssistantConfig {
|
|||||||
pub trusted_only: bool,
|
pub trusted_only: bool,
|
||||||
/// AI backend: "claude" (shared proxy token) or "ollama" (local model).
|
/// AI backend: "claude" (shared proxy token) or "ollama" (local model).
|
||||||
pub backend: String,
|
pub backend: String,
|
||||||
|
/// Per-contact allowlist (ed25519 pubkey hex) permitted to use `!ai`
|
||||||
|
/// regardless of `trusted_only`. Empty → only the `trusted_only` policy
|
||||||
|
/// applies. A user-blocked contact is always denied even if listed here.
|
||||||
|
pub allowed_contacts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to
|
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to
|
||||||
@ -226,6 +275,7 @@ impl MeshState {
|
|||||||
assistant: RwLock::new(assistant),
|
assistant: RwLock::new(assistant),
|
||||||
data_dir,
|
data_dir,
|
||||||
assist_inflight: RwLock::new(HashSet::new()),
|
assist_inflight: RwLock::new(HashSet::new()),
|
||||||
|
assist_denied: RwLock::new(VecDeque::new()),
|
||||||
});
|
});
|
||||||
(state, rx, cmd_rx)
|
(state, rx, cmd_rx)
|
||||||
}
|
}
|
||||||
@ -328,6 +378,8 @@ pub fn spawn_mesh_listener(
|
|||||||
our_x25519_secret: [u8; 32],
|
our_x25519_secret: [u8; 32],
|
||||||
our_x25519_pubkey_hex: String,
|
our_x25519_pubkey_hex: String,
|
||||||
server_name: Option<String>,
|
server_name: Option<String>,
|
||||||
|
lora_region: Option<String>,
|
||||||
|
channel_name: Option<String>,
|
||||||
shutdown: tokio::sync::watch::Receiver<bool>,
|
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||||
cmd_rx: mpsc::Receiver<MeshCommand>,
|
cmd_rx: mpsc::Receiver<MeshCommand>,
|
||||||
) -> tokio::task::JoinHandle<()> {
|
) -> tokio::task::JoinHandle<()> {
|
||||||
@ -349,6 +401,8 @@ pub fn spawn_mesh_listener(
|
|||||||
&our_x25519_secret,
|
&our_x25519_secret,
|
||||||
&our_x25519_pubkey_hex,
|
&our_x25519_pubkey_hex,
|
||||||
server_name.as_deref(),
|
server_name.as_deref(),
|
||||||
|
lora_region.as_deref(),
|
||||||
|
channel_name.as_deref(),
|
||||||
&mut shutdown,
|
&mut shutdown,
|
||||||
&mut cmd_rx,
|
&mut cmd_rx,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,7 +4,8 @@ use super::super::meshtastic::MeshtasticDevice;
|
|||||||
use super::super::serial::MeshcoreDevice;
|
use super::super::serial::MeshcoreDevice;
|
||||||
use super::super::types::*;
|
use super::super::types::*;
|
||||||
use super::{
|
use super::{
|
||||||
frames, MeshCommand, MeshState, ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES, SYNC_INTERVAL,
|
dispatch, frames, MeshCommand, MeshState, ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES,
|
||||||
|
SYNC_INTERVAL,
|
||||||
};
|
};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -39,6 +40,30 @@ impl MeshRadioDevice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Provision the operator-configured LoRa region. Meshcore radios manage
|
||||||
|
/// their own band on the device, so this is a no-op for them; Meshtastic
|
||||||
|
/// radios ship region-UNSET (RF-silent) and must be set or they never mesh.
|
||||||
|
/// Returns `Ok(true)` when a region was written (the device reboots to
|
||||||
|
/// apply, so the caller should restart the session).
|
||||||
|
async fn ensure_lora_region(&mut self, region: Option<&str>) -> Result<bool> {
|
||||||
|
match self {
|
||||||
|
Self::Meshcore(_) => Ok(false),
|
||||||
|
Self::Meshtastic(device) => device.ensure_lora_region(region).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provision the shared archy primary channel so all nodes can decode each
|
||||||
|
/// other. No-op for meshcore (it joins its channel by name on the device);
|
||||||
|
/// Meshtastic radios can sit on mismatched channels otherwise and silently
|
||||||
|
/// drop every packet as undecryptable. Returns `Ok(true)` when a channel was
|
||||||
|
/// written (device reboots; caller should restart the session).
|
||||||
|
async fn ensure_channel(&mut self, channel_name: Option<&str>) -> Result<bool> {
|
||||||
|
match self {
|
||||||
|
Self::Meshcore(_) => Ok(false),
|
||||||
|
Self::Meshtastic(device) => device.ensure_channel(channel_name).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_self_advert(&mut self) -> Result<()> {
|
async fn send_self_advert(&mut self) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
Self::Meshcore(device) => device.send_self_advert().await,
|
Self::Meshcore(device) => device.send_self_advert().await,
|
||||||
@ -46,6 +71,28 @@ impl MeshRadioDevice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lightweight serial keepalive (Meshtastic only). Keeps the firmware
|
||||||
|
/// streaming RECEIVED packets to our serial client — without it the radio
|
||||||
|
/// can mark a quiet client gone and deliver only our own queue-status.
|
||||||
|
/// Meshcore needs no such ping.
|
||||||
|
async fn send_keepalive(&mut self) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Meshcore(_) => Ok(()),
|
||||||
|
Self::Meshtastic(device) => device.send_keepalive().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actively advertise our identity over the air. Meshcore already does this
|
||||||
|
/// inside `send_self_advert` (CMD_SEND_SELF_ADVERT), so this is a no-op for
|
||||||
|
/// it; Meshtastic needs an explicit NodeInfo broadcast or peers never learn
|
||||||
|
/// about an already-running node.
|
||||||
|
async fn send_nodeinfo_advert(&mut self, want_response: bool) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Meshcore(_) => Ok(()),
|
||||||
|
Self::Meshtastic(device) => device.send_nodeinfo_broadcast(want_response).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_channel_text(&mut self, channel: u8, payload: &[u8]) -> Result<()> {
|
async fn send_channel_text(&mut self, channel: u8, payload: &[u8]) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
Self::Meshcore(device) => device.send_channel_text(channel, payload).await,
|
Self::Meshcore(device) => device.send_channel_text(channel, payload).await,
|
||||||
@ -53,6 +100,52 @@ impl MeshRadioDevice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], payload: &[u8]) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Meshcore(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
|
||||||
|
Self::Meshtastic(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reboot(&mut self, seconds: i64) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
// Meshcore has no equivalent local-admin reboot in our driver; the
|
||||||
|
// RX-deaf recovery this targets is Meshtastic-specific.
|
||||||
|
Self::Meshcore(_) => Ok(()),
|
||||||
|
Self::Meshtastic(device) => device.reboot(seconds).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Meshcore(device) => device.remove_contact(pubkey).await,
|
||||||
|
Self::Meshtastic(device) => device.remove_contact(pubkey).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_contact(
|
||||||
|
&mut self,
|
||||||
|
pubkey: &[u8; 32],
|
||||||
|
contact_type: u8,
|
||||||
|
flags: u8,
|
||||||
|
out_path_len: u8,
|
||||||
|
name: &str,
|
||||||
|
last_advert: u32,
|
||||||
|
) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Meshcore(device) => {
|
||||||
|
device
|
||||||
|
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Self::Meshtastic(device) => {
|
||||||
|
device
|
||||||
|
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
|
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Meshcore(device) => device.get_contacts().await,
|
Self::Meshcore(device) => device.get_contacts().await,
|
||||||
@ -80,6 +173,15 @@ impl MeshRadioDevice {
|
|||||||
Self::Meshtastic(device) => device.try_recv_frame().await,
|
Self::Meshtastic(device) => device.try_recv_frame().await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PKI-E2E status of the last inbound frame (meshtastic only; meshcore's
|
||||||
|
/// per-message E2E is derived in the frames decrypt path). Take-and-clear.
|
||||||
|
fn take_rx_encrypted(&mut self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Meshcore(_) => false,
|
||||||
|
Self::Meshtastic(device) => device.take_rx_encrypted(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan all candidate serial ports and open the first supported mesh device found.
|
/// Scan all candidate serial ports and open the first supported mesh device found.
|
||||||
@ -151,6 +253,7 @@ pub(super) const DM_V1_MARKER: &str = "@DM:";
|
|||||||
/// route inbound DMs to the correct contact_id thread.
|
/// route inbound DMs to the correct contact_id thread.
|
||||||
pub(super) const DM_V2_MARKER: &str = "@DM2:";
|
pub(super) const DM_V2_MARKER: &str = "@DM2:";
|
||||||
|
|
||||||
|
#[allow(dead_code)] // legacy @DM2-over-channel wrapper; kept for reference now that DMs are native unicast
|
||||||
fn wrap_dm_for_channel(
|
fn wrap_dm_for_channel(
|
||||||
dest_pubkey_prefix: &[u8; 6],
|
dest_pubkey_prefix: &[u8; 6],
|
||||||
sender_arch_prefix: &[u8; 6],
|
sender_arch_prefix: &[u8; 6],
|
||||||
@ -169,6 +272,7 @@ fn wrap_dm_for_channel(
|
|||||||
/// `[0u8; 6]` if the stored hex is malformed (which would only happen if a
|
/// `[0u8; 6]` if the stored hex is malformed (which would only happen if a
|
||||||
/// caller constructed `MeshState` with a bad value — empty string yields
|
/// caller constructed `MeshState` with a bad value — empty string yields
|
||||||
/// all-zero, which won't match any real peer on the receiver side).
|
/// all-zero, which won't match any real peer on the receiver side).
|
||||||
|
#[allow(dead_code)] // was used by the @DM2 wrapper; native unicast doesn't need it
|
||||||
fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
|
fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
|
||||||
let mut out = [0u8; 6];
|
let mut out = [0u8; 6];
|
||||||
if state.our_ed_pubkey_hex.len() >= 12 {
|
if state.our_ed_pubkey_hex.len() >= 12 {
|
||||||
@ -195,39 +299,42 @@ async fn send_dm_via_channel(
|
|||||||
consecutive_write_failures: &mut u32,
|
consecutive_write_failures: &mut u32,
|
||||||
) {
|
) {
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
let sender_prefix = our_sender_prefix(state);
|
let _ = state; // native unicast carries no separate sender prefix
|
||||||
// First try a single frame with the raw payload directly wrapped.
|
// NATIVE meshcore unicast (CMD_SEND_TXT_MSG): a real direct message to the
|
||||||
// This keeps small plain-text messages at minimal overhead.
|
// contact, NOT a broadcast on the shared public channel. This is the fix
|
||||||
let single = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, payload);
|
// for the long-standing public-channel pollution — archy used to tunnel
|
||||||
if single.len() <= 140 {
|
// every DM/relay/receipt as an `@DM2:` blob on channel 0, which (a) every
|
||||||
match device.send_channel_text(0, single.as_bytes()).await {
|
// mesh participant saw as spam and (b) stock meshcore clients (e.g. a
|
||||||
|
// phone) couldn't decode. A native DM is private and decodes everywhere.
|
||||||
|
// The receive side handles these via the existing RESP_CONTACT_MSG path.
|
||||||
|
//
|
||||||
|
// Small payloads send in one frame; larger ones are base64 + MC-chunked
|
||||||
|
// and reassembled by the receiver (try_chunk_reassemble).
|
||||||
|
if payload.len() <= 140 {
|
||||||
|
match device.send_text_msg(dest_pubkey_prefix, payload).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
*consecutive_write_failures = 0;
|
*consecutive_write_failures = 0;
|
||||||
info!(
|
info!(
|
||||||
dest = %hex::encode(dest_pubkey_prefix),
|
dest = %hex::encode(dest_pubkey_prefix),
|
||||||
len = payload.len(),
|
len = payload.len(),
|
||||||
wire_len = single.len(),
|
"Sent mesh DM (native unicast)"
|
||||||
"Sent mesh message (DM via channel)"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
*consecutive_write_failures += 1;
|
*consecutive_write_failures += 1;
|
||||||
warn!(
|
warn!(
|
||||||
failures = *consecutive_write_failures,
|
failures = *consecutive_write_failures,
|
||||||
"Failed to send DM via channel: {}", e
|
"Failed to send native DM: {}", e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload too large for one wrap — base64 then MC-chunk. Receiver
|
|
||||||
// reassembles base64 chunks and routes the decoded bytes back through
|
|
||||||
// the typed-envelope ladder in handle_channel_payload.
|
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
|
let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
|
||||||
static CHUNK_MSG_ID: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
|
static CHUNK_MSG_ID: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
|
||||||
let msg_id = CHUNK_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
let msg_id = CHUNK_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
let chunk_data_size = 80;
|
let chunk_data_size = 100;
|
||||||
let chunks: Vec<&str> = encoded
|
let chunks: Vec<&str> = encoded
|
||||||
.as_bytes()
|
.as_bytes()
|
||||||
.chunks(chunk_data_size)
|
.chunks(chunk_data_size)
|
||||||
@ -239,18 +346,20 @@ async fn send_dm_via_channel(
|
|||||||
raw_len = payload.len(),
|
raw_len = payload.len(),
|
||||||
b64_len = encoded.len(),
|
b64_len = encoded.len(),
|
||||||
chunks = total,
|
chunks = total,
|
||||||
"Sending chunked mesh message (DM via channel)"
|
"Sending chunked mesh DM (native unicast)"
|
||||||
);
|
);
|
||||||
let mut any_err = false;
|
let mut any_err = false;
|
||||||
for (idx, chunk) in chunks.iter().enumerate() {
|
for (idx, chunk) in chunks.iter().enumerate() {
|
||||||
let frame = format!("MC{:02x}{:02x}{:02x}{}", msg_id, idx as u8, total, chunk);
|
let frame = format!("MC{:02x}{:02x}{:02x}{}", msg_id, idx as u8, total, chunk);
|
||||||
let wrapped = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, frame.as_bytes());
|
if let Err(e) = device
|
||||||
if let Err(e) = device.send_channel_text(0, wrapped.as_bytes()).await {
|
.send_text_msg(dest_pubkey_prefix, frame.as_bytes())
|
||||||
|
.await
|
||||||
|
{
|
||||||
*consecutive_write_failures += 1;
|
*consecutive_write_failures += 1;
|
||||||
warn!(
|
warn!(
|
||||||
failures = *consecutive_write_failures,
|
failures = *consecutive_write_failures,
|
||||||
chunk = idx,
|
chunk = idx,
|
||||||
"Chunk DM-via-channel send failed: {}",
|
"Chunk native DM send failed: {}",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
any_err = true;
|
any_err = true;
|
||||||
@ -263,35 +372,109 @@ async fn send_dm_via_channel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send PLAIN text to a stock meshcore client as one or more native DMs.
|
||||||
|
/// Unlike `send_dm_via_channel`, this never uses MC-chunk framing (stock
|
||||||
|
/// clients can't reassemble it) — if the text exceeds one LoRa frame it is
|
||||||
|
/// split into multiple readable plain messages on UTF-8 char boundaries.
|
||||||
|
async fn send_plain_native_text(
|
||||||
|
device: &mut MeshRadioDevice,
|
||||||
|
dest_pubkey_prefix: &[u8; 6],
|
||||||
|
text: &[u8],
|
||||||
|
consecutive_write_failures: &mut u32,
|
||||||
|
) {
|
||||||
|
// Split on char boundaries so we never break a multi-byte UTF-8 sequence.
|
||||||
|
const FRAME: usize = 150; // under MAX_MESSAGE_LEN (160), leaves header room
|
||||||
|
let s = String::from_utf8_lossy(text);
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
for ch in s.chars() {
|
||||||
|
if cur.len() + ch.len_utf8() > FRAME {
|
||||||
|
parts.push(std::mem::take(&mut cur));
|
||||||
|
}
|
||||||
|
cur.push(ch);
|
||||||
|
}
|
||||||
|
if !cur.is_empty() || parts.is_empty() {
|
||||||
|
parts.push(cur);
|
||||||
|
}
|
||||||
|
let total = parts.len();
|
||||||
|
for (idx, part) in parts.iter().enumerate() {
|
||||||
|
match device
|
||||||
|
.send_text_msg(dest_pubkey_prefix, part.as_bytes())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
*consecutive_write_failures = 0;
|
||||||
|
info!(
|
||||||
|
dest = %hex::encode(dest_pubkey_prefix),
|
||||||
|
part = idx + 1,
|
||||||
|
total,
|
||||||
|
"Sent plain native DM"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
*consecutive_write_failures += 1;
|
||||||
|
warn!(
|
||||||
|
failures = *consecutive_write_failures,
|
||||||
|
"Plain native DM send failed: {}", e
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total > 1 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch the contacts list from the device and update the peer cache.
|
/// Fetch the contacts list from the device and update the peer cache.
|
||||||
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
|
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
|
||||||
match device.get_contacts().await {
|
match device.get_contacts().await {
|
||||||
Ok(contacts) => {
|
Ok(contacts) => {
|
||||||
// Skip firmware contacts the user has explicitly wiped via
|
// Contact blocking is intentionally NOT applied here. A read-time
|
||||||
// mesh.clear-all. MeshCore keeps its own persistent contact
|
// blocklist meant a wiped/re-paired contact could never come back
|
||||||
// table the app can't remove from, so we filter on read to
|
// even when it re-advertised (it broke phone re-pairing after a
|
||||||
// keep cleared entries out of the chat list.
|
// clear). Per-contact blocking will return later as an explicit,
|
||||||
let blocklist = state.radio_contact_blocklist.read().await.clone();
|
// user-controlled feature; until then every firmware contact is
|
||||||
|
// surfaced. `radio_contact_blocklist` is retained but unused.
|
||||||
let mut peers = state.peers.write().await;
|
let mut peers = state.peers.write().await;
|
||||||
|
let is_meshtastic = matches!(device.device_type(), DeviceType::Meshtastic);
|
||||||
for (idx, contact) in contacts.iter().enumerate() {
|
for (idx, contact) in contacts.iter().enumerate() {
|
||||||
if blocklist.contains(&contact.public_key_hex) {
|
let contact_id = if is_meshtastic {
|
||||||
continue;
|
meshtastic_contact_id(&contact.public_key_hex).unwrap_or(idx as u32)
|
||||||
}
|
} else {
|
||||||
let contact_id = idx as u32;
|
idx as u32
|
||||||
|
};
|
||||||
let existing = peers.get(&contact_id);
|
let existing = peers.get(&contact_id);
|
||||||
let peer = super::super::types::MeshPeer {
|
let peer = super::super::types::MeshPeer {
|
||||||
contact_id,
|
contact_id,
|
||||||
advert_name: contact.advert_name.clone(),
|
advert_name: contact.advert_name.clone(),
|
||||||
did: existing.and_then(|p| p.did.clone()),
|
did: existing.and_then(|p| p.did.clone()),
|
||||||
pubkey_hex: Some(contact.public_key_hex.clone()),
|
pubkey_hex: Some(contact.public_key_hex.clone()),
|
||||||
|
// Preserve any archipelago identity bound by an earlier
|
||||||
|
// identity advert — NEVER overwrite it with the firmware
|
||||||
|
// contact key, or a signed `!ai` query from this peer would
|
||||||
|
// fail authentication after the next contact refresh.
|
||||||
|
arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()),
|
||||||
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
|
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
|
||||||
rssi: None,
|
rssi: None,
|
||||||
snr: None,
|
snr: None,
|
||||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||||
hops: 0,
|
hops: 0,
|
||||||
|
last_advert: contact.last_advert,
|
||||||
|
// A non-zero path_len means the firmware has a route (direct
|
||||||
|
// or flood) to this contact — i.e. we can deliver to it.
|
||||||
|
reachable: contact.path_len != 0,
|
||||||
};
|
};
|
||||||
peers.insert(contact_id, peer);
|
peers.insert(contact_id, peer);
|
||||||
}
|
}
|
||||||
|
// A radio contact that shares an exact advert_name with a known
|
||||||
|
// federation peer is the same physical node — bind the federation
|
||||||
|
// peer's archipelago identity onto the radio record so a signed
|
||||||
|
// `!ai`/typed message over LoRa authenticates (and the contact stops
|
||||||
|
// showing as a radio/federation duplicate). Security is unchanged:
|
||||||
|
// the bound key is only a candidate the inbound signature must still
|
||||||
|
// verify against. See `bind_federation_twins`.
|
||||||
|
super::super::bind_federation_twins(&mut peers);
|
||||||
drop(peers);
|
drop(peers);
|
||||||
state.update_peer_count().await;
|
state.update_peer_count().await;
|
||||||
if !contacts.is_empty() {
|
if !contacts.is_empty() {
|
||||||
@ -334,6 +517,19 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn meshtastic_contact_id(public_key_hex: &str) -> Option<u32> {
|
||||||
|
let bytes = hex::decode(public_key_hex).ok()?;
|
||||||
|
if bytes.len() < 15 || &bytes[4..15] != b"meshtastic:" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let node_num = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
||||||
|
if node_num == 0 || node_num == u32::MAX {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(node_num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drain any queued messages from the device.
|
/// Drain any queued messages from the device.
|
||||||
/// Returns `true` if a write/communication error occurred (for failure tracking).
|
/// Returns `true` if a write/communication error occurred (for failure tracking).
|
||||||
async fn sync_queued_messages(
|
async fn sync_queued_messages(
|
||||||
@ -358,6 +554,23 @@ async fn sync_queued_messages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many times we will try to write the LoRa region across reconnects before
|
||||||
|
/// giving up. A healthy radio accepts it on the first try (the reboot-and-verify
|
||||||
|
/// resolves on the next session). A radio that silently refuses to persist
|
||||||
|
/// config — corrupt/full flash, managed mode, etc. — would otherwise reboot-loop
|
||||||
|
/// forever; after this many attempts we stop, log, and run without it.
|
||||||
|
const MAX_REGION_PROVISION_ATTEMPTS: u32 = 3;
|
||||||
|
|
||||||
|
/// Process-global count of LoRa-region writes attempted (one radio per process).
|
||||||
|
/// Reset to 0 whenever the radio reports the desired region, so genuine later
|
||||||
|
/// drift re-provisions but a broken radio doesn't loop.
|
||||||
|
static REGION_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
|
||||||
|
std::sync::atomic::AtomicU32::new(0);
|
||||||
|
|
||||||
|
/// Same retry-cap idea as the region, for the shared-channel write.
|
||||||
|
static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
|
||||||
|
std::sync::atomic::AtomicU32::new(0);
|
||||||
|
|
||||||
/// Run a single mesh session (connect, initialize, main loop).
|
/// Run a single mesh session (connect, initialize, main loop).
|
||||||
pub(super) async fn run_mesh_session(
|
pub(super) async fn run_mesh_session(
|
||||||
state: &Arc<MeshState>,
|
state: &Arc<MeshState>,
|
||||||
@ -367,6 +580,8 @@ pub(super) async fn run_mesh_session(
|
|||||||
our_x25519_secret: &[u8; 32],
|
our_x25519_secret: &[u8; 32],
|
||||||
our_x25519_pubkey_hex: &str,
|
our_x25519_pubkey_hex: &str,
|
||||||
server_name: Option<&str>,
|
server_name: Option<&str>,
|
||||||
|
lora_region: Option<&str>,
|
||||||
|
channel_name: Option<&str>,
|
||||||
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
||||||
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@ -399,6 +614,73 @@ pub(super) async fn run_mesh_session(
|
|||||||
|
|
||||||
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
|
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
|
||||||
|
|
||||||
|
// Provision the LoRa region before anything else. A fresh Meshtastic radio
|
||||||
|
// is region-UNSET and therefore RF-silent — it can neither hear nor be
|
||||||
|
// heard, so contact discovery and DMs would all silently fail. If we write
|
||||||
|
// a new region the firmware reboots to apply it; restart the session so we
|
||||||
|
// re-handshake the freshly-rebooted radio (and then set its name on the
|
||||||
|
// reconnect, where the region already matches and no reboot occurs).
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
let region_attempts = REGION_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
|
||||||
|
if region_attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||||
|
match device.ensure_lora_region(lora_region).await {
|
||||||
|
Ok(true) => {
|
||||||
|
REGION_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||||
|
info!(
|
||||||
|
region = lora_region.unwrap_or(""),
|
||||||
|
attempt = region_attempts + 1,
|
||||||
|
max = MAX_REGION_PROVISION_ATTEMPTS,
|
||||||
|
"Provisioned LoRa region — radio rebooting, restarting mesh session"
|
||||||
|
);
|
||||||
|
// Give the radio time to reboot before the reconnect re-opens it.
|
||||||
|
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Radio reports the desired region (or none configured): clear the
|
||||||
|
// attempt counter so a future genuine drift re-provisions cleanly.
|
||||||
|
Ok(false) => REGION_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed),
|
||||||
|
Err(e) => warn!("Failed to provision LoRa region: {}", e),
|
||||||
|
}
|
||||||
|
} else if lora_region.is_some() {
|
||||||
|
warn!(
|
||||||
|
region = lora_region.unwrap_or(""),
|
||||||
|
attempts = MAX_REGION_PROVISION_ATTEMPTS,
|
||||||
|
"Radio did not persist the configured LoRa region after repeated \
|
||||||
|
attempts — continuing without it. The radio likely needs a manual \
|
||||||
|
factory reset / reflash; mesh discovery stays offline until its \
|
||||||
|
region is set."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision the shared primary channel (after the region, since both reboot
|
||||||
|
// the radio). Without a matching channel two same-region radios still can't
|
||||||
|
// decode each other's traffic. Same retry-cap + restart-on-change pattern.
|
||||||
|
let channel_attempts = CHANNEL_PROVISION_ATTEMPTS.load(Ordering::Relaxed);
|
||||||
|
if channel_attempts < MAX_REGION_PROVISION_ATTEMPTS {
|
||||||
|
match device.ensure_channel(channel_name).await {
|
||||||
|
Ok(true) => {
|
||||||
|
CHANNEL_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
|
||||||
|
info!(
|
||||||
|
channel = channel_name.unwrap_or(""),
|
||||||
|
attempt = channel_attempts + 1,
|
||||||
|
max = MAX_REGION_PROVISION_ATTEMPTS,
|
||||||
|
"Provisioned shared mesh channel — radio rebooting, restarting mesh session"
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Ok(false) => CHANNEL_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed),
|
||||||
|
Err(e) => warn!("Failed to provision mesh channel: {}", e),
|
||||||
|
}
|
||||||
|
} else if channel_name.is_some() {
|
||||||
|
warn!(
|
||||||
|
channel = channel_name.unwrap_or(""),
|
||||||
|
attempts = MAX_REGION_PROVISION_ATTEMPTS,
|
||||||
|
"Radio did not persist the shared mesh channel after repeated \
|
||||||
|
attempts — continuing without it; the radio may need a manual reset."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Set advert name to the server's human-readable name (e.g. "ThinkPad"),
|
// Set advert name to the server's human-readable name (e.g. "ThinkPad"),
|
||||||
// falling back to the DID fragment if no name is configured.
|
// falling back to the DID fragment if no name is configured.
|
||||||
let advert_name = if let Some(name) = server_name {
|
let advert_name = if let Some(name) = server_name {
|
||||||
@ -423,23 +705,25 @@ pub(super) async fn run_mesh_session(
|
|||||||
if let Err(e) = device.send_self_advert().await {
|
if let Err(e) = device.send_self_advert().await {
|
||||||
warn!("Failed to send initial advert: {}", e);
|
warn!("Failed to send initial advert: {}", e);
|
||||||
}
|
}
|
||||||
|
// Actively announce our identity over the air with want_response, so any
|
||||||
// Archipelago identity advert (`ARCHY:2:{ed}:{x25519}`): broadcast as channel
|
// already-running neighbour both learns about us and replies with its own
|
||||||
// text so peers can bind our radio presence to our DID + keys. The firmware
|
// NodeInfo — immediate two-way discovery instead of waiting for the radio's
|
||||||
// advert alone carries the meshcore key (and nothing on Meshtastic), so this
|
// multi-hour NodeInfo cycle. (No-op for meshcore.)
|
||||||
// is what makes trust-gating + encrypted DMs work across BOTH transports.
|
if let Err(e) = device.send_nodeinfo_advert(true).await {
|
||||||
let identity_advert = super::super::protocol::encode_identity_broadcast(
|
warn!("Failed to send initial NodeInfo advert: {}", e);
|
||||||
our_did,
|
|
||||||
our_ed_pubkey_hex,
|
|
||||||
our_x25519_pubkey_hex,
|
|
||||||
);
|
|
||||||
if let Err(e) = device
|
|
||||||
.send_channel_text(0, identity_advert.as_bytes())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!("Failed to broadcast archipelago identity: {}", e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: Archipelago identity adverts (`ARCHY:2:{ed}:{x25519}`) are intentionally
|
||||||
|
// NOT broadcast on the shared public channel (channel 0). Doing so spams every
|
||||||
|
// participant on that channel — including plain Meshtastic/meshcore users who
|
||||||
|
// just see raw `ARCHY:2:…` text — on startup and again on every advert tick.
|
||||||
|
// The inbound parser in frames.rs still accepts these from any legacy peer that
|
||||||
|
// sends them, so trust-binding keeps working when a peer advertises; we simply
|
||||||
|
// don't pollute the public channel ourselves. A dedicated control channel (or a
|
||||||
|
// DM-targeted handshake) is the proper transport for this and is tracked
|
||||||
|
// separately. See encode_identity_broadcast / parse_identity_broadcast.
|
||||||
|
let _ = (our_did, our_ed_pubkey_hex, our_x25519_pubkey_hex);
|
||||||
|
|
||||||
// Fetch existing contacts from the device
|
// Fetch existing contacts from the device
|
||||||
refresh_contacts(&mut device, state).await;
|
refresh_contacts(&mut device, state).await;
|
||||||
|
|
||||||
@ -474,11 +758,19 @@ pub(super) async fn run_mesh_session(
|
|||||||
Ok(Some(frame)) => {
|
Ok(Some(frame)) => {
|
||||||
// Successful read resets the failure counter
|
// Successful read resets the failure counter
|
||||||
consecutive_write_failures = 0;
|
consecutive_write_failures = 0;
|
||||||
|
// For meshtastic, the PKI-E2E status of this frame can't
|
||||||
|
// ride the synthetic meshcore frame — snapshot the message
|
||||||
|
// id high-water mark, dispatch, then stamp the E2E pill on
|
||||||
|
// whatever received message this frame produced.
|
||||||
|
let before_id = dispatch::max_message_id(state).await;
|
||||||
let should_action = frames::handle_frame(
|
let should_action = frames::handle_frame(
|
||||||
&frame,
|
&frame,
|
||||||
state,
|
state,
|
||||||
our_x25519_secret,
|
our_x25519_secret,
|
||||||
).await;
|
).await;
|
||||||
|
if device.take_rx_encrypted() {
|
||||||
|
dispatch::stamp_received_encrypted(state, before_id).await;
|
||||||
|
}
|
||||||
if should_action {
|
if should_action {
|
||||||
// Contact discovery or messages waiting — sync both
|
// Contact discovery or messages waiting — sync both
|
||||||
refresh_contacts(&mut device, state).await;
|
refresh_contacts(&mut device, state).await;
|
||||||
@ -507,11 +799,16 @@ pub(super) async fn run_mesh_session(
|
|||||||
} else {
|
} else {
|
||||||
consecutive_write_failures = 0;
|
consecutive_write_failures = 0;
|
||||||
}
|
}
|
||||||
// Re-broadcast archipelago identity so peers that joined since
|
// Periodic over-air identity beacon (no want_response, to avoid
|
||||||
// startup (or missed it) can bind our DID/keys.
|
// reply storms) so peers that come online later still discover
|
||||||
if let Err(e) = device.send_channel_text(0, identity_advert.as_bytes()).await {
|
// us between the radio's own infrequent NodeInfo broadcasts.
|
||||||
warn!("Failed to re-broadcast archipelago identity: {}", e);
|
// No-op for meshcore (its self-advert above already goes out).
|
||||||
|
if let Err(e) = device.send_nodeinfo_advert(false).await {
|
||||||
|
debug!("Periodic NodeInfo advert failed: {}", e);
|
||||||
}
|
}
|
||||||
|
// (Identity re-broadcast on the public channel intentionally
|
||||||
|
// removed — see the note at session startup. It spammed the
|
||||||
|
// shared channel every advert tick.)
|
||||||
refresh_contacts(&mut device, state).await;
|
refresh_contacts(&mut device, state).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -520,8 +817,14 @@ pub(super) async fn run_mesh_session(
|
|||||||
handle_send_command(cmd, &mut device, state, &mut consecutive_write_failures).await;
|
handle_send_command(cmd, &mut device, state, &mut consecutive_write_failures).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodic message sync
|
// Periodic message sync + serial keepalive
|
||||||
_ = sync_timer.tick() => {
|
_ = sync_timer.tick() => {
|
||||||
|
// Keep the radio streaming inbound packets to our serial client
|
||||||
|
// (best-effort — a failed keepalive shouldn't trip the reconnect
|
||||||
|
// counter on its own; a truly dead port is caught by real writes).
|
||||||
|
if let Err(e) = device.send_keepalive().await {
|
||||||
|
debug!("Mesh keepalive failed: {}", e);
|
||||||
|
}
|
||||||
if sync_queued_messages(&mut device, state, our_x25519_secret).await {
|
if sync_queued_messages(&mut device, state, our_x25519_secret).await {
|
||||||
consecutive_write_failures += 1;
|
consecutive_write_failures += 1;
|
||||||
debug!(failures = consecutive_write_failures, "Message sync failed");
|
debug!(failures = consecutive_write_failures, "Message sync failed");
|
||||||
@ -562,6 +865,18 @@ async fn handle_send_command(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
MeshCommand::SendNativeText {
|
||||||
|
dest_pubkey_prefix,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
send_plain_native_text(
|
||||||
|
device,
|
||||||
|
&dest_pubkey_prefix,
|
||||||
|
&payload,
|
||||||
|
consecutive_write_failures,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
MeshCommand::SendRaw {
|
MeshCommand::SendRaw {
|
||||||
dest_pubkey_prefix,
|
dest_pubkey_prefix,
|
||||||
payload,
|
payload,
|
||||||
@ -612,8 +927,32 @@ async fn handle_send_command(
|
|||||||
*consecutive_write_failures = 0;
|
*consecutive_write_failures = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MeshCommand::RebootRadio { seconds } => {
|
||||||
|
if let Err(e) = device.reboot(seconds).await {
|
||||||
|
warn!("Failed to reboot radio: {}", e);
|
||||||
|
} else {
|
||||||
|
info!(seconds, "Radio reboot command sent to device");
|
||||||
|
}
|
||||||
|
}
|
||||||
MeshCommand::RefreshContacts => {
|
MeshCommand::RefreshContacts => {
|
||||||
refresh_contacts(device, state).await;
|
refresh_contacts(device, state).await;
|
||||||
}
|
}
|
||||||
|
MeshCommand::RemoveContact { pubkey } => {
|
||||||
|
if let Err(e) = device.remove_contact(&pubkey).await {
|
||||||
|
warn!(pubkey = %hex::encode(pubkey), "remove_contact failed: {}", e);
|
||||||
|
} else {
|
||||||
|
info!(pubkey = %hex::encode(&pubkey[..6]), "Removed firmware contact");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MeshCommand::AddContact { pubkey, name } => {
|
||||||
|
// type=1 (chat/user), flags=0, out_path_len=0 (firmware will flood
|
||||||
|
// until a path is learned). last_advert=0 lets the firmware keep its
|
||||||
|
// own advert timestamp.
|
||||||
|
if let Err(e) = device.add_contact(&pubkey, 1, 0, 0, &name, 0).await {
|
||||||
|
warn!(pubkey = %hex::encode(&pubkey[..6]), "add_contact failed: {}", e);
|
||||||
|
} else {
|
||||||
|
info!(pubkey = %hex::encode(&pubkey[..6]), "Imported advert as contact");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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