Compare commits
160
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ea9ad9adb | ||
|
|
a9d8895395 | ||
|
|
508f8e1786 | ||
|
|
0399f45fb2 | ||
|
|
6a4d48b49f | ||
|
|
6b9b7a5a9c | ||
|
|
66785c00dd | ||
|
|
d9411c3325 | ||
|
|
00a86e6ecf | ||
|
|
361ebea85c | ||
|
|
fe963a1a8b | ||
|
|
3441ea2459 | ||
|
|
3127d50091 | ||
|
|
8466cd14f0 | ||
|
|
036db76773 | ||
|
|
da3012b75a | ||
|
|
659d44a761 | ||
|
|
4cb5c07b1b | ||
|
|
3018849cc8 | ||
|
|
c1bb7b675d | ||
|
|
73b49bd5bf | ||
|
|
423c2f8201 | ||
|
|
3ce7bb6c18 | ||
|
|
8dd57bcbb1 | ||
|
|
8fade7c435 | ||
|
|
fd8e93235f | ||
|
|
668ed1590c | ||
|
|
b77b031b8e | ||
|
|
f756365935 | ||
|
|
60758263f3 | ||
|
|
6399b6881e | ||
|
|
ab85c2e361 | ||
|
|
616f62ce4f | ||
|
|
5d2fac690e | ||
|
|
eac583c15e | ||
|
|
d63cd92bee | ||
|
|
2e8417e39b | ||
|
|
2f327183eb | ||
|
|
6ddce90e45 | ||
|
|
d80b8ce513 | ||
|
|
8b88c45262 | ||
|
|
dbd19006f2 | ||
|
|
ba825c13a5 | ||
|
|
1fdb5e5cf2 | ||
|
|
274ed008fe | ||
|
|
6b42bfd503 | ||
|
|
30a7f73ead | ||
|
|
46350f48b6 | ||
|
|
7655a5971b | ||
|
|
f5c581a725 | ||
|
|
b614c5c694 | ||
|
|
3a52c766ac | ||
|
|
7e4fed7967 | ||
|
|
cd8763f468 | ||
|
|
dbafa12596 | ||
|
|
1736f6f99e | ||
|
|
0c02d06a66 | ||
|
|
6760d11a57 | ||
|
|
5616bb74e6 | ||
|
|
164f938982 | ||
|
|
d514e0e5e4 | ||
|
|
bdacc06a2b | ||
|
|
8ef7af985d | ||
|
|
002032b7da | ||
|
|
bc3729d99f | ||
|
|
649433b7fd | ||
|
|
a360f90647 | ||
|
|
ab927afbaa | ||
|
|
f94f5da6ee | ||
|
|
019144903c | ||
|
|
dce5084451 | ||
|
|
e8a729a4c7 | ||
|
|
77eb1b907b | ||
|
|
de1b25cc78 | ||
|
|
d865136631 | ||
|
|
36cd3f4e7d | ||
|
|
3ed9243c50 | ||
|
|
18284e1592 | ||
|
|
29ff413559 | ||
|
|
0f71013952 | ||
|
|
98b570679d | ||
|
|
d378d94a05 | ||
|
|
a1f70f9c18 | ||
|
|
2d11f262dd | ||
|
|
877b3e4168 | ||
|
|
b0656b068f | ||
|
|
f586cbc499 | ||
|
|
3078d4b69e | ||
|
|
1147dbd882 | ||
|
|
e08c0d0b9f | ||
|
|
a97128bfd2 | ||
|
|
6cd67df575 | ||
|
|
8d8130109d | ||
|
|
485c4d5d98 | ||
|
|
4cab118cb2 | ||
|
|
cd08fd3c9e | ||
|
|
aae3391ce8 | ||
|
|
bb14490fb7 | ||
|
|
24f122f35a | ||
|
|
8d82666c82 | ||
|
|
2c98bdd19d | ||
|
|
0995aa1033 | ||
|
|
abf6ca000d | ||
|
|
c421fdb064 | ||
|
|
918fec0af7 | ||
|
|
548107eb8b | ||
|
|
a3217a9db4 | ||
|
|
8ffb10d7e0 | ||
|
|
401a44b40a | ||
|
|
dd5c3783ed | ||
|
|
7ca973e7b1 | ||
|
|
a147db9b70 | ||
|
|
68b02359dc | ||
|
|
b7ff0b1d38 | ||
|
|
b9044e58c7 | ||
|
|
067a3ed106 | ||
|
|
88bdba19db | ||
|
|
f8cf0afbfc | ||
|
|
e8251b5bad | ||
|
|
ca2ddc889e | ||
|
|
2517379ac3 | ||
|
|
b8a09b448b | ||
|
|
a8c6a36cd1 | ||
|
|
d0b9f168f4 | ||
|
|
07dff3e4ca | ||
|
|
b30f41f3d7 | ||
|
|
a029a4c948 | ||
|
|
ef69434364 | ||
|
|
3ee5dd6715 | ||
|
|
eebdade0d4 | ||
|
|
aa2a13d510 | ||
|
|
d7286d5f63 | ||
|
|
5f40cd2af4 | ||
|
|
42c29b99e2 | ||
|
|
b0907c48b2 | ||
|
|
6c1f316956 | ||
|
|
8e094c7ce9 | ||
|
|
5c117f5718 | ||
|
|
43ac2975dc | ||
|
|
a34075287d | ||
|
|
0ecfdd1d01 | ||
|
|
4fc6c103ba | ||
|
|
b0e5e8c00e | ||
|
|
9eb5d8cee0 | ||
|
|
d8d472f72c | ||
|
|
7a78d750f4 | ||
|
|
4c41c38b3b | ||
|
|
fddbf8ccf7 | ||
|
|
9438bad7fc | ||
|
|
209c2dcd6c | ||
|
|
42034c0ff9 | ||
|
|
54cb23f07b | ||
|
|
0760819af4 | ||
|
|
e5f695c1c4 | ||
|
|
637818c9f1 | ||
|
|
712e1c8b25 | ||
|
|
3e3dfafdfc | ||
|
|
0fca903188 | ||
|
|
1508cc3e13 | ||
|
|
24e537c027 |
@@ -7,19 +7,20 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-iso:
|
build-iso:
|
||||||
runs-on: ubuntu-latest
|
runs-on: iso-builder
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
# Direct fetch + sync (actions/checkout token is broken on this Gitea)
|
# Direct fetch + sync (actions/checkout token is broken on this Gitea)
|
||||||
cd /home/archipelago/archy && git fetch origin main && git reset --hard origin/main
|
REPO_DIR="$HOME/archy"
|
||||||
|
cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main
|
||||||
echo "=== Source at commit: $(git log --oneline -1) ==="
|
echo "=== Source at commit: $(git log --oneline -1) ==="
|
||||||
rsync -a --delete \
|
rsync -a --delete \
|
||||||
--exclude '.git' --exclude 'node_modules' --exclude 'target' \
|
--exclude '.git' --exclude 'node_modules' --exclude 'target' \
|
||||||
--exclude 'image-recipe/build' --exclude 'image-recipe/results' \
|
--exclude 'image-recipe/build' --exclude 'image-recipe/results' \
|
||||||
--exclude 'web/dist' \
|
--exclude 'web/dist' \
|
||||||
/home/archipelago/archy/ "$GITHUB_WORKSPACE/"
|
"$REPO_DIR/" "$GITHUB_WORKSPACE/"
|
||||||
cd "$GITHUB_WORKSPACE"
|
cd "$GITHUB_WORKSPACE"
|
||||||
echo "=== Workspace version: $(grep '^version' core/archipelago/Cargo.toml) ==="
|
echo "=== Workspace version: $(grep '^version' core/archipelago/Cargo.toml) ==="
|
||||||
[ -f "scripts/first-boot-containers.sh" ] && echo " first-boot-containers.sh: PRESENT" || echo " first-boot-containers.sh: MISSING"
|
[ -f "scripts/first-boot-containers.sh" ] && echo " first-boot-containers.sh: PRESENT" || echo " first-boot-containers.sh: MISSING"
|
||||||
@@ -27,41 +28,65 @@ jobs:
|
|||||||
|
|
||||||
- name: Install ISO build dependencies
|
- name: Install ISO build dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -qq
|
# Skip apt if packages already installed (persistent runner)
|
||||||
sudo apt-get install -y -qq \
|
if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \
|
||||||
debootstrap squashfs-tools xorriso \
|
grub-efi-amd64-bin grub-pc-bin grub-common musl-tools >/dev/null 2>&1; then
|
||||||
isolinux syslinux-common mtools \
|
echo "ISO build deps already installed, skipping apt"
|
||||||
grub-efi-amd64-bin grub-pc-bin grub-common
|
else
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y -qq \
|
||||||
|
debootstrap squashfs-tools xorriso \
|
||||||
|
isolinux syslinux-common mtools \
|
||||||
|
grub-efi-amd64-bin grub-pc-bin grub-common \
|
||||||
|
musl-tools
|
||||||
|
fi
|
||||||
|
# Ensure musl Rust target is available
|
||||||
|
source $HOME/.cargo/env 2>/dev/null || true
|
||||||
|
rustup target add x86_64-unknown-linux-musl 2>/dev/null || true
|
||||||
|
|
||||||
- name: Build backend
|
- name: Build backend (incremental, musl static)
|
||||||
run: |
|
run: |
|
||||||
source $HOME/.cargo/env 2>/dev/null || true
|
source $HOME/.cargo/env 2>/dev/null || true
|
||||||
|
# Build in persistent repo dir to reuse target/ cache
|
||||||
|
cd "$HOME/archy"
|
||||||
export GIT_HASH=$(git rev-parse --short HEAD)
|
export GIT_HASH=$(git rev-parse --short HEAD)
|
||||||
cargo build --release --manifest-path core/Cargo.toml
|
# Static musl build for portability — ensures binary runs regardless
|
||||||
|
# of glibc version differences between build host and ISO rootfs.
|
||||||
|
cargo build --release --target x86_64-unknown-linux-musl --manifest-path core/Cargo.toml
|
||||||
|
# Copy binary to workspace for downstream steps
|
||||||
|
mkdir -p "$GITHUB_WORKSPACE/core/target/release"
|
||||||
|
cp core/target/x86_64-unknown-linux-musl/release/archipelago "$GITHUB_WORKSPACE/core/target/release/"
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: cd neode-ui && npm ci && npm run build
|
run: |
|
||||||
|
source $HOME/.nvm/nvm.sh 2>/dev/null || true
|
||||||
|
cd neode-ui && npm ci && npm run build
|
||||||
|
|
||||||
- name: Type check frontend
|
- name: Type check frontend
|
||||||
run: cd neode-ui && npx vue-tsc -b --noEmit
|
run: |
|
||||||
|
source $HOME/.nvm/nvm.sh 2>/dev/null || true
|
||||||
|
cd neode-ui && npx vue-tsc -b --noEmit
|
||||||
|
|
||||||
- name: Run frontend tests
|
- name: Run frontend tests
|
||||||
run: cd neode-ui && npx vitest run
|
|
||||||
|
|
||||||
- name: Run container orchestration unit tests
|
|
||||||
run: |
|
run: |
|
||||||
source $HOME/.cargo/env 2>/dev/null || true
|
source $HOME/.nvm/nvm.sh 2>/dev/null || true
|
||||||
echo "=== Container crate tests ==="
|
cd neode-ui && npx vitest run
|
||||||
cargo test -p archipelago-container --no-fail-fast --manifest-path core/Cargo.toml
|
|
||||||
echo ""
|
- name: Include AIUI if available
|
||||||
echo "=== Orchestration integration tests ==="
|
run: |
|
||||||
cargo test --test orchestration_tests --no-fail-fast --manifest-path core/Cargo.toml 2>/dev/null || echo "orchestration_tests not found, skipping"
|
if [ -d "/opt/archipelago/web-ui/aiui" ] && [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
|
||||||
|
mkdir -p web/dist/neode-ui/aiui
|
||||||
|
cp -r /opt/archipelago/web-ui/aiui/* web/dist/neode-ui/aiui/
|
||||||
|
echo "AIUI included from /opt/archipelago/web-ui/aiui/"
|
||||||
|
else
|
||||||
|
echo "WARNING: AIUI not found on build server — ISO will not include AIUI"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Configure root podman for insecure registry
|
- name: Configure root podman for insecure registry
|
||||||
run: |
|
run: |
|
||||||
sudo mkdir -p /etc/containers/registries.conf.d
|
sudo mkdir -p /etc/containers/registries.conf.d
|
||||||
echo '[[registry]]
|
echo '[[registry]]
|
||||||
location = "80.71.235.15:3000"
|
location = "git.tx1138.com"
|
||||||
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
|
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
|
||||||
|
|
||||||
- name: Build unbundled ISO
|
- name: Build unbundled ISO
|
||||||
@@ -234,7 +259,7 @@ jobs:
|
|||||||
echo "══════════════════════════════════════════"
|
echo "══════════════════════════════════════════"
|
||||||
echo "DEV ISO BUILD REPORT"
|
echo "DEV ISO BUILD REPORT"
|
||||||
echo "══════════════════════════════════════════"
|
echo "══════════════════════════════════════════"
|
||||||
echo "Commit: $(git rev-parse --short HEAD) ($(git log -1 --format=%s))"
|
echo "Commit: $(git -C "$HOME/archy" rev-parse --short HEAD 2>/dev/null || echo 'unknown') ($(git -C "$HOME/archy" log -1 --format=%s 2>/dev/null || echo 'unknown'))"
|
||||||
echo "Branch: ${GITHUB_REF_NAME:-dev-iso}"
|
echo "Branch: ${GITHUB_REF_NAME:-dev-iso}"
|
||||||
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
echo "Runner: $(hostname)"
|
echo "Runner: $(hostname)"
|
||||||
@@ -247,11 +272,17 @@ jobs:
|
|||||||
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
|
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
|
||||||
if [ -n "$ROOTFS" ]; then
|
if [ -n "$ROOTFS" ]; then
|
||||||
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
|
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
|
||||||
echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
# List key paths once (podman export omits ./ prefix, so match without it)
|
||||||
echo " SSL cert: $(sudo tar tf "$ROOTFS" ./etc/archipelago/ssl/archipelago.crt 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
ROOTFS_LIST=$(sudo tar tf "$ROOTFS" 2>/dev/null | grep -E '(etc/nginx/sites-available/archipelago|etc/archipelago/ssl/archipelago.crt|usr/local/bin/archipelago-kiosk-launcher|usr/local/bin/archipelago|opt/archipelago/web-ui/index.html)' || true)
|
||||||
echo " kiosk launcher: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago-kiosk-launcher 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
for item in \
|
||||||
echo " backend binary: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
"nginx config:etc/nginx/sites-available/archipelago" \
|
||||||
echo " web-ui index: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
"SSL cert:etc/archipelago/ssl/archipelago.crt" \
|
||||||
|
"kiosk launcher:usr/local/bin/archipelago-kiosk-launcher" \
|
||||||
|
"backend binary:usr/local/bin/archipelago" \
|
||||||
|
"web-ui index:opt/archipelago/web-ui/index.html"; do
|
||||||
|
label="${item%%:*}"; path="${item#*:}"
|
||||||
|
echo "$ROOTFS_LIST" | grep -q "$path" && echo " $label: PRESENT" || echo " $label: MISSING"
|
||||||
|
done
|
||||||
else
|
else
|
||||||
echo " rootfs.tar not found in workspace"
|
echo " rootfs.tar not found in workspace"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
name: Build Archipelago ISO
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-iso:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
run: |
|
|
||||||
# Direct clone using stored credentials (actions/checkout token is broken)
|
|
||||||
cd /home/archipelago/archy && git fetch origin main && git reset --hard origin/main
|
|
||||||
echo "=== Source at commit: $(git log --oneline -1) ==="
|
|
||||||
echo "=== Syncing to workspace ==="
|
|
||||||
rsync -a --delete --exclude='.git' --exclude='target/' --exclude='node_modules/' \
|
|
||||||
/home/archipelago/archy/ "$GITHUB_WORKSPACE/" || cp -a /home/archipelago/archy/* "$GITHUB_WORKSPACE/"
|
|
||||||
cd "$GITHUB_WORKSPACE"
|
|
||||||
echo "=== Workspace version: $(grep '^version' core/archipelago/Cargo.toml) ==="
|
|
||||||
echo "=== Key files ==="
|
|
||||||
echo " first-boot: $([ -f scripts/first-boot-containers.sh ] && echo PRESENT || echo MISSING)"
|
|
||||||
echo " Cargo.toml: $(grep '^version' core/archipelago/Cargo.toml)"
|
|
||||||
echo " package.json: $(grep '\"version\"' neode-ui/package.json | head -1)"
|
|
||||||
|
|
||||||
- name: Build backend
|
|
||||||
run: |
|
|
||||||
source $HOME/.cargo/env 2>/dev/null || true
|
|
||||||
cargo build --release --manifest-path core/Cargo.toml
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
run: |
|
|
||||||
rm -rf web/dist/neode-ui
|
|
||||||
cd neode-ui && npm ci && npm run build
|
|
||||||
|
|
||||||
- name: Type check frontend
|
|
||||||
run: cd neode-ui && npx vue-tsc -b --noEmit
|
|
||||||
|
|
||||||
- name: Run frontend tests
|
|
||||||
run: cd neode-ui && npx vitest run
|
|
||||||
|
|
||||||
- name: Cache Debian Live ISO
|
|
||||||
run: |
|
|
||||||
WORK_DIR="image-recipe/build/auto-installer"
|
|
||||||
mkdir -p "$WORK_DIR"
|
|
||||||
CACHED="/home/archipelago/archy/image-recipe/build/auto-installer/debian-live-installer.iso"
|
|
||||||
if [ -f "$CACHED" ] && [ ! -f "$WORK_DIR/debian-live-installer.iso" ]; then
|
|
||||||
cp "$CACHED" "$WORK_DIR/debian-live-installer.iso"
|
|
||||||
echo "Cached Debian Live ISO copied ($(du -h "$WORK_DIR/debian-live-installer.iso" | cut -f1))"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Configure root podman for insecure registry
|
|
||||||
run: |
|
|
||||||
sudo mkdir -p /etc/containers/registries.conf.d
|
|
||||||
echo '[[registry]]
|
|
||||||
location = "80.71.235.15:3000"
|
|
||||||
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
|
|
||||||
|
|
||||||
- name: Include AIUI if available
|
|
||||||
run: |
|
|
||||||
# Copy AIUI from the deployed system (build server has it at /opt/archipelago/web-ui/aiui/)
|
|
||||||
if [ -d "/opt/archipelago/web-ui/aiui" ] && [ -f "/opt/archipelago/web-ui/aiui/index.html" ]; then
|
|
||||||
mkdir -p web/dist/neode-ui/aiui
|
|
||||||
cp -r /opt/archipelago/web-ui/aiui/* web/dist/neode-ui/aiui/
|
|
||||||
echo "AIUI included from /opt/archipelago/web-ui/aiui/"
|
|
||||||
else
|
|
||||||
echo "WARNING: AIUI not found on build server"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build unbundled ISO
|
|
||||||
run: |
|
|
||||||
cd image-recipe
|
|
||||||
export ARCHIPELAGO_BIN="$(pwd)/../core/target/release/archipelago"
|
|
||||||
ls -la "$ARCHIPELAGO_BIN" || echo "WARNING: binary not found"
|
|
||||||
sudo -E UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 \
|
|
||||||
ARCHIPELAGO_BIN="$ARCHIPELAGO_BIN" \
|
|
||||||
./build-auto-installer-iso.sh
|
|
||||||
|
|
||||||
- name: Copy to Builds
|
|
||||||
run: |
|
|
||||||
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1)
|
|
||||||
if [ -n "$ISO" ]; then
|
|
||||||
DATE=$(date +%Y%m%d-%H%M)
|
|
||||||
DEST="/var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-${DATE}.iso"
|
|
||||||
sudo cp "$ISO" "$DEST"
|
|
||||||
sudo chown 1000:1000 "$DEST"
|
|
||||||
echo "ISO: archipelago-unbundled-${DATE}.iso"
|
|
||||||
echo "Size: $(du -h "$DEST" | cut -f1)"
|
|
||||||
echo "SHA256: $(sha256sum "$DEST" | cut -d' ' -f1)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build report
|
|
||||||
if: always()
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
set +eo pipefail
|
|
||||||
echo "══════════════════════════════════════════"
|
|
||||||
echo "BUILD REPORT"
|
|
||||||
echo "══════════════════════════════════════════"
|
|
||||||
echo "Commit: $(git rev-parse --short HEAD) ($(git log -1 --format=%s))"
|
|
||||||
echo "Branch: ${GITHUB_REF_NAME:-unknown}"
|
|
||||||
echo "Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
|
||||||
echo "Runner: $(hostname)"
|
|
||||||
echo ""
|
|
||||||
echo "── Artifacts ──"
|
|
||||||
ls -lh image-recipe/results/*.iso 2>/dev/null || echo " No ISO produced"
|
|
||||||
ls -lh /var/lib/archipelago/filebrowser/Builds/archipelago-unbundled-*.iso 2>/dev/null | tail -3
|
|
||||||
echo ""
|
|
||||||
echo "── Rootfs contents check ──"
|
|
||||||
ROOTFS=$(ls image-recipe/build/auto-installer/archipelago-rootfs.tar 2>/dev/null) || true
|
|
||||||
if [ -n "$ROOTFS" ]; then
|
|
||||||
echo " rootfs.tar: $(sudo du -h "$ROOTFS" 2>/dev/null | cut -f1 || echo 'unknown')"
|
|
||||||
echo " nginx config: $(sudo tar tf "$ROOTFS" ./etc/nginx/sites-available/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
echo " SSL cert: $(sudo tar tf "$ROOTFS" ./etc/archipelago/ssl/archipelago.crt 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
echo " keyboard config: $(sudo tar tf "$ROOTFS" ./etc/default/keyboard 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
echo " console-setup: $(sudo tar tf "$ROOTFS" ./etc/default/console-setup 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
echo " kiosk launcher: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago-kiosk-launcher 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
echo " logind lid: $(sudo tar tf "$ROOTFS" ./etc/systemd/logind.conf.d/lid-ignore.conf 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
echo " backend binary: $(sudo tar tf "$ROOTFS" ./usr/local/bin/archipelago 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
echo " web-ui index: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
echo " AIUI: $(sudo tar tf "$ROOTFS" ./opt/archipelago/web-ui/aiui/index.html 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
echo " claude-api-proxy: $(sudo tar tf "$ROOTFS" ./opt/archipelago/claude-api-proxy.py 2>/dev/null && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
else
|
|
||||||
echo " rootfs.tar not found in workspace"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo "── ISO contents check ──"
|
|
||||||
ISO=$(ls image-recipe/results/archipelago-installer-unbundled-*.iso 2>/dev/null | head -1) || true
|
|
||||||
if [ -n "$ISO" ]; then
|
|
||||||
echo " ISO size: $(sudo du -h "$ISO" 2>/dev/null | cut -f1 || echo 'unknown')"
|
|
||||||
ISO_MOUNT=$(mktemp -d)
|
|
||||||
if sudo mount -o loop,ro "$ISO" "$ISO_MOUNT" 2>/dev/null; then
|
|
||||||
echo " auto-install.sh: $([ -f "$ISO_MOUNT/archipelago/auto-install.sh" ] && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
echo " rootfs.tar: $([ -f "$ISO_MOUNT/archipelago/rootfs.tar" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/rootfs.tar" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
|
||||||
echo " backend bin: $([ -f "$ISO_MOUNT/archipelago/bin/archipelago" ] && echo "PRESENT ($(sudo du -h "$ISO_MOUNT/archipelago/bin/archipelago" 2>/dev/null | cut -f1))" || echo 'MISSING')"
|
|
||||||
echo " frontend: $([ -f "$ISO_MOUNT/archipelago/web-ui/index.html" ] && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
echo " image-versions: $([ -f "$ISO_MOUNT/archipelago/scripts/image-versions.sh" ] && echo 'PRESENT' || echo 'MISSING')"
|
|
||||||
sudo umount "$ISO_MOUNT" 2>/dev/null || true
|
|
||||||
else
|
|
||||||
echo " Could not mount ISO for inspection"
|
|
||||||
fi
|
|
||||||
rmdir "$ISO_MOUNT" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
echo "══════════════════════════════════════════"
|
|
||||||
|
|
||||||
- name: Fix workspace permissions
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
sudo chown -R $(id -u):$(id -g) . 2>/dev/null || true
|
|
||||||
sudo chmod -R u+rwX . 2>/dev/null || true
|
|
||||||
sudo chown -R $(id -u):$(id -g) "$HOME/.cache/act" 2>/dev/null || true
|
|
||||||
sudo chmod -R u+rwX "$HOME/.cache/act" 2>/dev/null || true
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
name: Container Orchestration Tests
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev-iso, main]
|
|
||||||
paths:
|
|
||||||
- 'core/archipelago/src/**'
|
|
||||||
- 'core/container/src/**'
|
|
||||||
- 'scripts/container-*.sh'
|
|
||||||
- 'scripts/reconcile-*.sh'
|
|
||||||
- 'scripts/image-versions.sh'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
unit-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache cargo registry
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
core/target
|
|
||||||
key: cargo-test-${{ hashFiles('core/Cargo.lock') }}
|
|
||||||
|
|
||||||
- name: Run orchestration unit tests
|
|
||||||
working-directory: core
|
|
||||||
run: |
|
|
||||||
source $HOME/.cargo/env 2>/dev/null || true
|
|
||||||
echo "=== Container crate tests ==="
|
|
||||||
cargo test -p archipelago-container --no-fail-fast 2>&1
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Orchestration integration tests ==="
|
|
||||||
cargo test --test orchestration_tests --no-fail-fast 2>&1
|
|
||||||
|
|
||||||
- name: Verify cargo check (full crate)
|
|
||||||
working-directory: core
|
|
||||||
run: |
|
|
||||||
source $HOME/.cargo/env 2>/dev/null || true
|
|
||||||
cargo check --release 2>&1
|
|
||||||
|
|
||||||
smoke-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: unit-tests
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Run container smoke tests on .228
|
|
||||||
env:
|
|
||||||
ARCHIPELAGO_SSH_KEY: ~/.ssh/archipelago-deploy
|
|
||||||
run: |
|
|
||||||
# Only run if SSH key exists (CI runner has deploy access)
|
|
||||||
if [ -f "$ARCHIPELAGO_SSH_KEY" ]; then
|
|
||||||
bash scripts/dev-container-test.sh --once
|
|
||||||
else
|
|
||||||
echo "⚠ SSH key not available — skipping live smoke tests"
|
|
||||||
echo " To enable: add archipelago-deploy key to CI runner"
|
|
||||||
fi
|
|
||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 6
|
||||||
versionName = "0.1.0"
|
versionName = "0.4.2"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class InputWebSocket(
|
|||||||
private var password: String = ""
|
private var password: String = ""
|
||||||
private var sessionCookie: String? = null
|
private var sessionCookie: String? = null
|
||||||
|
|
||||||
|
/** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */
|
||||||
|
var playerId: Int = 0
|
||||||
|
|
||||||
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
|
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||||
val state: StateFlow<ConnectionState> = _state
|
val state: StateFlow<ConnectionState> = _state
|
||||||
|
|
||||||
@@ -109,10 +112,11 @@ class InputWebSocket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun doConnect() {
|
private fun doConnect() {
|
||||||
|
val basePath = "/ws/remote-input" + if (playerId > 0) "?p=$playerId" else ""
|
||||||
val wsUrl = serverUrl
|
val wsUrl = serverUrl
|
||||||
.replace("https://", "wss://")
|
.replace("https://", "wss://")
|
||||||
.replace("http://", "ws://")
|
.replace("http://", "ws://")
|
||||||
.trimEnd('/') + "/ws/remote-input"
|
.trimEnd('/') + basePath
|
||||||
|
|
||||||
val reqBuilder = Request.Builder().url(wsUrl)
|
val reqBuilder = Request.Builder().url(wsUrl)
|
||||||
sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") }
|
sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") }
|
||||||
@@ -160,7 +164,8 @@ class InputWebSocket(
|
|||||||
// ─── Input senders ──────────────────────────────────────────
|
// ─── Input senders ──────────────────────────────────────────
|
||||||
|
|
||||||
fun sendKey(key: String) {
|
fun sendKey(key: String) {
|
||||||
ws?.send("""{"t":"k","k":"$key"}""")
|
val pField = if (playerId > 0) ""","p":$playerId""" else ""
|
||||||
|
ws?.send("""{"t":"k","k":"$key"$pField}""")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMouseMove(dx: Int, dy: Int) {
|
fun sendMouseMove(dx: Int, dy: Int) {
|
||||||
|
|||||||
@@ -101,8 +101,10 @@ fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) C
|
|||||||
@Composable
|
@Composable
|
||||||
fun NESController(
|
fun NESController(
|
||||||
style: ControllerStyle = ControllerStyle.CLASSIC,
|
style: ControllerStyle = ControllerStyle.CLASSIC,
|
||||||
|
playerId: Int = 0,
|
||||||
onKey: (String) -> Unit,
|
onKey: (String) -> Unit,
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
|
onPlayerToggle: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val c = paletteFor(style)
|
val c = paletteFor(style)
|
||||||
@@ -184,29 +186,33 @@ fun NESController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A/B Buttons in inlay (same size as D-pad inlay, more right margin)
|
// A/B/C Buttons in inlay — triangle: C top, B+A bottom
|
||||||
Inlay(c, Modifier.align(Alignment.CenterEnd).padding(end = 48.dp).size(140.dp)) {
|
Inlay(c, Modifier.align(Alignment.CenterEnd).padding(end = 48.dp).size(140.dp)) {
|
||||||
Row(
|
Column(
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
// C on top (white)
|
||||||
Spacer(Modifier.height(10.dp))
|
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 44.dp) { onKey("c") }
|
||||||
RoundBtn(c, 52.dp) { onKey("Escape") }
|
Spacer(Modifier.height(6.dp))
|
||||||
Text("B", color = c.labelMuted, fontSize = 9.sp, fontWeight = FontWeight.Bold)
|
// B + A on bottom row
|
||||||
}
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Spacer(Modifier.width(16.dp))
|
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 44.dp) { onKey("b") }
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 44.dp) { onKey("a") }
|
||||||
RoundBtn(c, 52.dp) { onKey("Return") }
|
|
||||||
Text("A", color = c.labelMuted, fontSize = 9.sp, fontWeight = FontWeight.Bold)
|
|
||||||
Spacer(Modifier.height(10.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings button (bottom center)
|
// Player toggle + settings (bottom center)
|
||||||
SettingsBtn(c, Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp), onMenu)
|
Row(
|
||||||
|
Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
PlayerPill(c, playerId, onPlayerToggle)
|
||||||
|
SettingsBtn(c, Modifier, onMenu)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,6 +353,28 @@ fun RoundBtn(c: NESPalette, sz: Dp = 52.dp, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Colored round button — custom color instead of palette */
|
||||||
|
@Composable
|
||||||
|
fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Unit) {
|
||||||
|
var p by remember { mutableStateOf(false) }
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(sz)
|
||||||
|
.shadow(if (p) 1.dp else 4.dp, CircleShape)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Brush.verticalGradient(
|
||||||
|
if (p) listOf(pressColor, color.copy(alpha = 0.85f))
|
||||||
|
else listOf(color, color.copy(alpha = 0.8f))
|
||||||
|
))
|
||||||
|
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background(
|
||||||
|
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 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) {
|
||||||
@@ -370,19 +398,39 @@ fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onCli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Small settings gear button */
|
/** Settings gear button (48dp — large enough for easy tap on TV) */
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
var p by remember { mutableStateOf(false) }
|
var p by remember { mutableStateOf(false) }
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(24.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(if (p) c.capsulePress else c.capsule)
|
.background(if (p) c.capsulePress else c.capsule)
|
||||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Settings, "Settings", Modifier.size(14.dp), tint = c.labelMuted)
|
Icon(Icons.Default.Settings, "Settings", Modifier.size(28.dp), tint = c.labelMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Player ID toggle pill (P1/P2/ALL) */
|
||||||
|
@Composable
|
||||||
|
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
||||||
|
val label = when (playerId) { 1 -> "P1"; 2 -> "P2"; else -> "ALL" }
|
||||||
|
val accent = when (playerId) { 1 -> Color(0xFF00F0FF); 2 -> Color(0xFFFF0080); else -> c.labelMuted }
|
||||||
|
var p by remember { mutableStateOf(false) }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(28.dp)
|
||||||
|
.width(44.dp)
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.background(if (p) c.capsulePress else c.capsule)
|
||||||
|
.border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(6.dp))
|
||||||
|
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onToggle(); tryAwaitRelease(); p = false }) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(label, color = accent, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,9 +55,15 @@ fun NESKeyboard(
|
|||||||
var layer by remember { mutableStateOf(NKLayer.ALPHA) }
|
var layer by remember { mutableStateOf(NKLayer.ALPHA) }
|
||||||
var shifted by remember { mutableStateOf(false) }
|
var shifted by remember { mutableStateOf(false) }
|
||||||
var capsLock by remember { mutableStateOf(false) }
|
var capsLock by remember { mutableStateOf(false) }
|
||||||
|
var ctrlHeld by remember { mutableStateOf(false) }
|
||||||
val up = shifted || capsLock
|
val up = shifted || capsLock
|
||||||
|
|
||||||
fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false }
|
fun emit(k: String) {
|
||||||
|
val key = if (ctrlHeld) "ctrl+$k" else k
|
||||||
|
onKey(key)
|
||||||
|
if (shifted && !capsLock) shifted = false
|
||||||
|
if (ctrlHeld) ctrlHeld = false
|
||||||
|
}
|
||||||
fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) }
|
fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) }
|
||||||
|
|
||||||
// NES body wrapping keyboard
|
// NES body wrapping keyboard
|
||||||
@@ -113,9 +119,12 @@ fun NESKeyboard(
|
|||||||
NKey(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) {
|
NKey(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) {
|
||||||
layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false
|
layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false
|
||||||
}
|
}
|
||||||
NKey(",", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("comma") }
|
NKey("Ctrl", Modifier.weight(1.2f), keyBg, keyBgP, if (ctrlHeld) accent else keyTxt, 11) {
|
||||||
NKey("space", Modifier.weight(5f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
|
ctrlHeld = !ctrlHeld
|
||||||
NKey(".", Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit("period") }
|
}
|
||||||
|
NKey(",", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("comma") }
|
||||||
|
NKey("space", Modifier.weight(4f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
|
||||||
|
NKey(".", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("period") }
|
||||||
NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") }
|
NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-10
@@ -36,11 +36,13 @@ import com.archipelago.app.ui.theme.NES
|
|||||||
@Composable
|
@Composable
|
||||||
fun NESPortraitController(
|
fun NESPortraitController(
|
||||||
style: ControllerStyle = ControllerStyle.CLASSIC,
|
style: ControllerStyle = ControllerStyle.CLASSIC,
|
||||||
|
playerId: Int = 0,
|
||||||
onKey: (String) -> Unit,
|
onKey: (String) -> Unit,
|
||||||
onMouseMove: (Int, Int) -> Unit = { _, _ -> },
|
onMouseMove: (Int, Int) -> Unit = { _, _ -> },
|
||||||
onMouseClick: (Int) -> Unit = { _ -> },
|
onMouseClick: (Int) -> Unit = { _ -> },
|
||||||
onMouseScroll: (Int) -> Unit = { _ -> },
|
onMouseScroll: (Int) -> Unit = { _ -> },
|
||||||
onMenu: () -> Unit,
|
onMenu: () -> Unit,
|
||||||
|
onPlayerToggle: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val c = paletteFor(style)
|
val c = paletteFor(style)
|
||||||
val isClassic = style == ControllerStyle.CLASSIC
|
val isClassic = style == ControllerStyle.CLASSIC
|
||||||
@@ -111,16 +113,18 @@ fun NESPortraitController(
|
|||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
// A/B Buttons
|
// A/B/C Buttons — triangle: C top, B+A bottom
|
||||||
Inlay(c, Modifier.fillMaxWidth()) {
|
Inlay(c, Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Column(
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
) {
|
||||||
RoundBtn(c, 52.dp) { onKey("Escape") }
|
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 46.dp) { onKey("c") }
|
||||||
Spacer(Modifier.width(24.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
RoundBtn(c, 52.dp) { onKey("Return") }
|
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 46.dp) { onKey("b") }
|
||||||
|
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 46.dp) { onKey("a") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,8 +143,16 @@ fun NESPortraitController(
|
|||||||
|
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|
||||||
// Settings
|
// Player toggle + Settings
|
||||||
SettingsBtn(c, Modifier, onMenu)
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
PlayerPill(c, playerId, onPlayerToggle)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
SettingsBtn(c, Modifier, onMenu)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,14 @@ 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.CLASSIC) }
|
||||||
|
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
|
||||||
|
|
||||||
val ws = remember { InputWebSocket(scope) }
|
val ws = remember { InputWebSocket(scope) }
|
||||||
|
|
||||||
|
fun togglePlayer() {
|
||||||
|
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
||||||
|
ws.playerId = playerId
|
||||||
|
}
|
||||||
val connectionState by ws.state.collectAsState()
|
val connectionState by ws.state.collectAsState()
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
@@ -98,32 +104,44 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
when {
|
when {
|
||||||
isGamepadMode && isLandscape -> NESController(
|
isGamepadMode && isLandscape -> NESController(
|
||||||
style = controllerStyle,
|
style = controllerStyle,
|
||||||
|
playerId = playerId,
|
||||||
onKey = { ws.sendKey(it) },
|
onKey = { ws.sendKey(it) },
|
||||||
onMenu = { showModal = true },
|
onMenu = { showModal = true },
|
||||||
|
onPlayerToggle = ::togglePlayer,
|
||||||
)
|
)
|
||||||
isGamepadMode && !isLandscape -> NESPortraitController(
|
isGamepadMode && !isLandscape -> NESPortraitController(
|
||||||
style = controllerStyle,
|
style = controllerStyle,
|
||||||
|
playerId = playerId,
|
||||||
onKey = { ws.sendKey(it) },
|
onKey = { ws.sendKey(it) },
|
||||||
onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||||
onMouseClick = { ws.sendClick(it) },
|
onMouseClick = { ws.sendClick(it) },
|
||||||
onMouseScroll = { ws.sendScroll(it) },
|
onMouseScroll = { ws.sendScroll(it) },
|
||||||
onMenu = { showModal = true },
|
onMenu = { showModal = true },
|
||||||
|
onPlayerToggle = ::togglePlayer,
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
||||||
Column(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
Trackpad(
|
Column(Modifier.fillMaxSize()) {
|
||||||
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
Trackpad(
|
||||||
onClick = { ws.sendClick(it) },
|
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
|
||||||
onScroll = { ws.sendScroll(it) },
|
onClick = { ws.sendClick(it) },
|
||||||
onTwoFingerHold = { showModal = true },
|
onScroll = { ws.sendScroll(it) },
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
onTwoFingerHold = { showModal = true },
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||||
)
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
NESKeyboard(
|
)
|
||||||
style = controllerStyle,
|
NESKeyboard(
|
||||||
onKey = { ws.sendKey(it) },
|
style = controllerStyle,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onKey = { ws.sendKey(it) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Settings icon top-right in keyboard mode
|
||||||
|
com.archipelago.app.ui.components.SettingsBtn(
|
||||||
|
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||||
|
onClick = { showModal = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-127
@@ -1,127 +0,0 @@
|
|||||||
# Quick Build Guide - Archipelago Beta Release
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
Make sure you have:
|
|
||||||
- Docker or Podman installed
|
|
||||||
- `xorriso` installed (for ISO creation)
|
|
||||||
- Access to dev server: archipelago@192.168.1.228
|
|
||||||
|
|
||||||
**Note**: When building on the target server with `sudo`, the script will automatically install missing dependencies (`xorriso`, `podman`).
|
|
||||||
|
|
||||||
## Build Auto-Installer ISO
|
|
||||||
|
|
||||||
### Option 1: Build on Target Server (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# SSH to target server
|
|
||||||
ssh archipelago@192.168.1.228
|
|
||||||
|
|
||||||
# Navigate to project
|
|
||||||
cd ~/archy/image-recipe
|
|
||||||
|
|
||||||
# Run build (auto-installs missing deps)
|
|
||||||
sudo ./build-auto-installer-iso.sh
|
|
||||||
|
|
||||||
# Copy ISO back to your Mac
|
|
||||||
# On your Mac:
|
|
||||||
scp archipelago@192.168.1.228:~/archy/image-recipe/results/archipelago-auto-installer-*.iso .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Build from Mac (requires Docker)
|
|
||||||
|
|
||||||
**Important**: This requires Docker Desktop installed on macOS.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /Users/dorian/Projects/archy/image-recipe
|
|
||||||
|
|
||||||
# Capture current live server state
|
|
||||||
DEV_SERVER=archipelago@192.168.1.228 ./build-auto-installer-iso.sh
|
|
||||||
|
|
||||||
# ISO will be created in: results/archipelago-auto-installer-*.iso
|
|
||||||
```
|
|
||||||
|
|
||||||
## What the ISO Includes
|
|
||||||
|
|
||||||
✅ Complete Debian 12 root filesystem
|
|
||||||
✅ Pre-built Archipelago backend
|
|
||||||
✅ Pre-built frontend (web UI)
|
|
||||||
✅ **Prepackaged container images** (Bitcoin Knots, LND, UIs, and other bundled apps), loaded on first boot
|
|
||||||
✅ Nginx configuration (HTTPS ready)
|
|
||||||
✅ Auto-installer that:
|
|
||||||
- Detects internal disk
|
|
||||||
- Creates partitions (EFI + root)
|
|
||||||
- Extracts pre-built system
|
|
||||||
- Installs bootloader
|
|
||||||
- Reboots to working system
|
|
||||||
|
|
||||||
## What Users Need to Do Post-Install
|
|
||||||
|
|
||||||
1. **Start apps from the Web UI** – Container images are prepackaged and loaded on first boot. Bitcoin Knots + UI, LND + UI, and other bundled apps are ready to start from the Web UI without manual `podman run`. No need to pull or deploy core containers.
|
|
||||||
|
|
||||||
2. **Access Web UI** – Navigate to `http://[server-ip]`
|
|
||||||
|
|
||||||
## Testing the ISO
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Use VirtualBox, QEMU, or real hardware
|
|
||||||
qemu-system-x86_64 \
|
|
||||||
-m 4G \
|
|
||||||
-cdrom results/archipelago-auto-installer-*.iso \
|
|
||||||
-hda archipelago-test.qcow2 \
|
|
||||||
-boot d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
⚠️ **The auto-installer will ERASE the target disk!**
|
|
||||||
⚠️ Make sure to test on a non-production machine first
|
|
||||||
⚠️ Minimum 20GB disk space required (500GB+ recommended for Bitcoin)
|
|
||||||
|
|
||||||
## Build from Source (Alternative)
|
|
||||||
|
|
||||||
If you want to build everything from scratch instead of capturing the live server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BUILD_FROM_SOURCE=1 ./build-auto-installer-iso.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
- Build backend from Rust source
|
|
||||||
- Build frontend with `npm run build`
|
|
||||||
- Create fresh SSL certificates
|
|
||||||
- Generate default configs
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**ISO won't boot:**
|
|
||||||
- Ensure UEFI mode is enabled
|
|
||||||
- Try disabling Secure Boot
|
|
||||||
|
|
||||||
**Installer hangs:**
|
|
||||||
- Check the auto-start script fix is applied (see DEPLOYMENT.md)
|
|
||||||
|
|
||||||
**Backend doesn't detect containers:**
|
|
||||||
- Verify `/etc/sudoers.d/archipelago-podman` exists
|
|
||||||
- Check backend can run `sudo podman ps`
|
|
||||||
|
|
||||||
## Version Naming
|
|
||||||
|
|
||||||
ISOs are automatically named with timestamp:
|
|
||||||
```
|
|
||||||
archipelago-auto-installer-YYYYMMDD-HHMMSS.iso
|
|
||||||
```
|
|
||||||
|
|
||||||
For releases, rename to:
|
|
||||||
```
|
|
||||||
archipelago-v0.1.0-beta.1.iso
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps After Building
|
|
||||||
|
|
||||||
1. Test the ISO on VM
|
|
||||||
2. Verify web UI loads
|
|
||||||
3. Test container deployment
|
|
||||||
4. Document any issues
|
|
||||||
5. Tag the release in git
|
|
||||||
6. Upload ISO to distribution point
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
# CLAUDE.md — Archipelago (Archy)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Archipelago is a **Bitcoin Node OS** — bootable, self-sovereign personal server. Flash to USB, install on hardware, manage via web UI.
|
|
||||||
|
|
||||||
**Stack**: Rust backend + Vue 3 + TypeScript (strict) + Vite 7 + Tailwind + Pinia + Podman on Debian 12
|
|
||||||
**Version**: 1.3.0 | **Target**: x86_64 and ARM64
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Beta Freeze (2026-03-18)
|
|
||||||
|
|
||||||
**Phase 1: Feature Testing (internal) — WE ARE HERE**
|
|
||||||
|
|
||||||
Feature set is LOCKED. Only: bug fixes, security hardening, ISO build fixes, UI polish, testing.
|
|
||||||
No new features, no new apps, no new deps, no scope creep.
|
|
||||||
|
|
||||||
Track: `docs/BETA-PROGRESS.md` | Checklist: `docs/BETA-RELEASE-CHECKLIST.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd neode-ui && npm start # Local dev (mock backend :5959, Vite :8100)
|
|
||||||
cd neode-ui && npm run build # Build (outputs to web/dist/neode-ui/)
|
|
||||||
./scripts/deploy-to-target.sh --live # Deploy to live server (.228)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Infrastructure
|
|
||||||
|
|
||||||
| What | Where |
|
|
||||||
|------|-------|
|
|
||||||
| Dev server | `192.168.1.228` (SSH key: `~/.ssh/archipelago-deploy`) |
|
|
||||||
| Secondary | `192.168.1.198` |
|
|
||||||
| Git remote | `git.tx1138.com` (remote name: `tx1138`) |
|
|
||||||
| App registry | `80.71.235.15:3000/archipelago/` (HTTP, insecure) |
|
|
||||||
| CI runner | act_runner on .228, workflow: `.gitea/workflows/build-iso.yml` |
|
|
||||||
| ISO builds | FileBrowser at `http://192.168.1.228:8083` → Builds/ |
|
|
||||||
| SSH creds | Gitignored `scripts/deploy-config.sh` |
|
|
||||||
| Web password | `password123` |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Debian 12
|
|
||||||
├── Podman (rootless, user archipelago)
|
|
||||||
├── Nginx (80/443 → backend, app proxies)
|
|
||||||
├── Rust Backend (core/) on 127.0.0.1:5678
|
|
||||||
│ ├── core/archipelago/ — Binary, RPC, auth, sessions
|
|
||||||
│ └── core/container/ — PodmanClient, manifests, health
|
|
||||||
└── Vue.js UI (neode-ui/)
|
|
||||||
├── src/api/rpc-client.ts — All backend communication
|
|
||||||
├── src/stores/ — Pinia state
|
|
||||||
├── src/views/ — Pages
|
|
||||||
└── src/style.css — ALL styling (global classes only)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Data paths**: `/var/lib/archipelago/{app-id}/` (data), `/opt/archipelago/web-ui/` (frontend), `/usr/local/bin/archipelago` (binary)
|
|
||||||
|
|
||||||
## Critical Rules
|
|
||||||
|
|
||||||
1. **Never build Rust on macOS** — deploy script handles cross-compilation via rsync + remote build
|
|
||||||
2. **Always deploy after changes** — `./scripts/deploy-to-target.sh --live`
|
|
||||||
3. **Frontend builds to `web/dist/neode-ui/`** — not `neode-ui/dist/`
|
|
||||||
4. **Container images**: `scripts/image-versions.sh` is the single source of truth. All scripts use `$*_IMAGE` variables, never hardcoded registry paths.
|
|
||||||
5. **Type-check before committing** — `cd neode-ui && npx vue-tsc -b --noEmit`
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
- `<script setup lang="ts">` always — no Options API
|
|
||||||
- Global CSS in `style.css` — **never inline Tailwind**
|
|
||||||
- `.glass-button` for ALL buttons — `.gradient-button` is BANNED
|
|
||||||
- `.glass-card` for containers, `.path-option-card` for interactive cards
|
|
||||||
- `translateZ(0)` + `isolation: isolate` on glass elements (Chromium compositor fix)
|
|
||||||
- Pinia for state, typed RPC client, handle loading/error/empty states
|
|
||||||
|
|
||||||
## Backend (Rust)
|
|
||||||
|
|
||||||
- No `unwrap()`/`expect()` — use `?` with `.context()`
|
|
||||||
- `tracing` for logging, never `println!` or log secrets
|
|
||||||
- Backend binds `127.0.0.1` only — nginx handles external access
|
|
||||||
- Validate all input before path construction — reject `..`, `/`, null bytes
|
|
||||||
- `tokio` runtime, timeouts on all external ops
|
|
||||||
|
|
||||||
## Security (Post-Pentest)
|
|
||||||
|
|
||||||
- RBAC: explicit method allowlists, never prefix matching
|
|
||||||
- Session cookies: `SameSite=Lax; HttpOnly; Path=/`
|
|
||||||
- Rate-limit auth endpoints, rotate tokens after privilege escalation
|
|
||||||
- Validate redirect URLs with `isLocalRedirect()`, never `v-html` with user input
|
|
||||||
- Container security: drop ALL caps, add only required, `no-new-privileges`, memory limits, health checks
|
|
||||||
- See `.claude/rules/` for detailed crypto, API, container, and Bitcoin rules
|
|
||||||
|
|
||||||
## ISO Build & CI
|
|
||||||
|
|
||||||
CI builds on every push to `main` via git.tx1138.com Actions.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Manual build on .228:
|
|
||||||
ssh archipelago@192.168.1.228
|
|
||||||
cd ~/archy/image-recipe
|
|
||||||
sudo UNBUNDLED=1 DEV_SERVER=localhost BUILD_FROM_SOURCE=0 ./build-auto-installer-iso.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Debugging fresh installs** — SSH in and check:
|
|
||||||
```bash
|
|
||||||
cat /var/log/archipelago-install.log # Full installer output
|
|
||||||
cat /var/log/archipelago-first-boot-diagnostics.log # Service status, nginx, LUKS, etc.
|
|
||||||
sudo archipelago-diagnostics # Re-run diagnostics anytime
|
|
||||||
```
|
|
||||||
|
|
||||||
**Kiosk**: X11 on VT7, console on VT1. `Ctrl+Alt+F1` for terminal, `Ctrl+Alt+F7` for kiosk.
|
|
||||||
Toggle: `sudo archipelago-kiosk enable|disable|toggle`
|
|
||||||
|
|
||||||
## App Integration Checklist
|
|
||||||
|
|
||||||
When adding/fixing apps, check ALL of these:
|
|
||||||
- `core/archipelago/src/api/rpc/package/` — config, capabilities, deps
|
|
||||||
- `neode-ui/src/views/marketplace/marketplaceData.ts` — marketplace entry
|
|
||||||
- `image-recipe/configs/nginx-archipelago.conf` — proxy rules (HTTP + HTTPS)
|
|
||||||
- `scripts/image-versions.sh` — pinned image version
|
|
||||||
- `scripts/first-boot-containers.sh` — first boot creation
|
|
||||||
- `scripts/deploy-to-target.sh` — deploy logic
|
|
||||||
|
|
||||||
## Git
|
|
||||||
|
|
||||||
Commits: `type: description` (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`)
|
|
||||||
Push to: `git push tx1138 main`
|
|
||||||
+1
-1
@@ -28,7 +28,7 @@ npm test # Run tests
|
|||||||
|
|
||||||
### Backend (Rust)
|
### Backend (Rust)
|
||||||
|
|
||||||
Build on a Linux server (Debian 12), **not** macOS:
|
Build on a Linux server (Debian 13), **not** macOS:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo clippy --all-targets --all-features
|
cargo clippy --all-targets --all-features
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# Demo Deployment via Portainer
|
|
||||||
|
|
||||||
Deploy Archipelago with the **mock backend** for demos. No real node required.
|
|
||||||
|
|
||||||
## Quick Deploy (Portainer)
|
|
||||||
|
|
||||||
1. In Portainer: **Stacks** → **Add stack**
|
|
||||||
2. Name: `archy-demo`
|
|
||||||
3. **Web editor** → paste contents of `docker-compose.demo.yml`
|
|
||||||
4. Or **Build from repository**: use this repo URL and set Compose path to `docker-compose.demo.yml`
|
|
||||||
5. Deploy
|
|
||||||
|
|
||||||
**Access:** http://your-host:4848
|
|
||||||
|
|
||||||
## Mock Backend
|
|
||||||
|
|
||||||
- Uses the Node.js mock backend (not the Rust backend)
|
|
||||||
- Pre-loaded apps, fake data, simulated install/start/stop
|
|
||||||
- **Login password:** `password123`
|
|
||||||
|
|
||||||
## Port
|
|
||||||
|
|
||||||
Default: **4848**. To change, edit the ports mapping in `docker-compose.demo.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
ports:
|
|
||||||
- "YOUR_PORT:80"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Chat (Claude AI)
|
|
||||||
|
|
||||||
Set `ANTHROPIC_API_KEY` in the Portainer stack environment to enable real AI chat:
|
|
||||||
|
|
||||||
1. In the stack editor, add under **Environment variables**:
|
|
||||||
- `ANTHROPIC_API_KEY` = your Anthropic API key (starts with `sk-ant-api...`)
|
|
||||||
2. Redeploy the stack
|
|
||||||
|
|
||||||
Without this key, chat shows a "not configured" error. The key is passed to the `neode-backend` container which proxies requests to `api.anthropic.com`.
|
|
||||||
|
|
||||||
## Dev Mode
|
|
||||||
|
|
||||||
`VITE_DEV_MODE=existing` skips setup/onboarding and goes straight to login. For other flows:
|
|
||||||
|
|
||||||
- `setup` – Password setup screen first
|
|
||||||
- `onboarding` – Experimental onboarding flow
|
|
||||||
- `existing` – Login only (default for demo)
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, and decentralized identity through a glassmorphism web UI.
|
**Archipelago** is a bootable personal server OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage Bitcoin infrastructure, self-hosted apps, and decentralized identity through a glassmorphism web UI.
|
||||||
|
|
||||||
[](https://www.debian.org/)
|
[](https://www.debian.org/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.rust-lang.org/)
|
[](https://www.rust-lang.org/)
|
||||||
[](https://vuejs.org/)
|
[](https://vuejs.org/)
|
||||||
@@ -81,7 +81,7 @@ Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Pe
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- macOS or Linux for frontend development
|
- macOS or Linux for frontend development
|
||||||
- Linux dev server (Debian 12) for backend builds — **never build Rust on macOS for Linux**
|
- Linux dev server (Debian 13) for backend builds — **never build Rust on macOS for Linux**
|
||||||
- Node.js 20+, Rust stable toolchain
|
- Node.js 20+, Rust stable toolchain
|
||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
@@ -112,7 +112,7 @@ sudo ./build-auto-installer-iso.sh
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
Debian 12 (Bookworm)
|
Debian 13 (Trixie)
|
||||||
├── Rootless Podman (30 containers, archy-net DNS)
|
├── Rootless Podman (30 containers, archy-net DNS)
|
||||||
├── Nginx (reverse proxy, security headers, rate limiting)
|
├── Nginx (reverse proxy, security headers, rate limiting)
|
||||||
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678)
|
├── Rust Backend (JSON-RPC API on 127.0.0.1:5678)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Archipelago v1.0.0 Release Notes
|
# Archipelago v1.0.0 Release Notes
|
||||||
|
|
||||||
**Release Date**: March 2026
|
**Release Date**: March 2026
|
||||||
**Target Platform**: Debian 12 (Bookworm) — x86_64 and ARM64
|
**Target Platform**: Debian 13 (Trixie) — x86_64 and ARM64
|
||||||
|
|
||||||
## What is Archipelago?
|
## What is Archipelago?
|
||||||
|
|
||||||
@@ -109,3 +109,4 @@ Archipelago is open source. To contribute:
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License. See `LICENSE` for details.
|
MIT License. See `LICENSE` for details.
|
||||||
|
# 2026-04-18 ISO build trigger
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Archipelago App Catalog
|
||||||
|
|
||||||
|
Dynamic app catalog for the Archipelago marketplace. Nodes fetch this catalog to discover available apps.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. The Archipelago frontend fetches `catalog.json` from this repo
|
||||||
|
2. Apps listed here appear in every node's app store automatically
|
||||||
|
3. When a user installs an app, the backend pulls the Docker image and creates the container
|
||||||
|
|
||||||
|
## Adding a new app
|
||||||
|
|
||||||
|
Add an entry to `catalog.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-app",
|
||||||
|
"title": "My App",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "What it does",
|
||||||
|
"icon": "/assets/img/app-icons/my-app.svg",
|
||||||
|
"author": "Author",
|
||||||
|
"category": "data",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/my-app:1.0.0",
|
||||||
|
"repoUrl": "https://github.com/...",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": ["8080:8080"],
|
||||||
|
"volumes": ["/var/lib/archipelago/my-app:/data"],
|
||||||
|
"env": ["NODE_ENV=production"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For apps with hardcoded backend configs (Bitcoin, LND, etc.), `containerConfig` is optional.
|
||||||
|
For new apps, include `containerConfig` so the backend knows how to create the container.
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
money, commerce, data, home, nostr, networking, community, development, l484
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"updated": "2026-04-12T00:00:00Z",
|
||||||
|
"registry": "git.tx1138.com/lfg2025",
|
||||||
|
"featured": {
|
||||||
|
"id": "indeedhub",
|
||||||
|
"banner": "/assets/img/featured/indeedhub-banner.jpg",
|
||||||
|
"headline": "Stream Sovereignty",
|
||||||
|
"description": "Bitcoin documentaries with Nostr identity.",
|
||||||
|
"tag": "NOSTR IDENTITY // YOUR NODE"
|
||||||
|
},
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"id": "bitcoin-knots", "title": "Bitcoin Knots", "version": "28.1.0",
|
||||||
|
"description": "Run a full Bitcoin node. Validate and relay blocks and transactions.",
|
||||||
|
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
|
||||||
|
"author": "Bitcoin Knots", "category": "money", "tier": "core",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/bitcoin-knots:latest",
|
||||||
|
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lnd", "title": "LND", "version": "0.18.4",
|
||||||
|
"description": "Lightning Network Daemon. Fast Bitcoin payments through Lightning.",
|
||||||
|
"icon": "/assets/img/app-icons/lnd.svg",
|
||||||
|
"author": "Lightning Labs", "category": "money", "tier": "core",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/lnd:v0.18.4-beta",
|
||||||
|
"repoUrl": "https://github.com/lightningnetwork/lnd",
|
||||||
|
"requires": ["bitcoin-knots"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "btcpay-server", "title": "BTCPay Server", "version": "1.13.7",
|
||||||
|
"description": "Self-hosted Bitcoin payment processor.",
|
||||||
|
"icon": "/assets/img/app-icons/btcpay-server.png",
|
||||||
|
"author": "BTCPay Server Foundation", "category": "commerce", "tier": "core",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/btcpayserver:1.13.7",
|
||||||
|
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||||
|
"requires": ["bitcoin-knots"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mempool", "title": "Mempool Explorer", "version": "3.0.0",
|
||||||
|
"description": "Self-hosted Bitcoin blockchain and mempool visualizer.",
|
||||||
|
"icon": "/assets/img/app-icons/mempool.webp",
|
||||||
|
"author": "Mempool", "category": "money", "tier": "core",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/mempool-frontend:v3.0.0",
|
||||||
|
"repoUrl": "https://github.com/mempool/mempool",
|
||||||
|
"requires": ["bitcoin-knots", "electrumx"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "electrumx", "title": "ElectrumX", "version": "1.18.0",
|
||||||
|
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
|
||||||
|
"icon": "/assets/img/app-icons/electrumx.webp",
|
||||||
|
"author": "Luke Childs", "category": "money", "tier": "core",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/electrumx:v1.18.0",
|
||||||
|
"repoUrl": "https://github.com/spesmilo/electrumx",
|
||||||
|
"requires": ["bitcoin-knots"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "indeedhub", "title": "IndeeHub", "version": "1.0.0",
|
||||||
|
"description": "Bitcoin documentary streaming with Nostr identity.",
|
||||||
|
"icon": "/assets/img/app-icons/indeedhub.png",
|
||||||
|
"author": "IndeeHub", "category": "community",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/indeedhub:1.0.0",
|
||||||
|
"repoUrl": "https://github.com/indeedhub/indeedhub"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "botfights", "title": "BotFights", "version": "1.1.0",
|
||||||
|
"description": "Bot arena + 2-player arcade fighter with controller support and Adventure Mode.",
|
||||||
|
"icon": "/assets/img/app-icons/botfights.svg",
|
||||||
|
"author": "BotFights", "category": "community",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/botfights:1.1.0",
|
||||||
|
"repoUrl": "https://botfights.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gitea", "title": "Gitea", "version": "1.23",
|
||||||
|
"description": "Self-hosted Git service with container registry, CI/CD, issue tracking.",
|
||||||
|
"icon": "/assets/img/app-icons/gitea.svg",
|
||||||
|
"author": "Gitea", "category": "development",
|
||||||
|
"dockerImage": "docker.io/gitea/gitea:1.23",
|
||||||
|
"repoUrl": "https://gitea.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "filebrowser", "title": "File Browser", "version": "2.27.0",
|
||||||
|
"description": "Web-based file manager.",
|
||||||
|
"icon": "/assets/img/app-icons/file-browser.webp",
|
||||||
|
"author": "File Browser", "category": "data", "tier": "core",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
|
||||||
|
"repoUrl": "https://github.com/filebrowser/filebrowser"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vaultwarden", "title": "Vaultwarden", "version": "1.30.0",
|
||||||
|
"description": "Self-hosted password vault with zero-knowledge encryption.",
|
||||||
|
"icon": "/assets/img/app-icons/vaultwarden.webp",
|
||||||
|
"author": "Vaultwarden", "category": "data", "tier": "recommended",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/vaultwarden:1.30.0-alpine",
|
||||||
|
"repoUrl": "https://github.com/dani-garcia/vaultwarden"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "searxng", "title": "SearXNG", "version": "2024.1.0",
|
||||||
|
"description": "Privacy-respecting metasearch engine.",
|
||||||
|
"icon": "/assets/img/app-icons/searxng.png",
|
||||||
|
"author": "SearXNG", "category": "data", "tier": "recommended",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/searxng:latest",
|
||||||
|
"repoUrl": "https://github.com/searxng/searxng"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nostr-rs-relay", "title": "Nostr Relay", "version": "0.9.0",
|
||||||
|
"description": "Your own Nostr relay. Store events locally, relay for friends.",
|
||||||
|
"icon": "/assets/img/app-icons/nostr-rs-relay.svg",
|
||||||
|
"author": "scsiblade", "category": "nostr",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0",
|
||||||
|
"repoUrl": "https://sr.ht/~gheartsfield/nostr-rs-relay/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fedimint", "title": "Fedimint", "version": "0.10.0",
|
||||||
|
"description": "Federated Bitcoin mint with privacy through federated guardians.",
|
||||||
|
"icon": "/assets/img/app-icons/fedimint.png",
|
||||||
|
"author": "Fedimint", "category": "money",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/fedimintd:v0.10.0",
|
||||||
|
"repoUrl": "https://github.com/fedimint/fedimint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ollama", "title": "Ollama", "version": "0.5.4",
|
||||||
|
"description": "Run AI models locally. Private and on your hardware.",
|
||||||
|
"icon": "/assets/img/app-icons/ollama.png",
|
||||||
|
"author": "Ollama", "category": "data",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/ollama:latest",
|
||||||
|
"repoUrl": "https://github.com/ollama/ollama"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nextcloud", "title": "Nextcloud", "version": "28",
|
||||||
|
"description": "Your own private cloud. File sync, calendars, contacts.",
|
||||||
|
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||||
|
"author": "Nextcloud", "category": "data",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/nextcloud:28",
|
||||||
|
"repoUrl": "https://github.com/nextcloud/server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "jellyfin", "title": "Jellyfin", "version": "10.8.13",
|
||||||
|
"description": "Free media server. Stream movies, music, and photos.",
|
||||||
|
"icon": "/assets/img/app-icons/jellyfin.webp",
|
||||||
|
"author": "Jellyfin", "category": "data",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/jellyfin:10.8.13",
|
||||||
|
"repoUrl": "https://github.com/jellyfin/jellyfin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "immich", "title": "Immich", "version": "1.90.0",
|
||||||
|
"description": "High-performance photo and video backup with ML.",
|
||||||
|
"icon": "/assets/img/app-icons/immich.png",
|
||||||
|
"author": "Immich", "category": "data",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/immich-server:release",
|
||||||
|
"repoUrl": "https://github.com/immich-app/immich"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "homeassistant", "title": "Home Assistant", "version": "2024.1",
|
||||||
|
"description": "Open-source home automation.",
|
||||||
|
"icon": "/assets/img/app-icons/homeassistant.png",
|
||||||
|
"author": "Home Assistant", "category": "home",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/home-assistant:2024.1",
|
||||||
|
"repoUrl": "https://github.com/home-assistant/core"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grafana", "title": "Grafana", "version": "10.2.0",
|
||||||
|
"description": "Analytics and monitoring dashboards.",
|
||||||
|
"icon": "/assets/img/app-icons/grafana.png",
|
||||||
|
"author": "Grafana Labs", "category": "data", "tier": "recommended",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/grafana:10.2.0",
|
||||||
|
"repoUrl": "https://github.com/grafana/grafana"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tailscale", "title": "Tailscale", "version": "1.78.0",
|
||||||
|
"description": "Zero-config VPN with WireGuard mesh networking.",
|
||||||
|
"icon": "/assets/img/app-icons/tailscale.webp",
|
||||||
|
"author": "Tailscale", "category": "networking", "tier": "recommended",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/tailscale:stable",
|
||||||
|
"repoUrl": "https://github.com/tailscale/tailscale"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uptime-kuma", "title": "Uptime Kuma", "version": "1.23.0",
|
||||||
|
"description": "Self-hosted uptime monitoring.",
|
||||||
|
"icon": "/assets/img/app-icons/uptime-kuma.webp",
|
||||||
|
"author": "Uptime Kuma", "category": "data", "tier": "recommended",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/uptime-kuma:1",
|
||||||
|
"repoUrl": "https://github.com/louislam/uptime-kuma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nostr-vpn", "title": "Nostr VPN", "version": "0.3.7",
|
||||||
|
"description": "Tailscale-style mesh VPN with Nostr control plane.",
|
||||||
|
"icon": "/assets/img/app-icons/nostr-vpn.svg",
|
||||||
|
"author": "Martti Malmi", "category": "networking",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/nostr-vpn:v0.3.7",
|
||||||
|
"repoUrl": "https://github.com/mmalmi/nostr-vpn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fips", "title": "FIPS", "version": "0.1.0",
|
||||||
|
"description": "Free Internetworking Peering System. Encrypted mesh network.",
|
||||||
|
"icon": "/assets/img/app-icons/fips.svg",
|
||||||
|
"author": "Jim Corgan", "category": "networking",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/fips:v0.1.0",
|
||||||
|
"repoUrl": "https://github.com/jmcorgan/fips"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "routstr", "title": "Routstr", "version": "0.4.3",
|
||||||
|
"description": "Decentralized AI inference proxy with Cashu ecash.",
|
||||||
|
"icon": "/assets/img/app-icons/routstr.svg",
|
||||||
|
"author": "Routstr", "category": "community",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/routstr:v0.4.3",
|
||||||
|
"repoUrl": "https://github.com/routstr/routstr-core"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dwn", "title": "Decentralized Web Node", "version": "0.4.0",
|
||||||
|
"description": "Own your data with DID-based access control.",
|
||||||
|
"icon": "/assets/img/app-icons/dwn.svg",
|
||||||
|
"author": "TBD", "category": "data",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/dwn-server:main",
|
||||||
|
"repoUrl": "https://github.com/TBD54566975/dwn-server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "endurain", "title": "Endurain", "version": "0.8.0",
|
||||||
|
"description": "Self-hosted fitness tracking. Strava alternative.",
|
||||||
|
"icon": "/assets/img/app-icons/endurain.png",
|
||||||
|
"author": "Endurain", "category": "data",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/endurain:0.8.0",
|
||||||
|
"repoUrl": "https://github.com/joaovitoriasilva/endurain"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "penpot", "title": "Penpot", "version": "2.4",
|
||||||
|
"description": "Open-source design platform. Self-hosted Figma alternative.",
|
||||||
|
"icon": "/assets/img/app-icons/penpot.webp",
|
||||||
|
"author": "Penpot", "category": "data",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/penpot-frontend:2.4",
|
||||||
|
"repoUrl": "https://github.com/penpot/penpot"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "photoprism", "title": "PhotoPrism", "version": "240915",
|
||||||
|
"description": "AI-powered photo management with facial recognition.",
|
||||||
|
"icon": "/assets/img/app-icons/photoprism.svg",
|
||||||
|
"author": "PhotoPrism", "category": "data",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/photoprism:240915",
|
||||||
|
"repoUrl": "https://github.com/photoprism/photoprism"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
app:
|
||||||
|
id: botfights
|
||||||
|
name: BotFights
|
||||||
|
version: 1.0.0
|
||||||
|
description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
|
||||||
|
category: community
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: git.tx1138.com/lfg2025/botfights:1.1.0
|
||||||
|
pull_policy: always
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 500Mi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
cpu_limit: 2
|
||||||
|
memory_limit: 512Mi
|
||||||
|
disk_limit: 500Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: true
|
||||||
|
no_new_privileges: true
|
||||||
|
user: 1001
|
||||||
|
seccomp_profile: default
|
||||||
|
network_policy: bridge
|
||||||
|
apparmor_profile: default
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 9100
|
||||||
|
container: 9100
|
||||||
|
protocol: tcp # Web UI + API
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: botfights-data
|
||||||
|
target: /app/server/data
|
||||||
|
- type: tmpfs
|
||||||
|
target: /tmp
|
||||||
|
options: [rw,noexec,nosuid,size=64m]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://localhost:9100
|
||||||
|
path: /api/health
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Bot arena and arcade fighter with controller support
|
||||||
|
type: ui
|
||||||
|
port: 9100
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
author: Dorian
|
||||||
|
license: MIT
|
||||||
|
tags:
|
||||||
|
- bitcoin
|
||||||
|
- gaming
|
||||||
|
- arcade
|
||||||
|
- fighter
|
||||||
|
- bots
|
||||||
|
- competition
|
||||||
|
- controller
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
id: gitea
|
||||||
|
name: Gitea
|
||||||
|
version: "1.23"
|
||||||
|
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
|
||||||
|
category: development
|
||||||
|
icon: git-branch
|
||||||
|
port: 3000
|
||||||
|
internal_port: 3001
|
||||||
|
ssh_port: 2222
|
||||||
|
image: docker.io/gitea/gitea:1.23
|
||||||
|
tier: optional
|
||||||
|
|
||||||
|
requires:
|
||||||
|
memory_mb: 256
|
||||||
|
disk_mb: 500
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- host: /var/lib/archipelago/gitea/data
|
||||||
|
container: /data
|
||||||
|
- host: /var/lib/archipelago/gitea/config
|
||||||
|
container: /etc/gitea
|
||||||
|
|
||||||
|
environment:
|
||||||
|
GITEA__database__DB_TYPE: sqlite3
|
||||||
|
GITEA__server__SSH_PORT: "2222"
|
||||||
|
GITEA__server__SSH_LISTEN_PORT: "22"
|
||||||
|
GITEA__server__LFS_START_SERVER: "true"
|
||||||
|
GITEA__packages__ENABLED: "true"
|
||||||
|
GITEA__repository__ENABLE_PUSH_CREATE_USER: "true"
|
||||||
|
GITEA__repository__ENABLE_PUSH_CREATE_ORG: "true"
|
||||||
|
|
||||||
|
# Gitea hardcodes X-Frame-Options: SAMEORIGIN which blocks iframe embedding.
|
||||||
|
# Container binds to internal_port (3001), nginx proxies public port (3000)
|
||||||
|
# stripping the X-Frame-Options header so the app works in Archipelago's iframe.
|
||||||
|
nginx_proxy:
|
||||||
|
listen: 3000
|
||||||
|
proxy_pass: "http://127.0.0.1:3001"
|
||||||
|
extra_headers:
|
||||||
|
- "proxy_hide_header X-Frame-Options"
|
||||||
|
- "proxy_hide_header Content-Security-Policy"
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
endpoint: /
|
||||||
|
interval: 120
|
||||||
|
timeout: 5
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
features:
|
||||||
|
- Git repositories with web UI
|
||||||
|
- Built-in container/package registry
|
||||||
|
- Issue tracking and pull requests
|
||||||
|
- CI/CD via Gitea Actions
|
||||||
|
- Lightweight (SQLite, no external DB needed)
|
||||||
Generated
+1
-1
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.3.1"
|
version = "1.7.1-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.3.4"
|
version = "1.7.1-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
authors = ["Archipelago Team"]
|
authors = ["Archipelago Team"]
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
//! HTTP handlers for the content-addressed blob store.
|
||||||
|
//!
|
||||||
|
//! - `POST /api/blob` — session-authenticated. Raw body is the blob;
|
||||||
|
//! headers set mime/filename. Returns `{cid, size, mime}`.
|
||||||
|
//! - `GET /blob/<cid>?cap=<hex>&exp=<epoch>&peer=<pubkey>` — peer-facing.
|
||||||
|
//! Capability verified against the stored HMAC key; bytes streamed back.
|
||||||
|
|
||||||
|
use super::{build_response, ApiHandler};
|
||||||
|
use crate::blobs::BlobStore;
|
||||||
|
use anyhow::Result;
|
||||||
|
use hyper::{Body, HeaderMap, Response, StatusCode};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Read the archipelago .onion address if Tor has published one, so uploads
|
||||||
|
/// that need to be publicly reachable (profile pictures, banners) can return
|
||||||
|
/// a URL a peer outside the LAN can actually fetch. Returns `None` before
|
||||||
|
/// onboarding or when Tor isn't running — callers fall back to the local
|
||||||
|
/// self-test URL.
|
||||||
|
async fn read_self_onion(data_dir: &Path) -> Option<String> {
|
||||||
|
let hostnames = data_dir.join("tor-hostnames").join("archipelago");
|
||||||
|
let legacy = Path::new("/var/lib/archipelago/tor-hostnames/archipelago");
|
||||||
|
for p in [hostnames.as_path(), legacy] {
|
||||||
|
if let Ok(s) = tokio::fs::read_to_string(p).await {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiHandler {
|
||||||
|
pub(super) async fn handle_blob_upload(
|
||||||
|
store: &Arc<BlobStore>,
|
||||||
|
self_pubkey_hex: &str,
|
||||||
|
data_dir: &Path,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
body: hyper::body::Bytes,
|
||||||
|
) -> Result<Response<Body>> {
|
||||||
|
let mime = headers
|
||||||
|
.get("x-blob-mime")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
let filename = headers
|
||||||
|
.get("x-blob-filename")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let bytes = body.to_vec();
|
||||||
|
// Uploads through /api/blob come from the node owner's session and
|
||||||
|
// are almost always intended for external consumption (profile
|
||||||
|
// pictures, banners). Store them public so `/blob/<cid>` serves
|
||||||
|
// without a capability check — external Nostr clients fetching a
|
||||||
|
// kind-0 `picture` URL have no cap and can't get one.
|
||||||
|
match store.put(&bytes, &mime, filename, None, true).await {
|
||||||
|
Ok(meta) => {
|
||||||
|
let exp =
|
||||||
|
(chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
|
||||||
|
let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp);
|
||||||
|
let self_test_url = format!(
|
||||||
|
"/blob/{}?cap={}&exp={}&peer={}",
|
||||||
|
meta.cid, cap, exp, self_pubkey_hex
|
||||||
|
);
|
||||||
|
let public_url = match read_self_onion(data_dir).await {
|
||||||
|
Some(onion) => format!("http://{}/blob/{}", onion, meta.cid),
|
||||||
|
// Pre-onboarding / Tor-not-up: surface the local path so
|
||||||
|
// the UI doesn't break; publishing to Nostr should wait
|
||||||
|
// until Tor is live anyway.
|
||||||
|
None => format!("/blob/{}", meta.cid),
|
||||||
|
};
|
||||||
|
let resp = serde_json::json!({
|
||||||
|
"cid": meta.cid,
|
||||||
|
"size": meta.size,
|
||||||
|
"mime": meta.mime,
|
||||||
|
"filename": meta.filename,
|
||||||
|
"public_url": public_url,
|
||||||
|
"self_test_url": self_test_url,
|
||||||
|
});
|
||||||
|
Ok(build_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
"application/json",
|
||||||
|
Body::from(serde_json::to_vec(&resp).unwrap_or_default()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"text/plain",
|
||||||
|
Body::from(format!("blob upload failed: {}", e)),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Share-to-mesh iframe intent. Mirrors `handle_blob_upload` but adds
|
||||||
|
/// CORS headers for the requesting app origin and returns a small JSON
|
||||||
|
/// payload the app forwards to its parent via postMessage:
|
||||||
|
/// `{ type: "share-to-mesh", cid, size, mime, filename }`.
|
||||||
|
pub(super) async fn handle_share_to_mesh(
|
||||||
|
store: &Arc<BlobStore>,
|
||||||
|
self_pubkey_hex: &str,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
body: hyper::body::Bytes,
|
||||||
|
origin: &str,
|
||||||
|
) -> Result<Response<Body>> {
|
||||||
|
let mime = headers
|
||||||
|
.get("x-blob-mime")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
let filename = headers
|
||||||
|
.get("x-blob-filename")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let bytes = body.to_vec();
|
||||||
|
let meta = match store.put(&bytes, &mime, filename, None, false).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"text/plain",
|
||||||
|
Body::from(format!("share-to-mesh failed: {}", e)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Self-signed capability so the app can preview/download its own
|
||||||
|
// upload before the user has picked a peer.
|
||||||
|
let exp = (chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
|
||||||
|
let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp);
|
||||||
|
let self_url = format!(
|
||||||
|
"/blob/{}?cap={}&exp={}&peer={}",
|
||||||
|
meta.cid, cap, exp, self_pubkey_hex
|
||||||
|
);
|
||||||
|
let resp = serde_json::json!({
|
||||||
|
"type": "share-to-mesh",
|
||||||
|
"cid": meta.cid,
|
||||||
|
"size": meta.size,
|
||||||
|
"mime": meta.mime,
|
||||||
|
"filename": meta.filename,
|
||||||
|
"self_url": self_url,
|
||||||
|
});
|
||||||
|
let body_vec = serde_json::to_vec(&resp).unwrap_or_default();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Access-Control-Allow-Origin", origin)
|
||||||
|
.header("Access-Control-Allow-Credentials", "true")
|
||||||
|
.header("Vary", "Origin")
|
||||||
|
.body(Body::from(body_vec))
|
||||||
|
.unwrap_or_else(|_| Response::new(Body::from("internal error"))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_blob_download(
|
||||||
|
store: &Arc<BlobStore>,
|
||||||
|
path: &str,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<Response<Body>> {
|
||||||
|
let cid = path.strip_prefix("/blob/").unwrap_or("");
|
||||||
|
if cid.is_empty() || !cid.chars().all(|c| c.is_ascii_hexdigit()) || cid.len() != 64 {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"text/plain",
|
||||||
|
Body::from("invalid cid"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public blobs (profile pictures, banners) bypass the capability
|
||||||
|
// check — their CID is published on Nostr relays where any reader
|
||||||
|
// can see it, and external readers have no way to obtain a cap.
|
||||||
|
// Only blobs explicitly marked public at upload time qualify.
|
||||||
|
let is_public = store.meta(cid).await.map(|m| m.public).unwrap_or(false);
|
||||||
|
|
||||||
|
if !is_public {
|
||||||
|
let mut cap = None;
|
||||||
|
let mut exp: Option<u64> = None;
|
||||||
|
let mut peer = None;
|
||||||
|
for pair in query.split('&') {
|
||||||
|
let mut it = pair.splitn(2, '=');
|
||||||
|
match (it.next(), it.next()) {
|
||||||
|
(Some("cap"), Some(v)) => cap = Some(v.to_string()),
|
||||||
|
(Some("exp"), Some(v)) => exp = v.parse().ok(),
|
||||||
|
(Some("peer"), Some(v)) => peer = Some(v.to_string()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (Some(cap), Some(exp), Some(peer)) = (cap, exp, peer) else {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"text/plain",
|
||||||
|
Body::from("missing cap/exp/peer"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = store.verify_capability(cid, &peer, exp, &cap) {
|
||||||
|
tracing::warn!("blob cap rejected: cid={} peer={} reason={}", cid, peer, e);
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"text/plain",
|
||||||
|
Body::from(format!("capability rejected: {}", e)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = match store.get(cid).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"text/plain",
|
||||||
|
Body::from("blob not found"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mime = store
|
||||||
|
.meta(cid)
|
||||||
|
.await
|
||||||
|
.map(|m| m.mime)
|
||||||
|
.unwrap_or_else(|_| "application/octet-stream".to_string());
|
||||||
|
Ok(build_response(StatusCode::OK, &mime, Body::from(bytes)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
use super::build_response;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use super::build_response;use crate::content_server;
|
use crate::content_server;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use hyper::{Response, StatusCode};
|
use hyper::{Response, StatusCode};
|
||||||
|
|
||||||
use super::{ApiHandler, is_valid_app_id};
|
use super::{is_valid_app_id, ApiHandler};
|
||||||
|
|
||||||
impl ApiHandler {
|
impl ApiHandler {
|
||||||
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
||||||
@@ -25,14 +26,22 @@ impl ApiHandler {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let body = serde_json::to_vec(&serde_json::json!({ "items": items }))
|
let body =
|
||||||
.unwrap_or_default();
|
serde_json::to_vec(&serde_json::json!({ "items": items })).unwrap_or_default();
|
||||||
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
|
Ok(build_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(body),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let body = serde_json::json!({ "error": e.to_string() });
|
let body = serde_json::json!({ "error": e.to_string() });
|
||||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||||
Ok(build_response(StatusCode::INTERNAL_SERVER_ERROR, "application/json", hyper::Body::from(body_bytes)))
|
Ok(build_response(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(body_bytes),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +53,11 @@ impl ApiHandler {
|
|||||||
) -> Result<Response<hyper::Body>> {
|
) -> Result<Response<hyper::Body>> {
|
||||||
let content_id = path.strip_prefix("/content/").unwrap_or("");
|
let content_id = path.strip_prefix("/content/").unwrap_or("");
|
||||||
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||||
return Ok(build_response(StatusCode::BAD_REQUEST, "text/plain", hyper::Body::from("Invalid content ID")));
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from("Invalid content ID"),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract payment token from X-Payment-Token header
|
// Extract payment token from X-Payment-Token header
|
||||||
@@ -90,16 +103,17 @@ impl ApiHandler {
|
|||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
total,
|
total,
|
||||||
}) => {
|
}) => Ok(Response::builder()
|
||||||
Ok(Response::builder()
|
.status(StatusCode::PARTIAL_CONTENT)
|
||||||
.status(StatusCode::PARTIAL_CONTENT)
|
.header("Content-Type", mime_type)
|
||||||
.header("Content-Type", mime_type)
|
.header("Content-Length", bytes.len().to_string())
|
||||||
.header("Content-Length", bytes.len().to_string())
|
.header(
|
||||||
.header("Content-Range", format!("bytes {}-{}/{}", start, end, total))
|
"Content-Range",
|
||||||
.header("Accept-Ranges", "bytes")
|
format!("bytes {}-{}/{}", start, end, total),
|
||||||
.body(hyper::Body::from(bytes))
|
)
|
||||||
.unwrap())
|
.header("Accept-Ranges", "bytes")
|
||||||
}
|
.body(hyper::Body::from(bytes))
|
||||||
|
.unwrap()),
|
||||||
Ok(content_server::ServeResult::PaymentRequired(price_sats)) => {
|
Ok(content_server::ServeResult::PaymentRequired(price_sats)) => {
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"error": "Payment required",
|
"error": "Payment required",
|
||||||
@@ -107,16 +121,80 @@ impl ApiHandler {
|
|||||||
"payment_header": "X-Payment-Token",
|
"payment_header": "X-Payment-Token",
|
||||||
});
|
});
|
||||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||||
Ok(build_response(StatusCode::PAYMENT_REQUIRED, "application/json", hyper::Body::from(body_bytes)))
|
Ok(build_response(
|
||||||
|
StatusCode::PAYMENT_REQUIRED,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(body_bytes),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
Ok(content_server::ServeResult::Forbidden) => {
|
Ok(content_server::ServeResult::Forbidden) => Ok(build_response(
|
||||||
Ok(build_response(StatusCode::FORBIDDEN, "application/json", hyper::Body::from(
|
StatusCode::FORBIDDEN,
|
||||||
r#"{"error":"Access denied — federation peer required"}"#,
|
"application/json",
|
||||||
)))
|
hyper::Body::from(r#"{"error":"Access denied — federation peer required"}"#),
|
||||||
|
)),
|
||||||
|
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from("Content not found"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve a degraded preview of paid content (blurred image or first 2% of video).
|
||||||
|
pub(super) async fn handle_content_preview(
|
||||||
|
path: &str,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Response<hyper::Body>> {
|
||||||
|
// Path format: /content/{id}/preview
|
||||||
|
let content_id = path
|
||||||
|
.strip_prefix("/content/")
|
||||||
|
.and_then(|s| s.strip_suffix("/preview"))
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if content_id.is_empty() || !is_valid_app_id(content_id) {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from("Invalid content ID"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
match content_server::serve_content_preview(&config.data_dir, content_id).await {
|
||||||
|
Ok(content_server::PreviewResult::FullContent(bytes, mime_type)) => {
|
||||||
|
let len = bytes.len();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", mime_type)
|
||||||
|
.header("Content-Length", len.to_string())
|
||||||
|
.body(hyper::Body::from(bytes))
|
||||||
|
.unwrap())
|
||||||
}
|
}
|
||||||
Ok(content_server::ServeResult::NotFound) | Err(_) => {
|
Ok(content_server::PreviewResult::BlurPreview(bytes, mime_type)) => {
|
||||||
Ok(build_response(StatusCode::NOT_FOUND, "text/plain", hyper::Body::from("Content not found")))
|
let len = bytes.len();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", mime_type)
|
||||||
|
.header("Content-Length", len.to_string())
|
||||||
|
.header("X-Content-Preview", "blur")
|
||||||
|
.body(hyper::Body::from(bytes))
|
||||||
|
.unwrap())
|
||||||
}
|
}
|
||||||
|
Ok(content_server::PreviewResult::TruncatedPreview(bytes, mime_type, total_size)) => {
|
||||||
|
let len = bytes.len();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", mime_type)
|
||||||
|
.header("Content-Length", len.to_string())
|
||||||
|
.header("X-Content-Preview", "truncated")
|
||||||
|
.header("X-Content-Total-Size", total_size.to_string())
|
||||||
|
.body(hyper::Body::from(bytes))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
Ok(content_server::PreviewResult::NotFound) | Err(_) => Ok(build_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from("Preview not available"),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
use super::build_response;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use super::build_response;use crate::network::dwn_store::DwnStore;
|
use crate::network::dwn_store::DwnStore;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use hyper::{Response, StatusCode};
|
use hyper::{Response, StatusCode};
|
||||||
|
|
||||||
@@ -10,11 +11,14 @@ impl ApiHandler {
|
|||||||
pub(super) async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
|
pub(super) async fn handle_dwn_health(config: &Config) -> Result<Response<hyper::Body>> {
|
||||||
match DwnStore::new(&config.data_dir).await {
|
match DwnStore::new(&config.data_dir).await {
|
||||||
Ok(store) => {
|
Ok(store) => {
|
||||||
let stats = store.stats().await.unwrap_or(crate::network::dwn_store::StoreStats {
|
let stats = store
|
||||||
message_count: 0,
|
.stats()
|
||||||
protocol_count: 0,
|
.await
|
||||||
total_bytes: 0,
|
.unwrap_or(crate::network::dwn_store::StoreStats {
|
||||||
});
|
message_count: 0,
|
||||||
|
protocol_count: 0,
|
||||||
|
total_bytes: 0,
|
||||||
|
});
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"message_count": stats.message_count,
|
"message_count": stats.message_count,
|
||||||
@@ -27,7 +31,11 @@ impl ApiHandler {
|
|||||||
.body(hyper::Body::from(body.to_string()))
|
.body(hyper::Body::from(body.to_string()))
|
||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
Err(_) => Ok(build_response(StatusCode::SERVICE_UNAVAILABLE, "application/json", hyper::Body::from(r#"{"status":"unavailable"}"#))),
|
Err(_) => Ok(build_response(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"status":"unavailable"}"#),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,12 +70,8 @@ impl ApiHandler {
|
|||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
for message in &messages {
|
for message in &messages {
|
||||||
let interface = message["descriptor"]["interface"]
|
let interface = message["descriptor"]["interface"].as_str().unwrap_or("");
|
||||||
.as_str()
|
let method = message["descriptor"]["method"].as_str().unwrap_or("");
|
||||||
.unwrap_or("");
|
|
||||||
let method = message["descriptor"]["method"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
let result = match (interface, method) {
|
let result = match (interface, method) {
|
||||||
("Records", "Write") => {
|
("Records", "Write") => {
|
||||||
@@ -88,7 +92,9 @@ impl ApiHandler {
|
|||||||
Ok(msg) => {
|
Ok(msg) => {
|
||||||
serde_json::json!({"status": {"code": 202}, "entry": msg})
|
serde_json::json!({"status": {"code": 202}, "entry": msg})
|
||||||
}
|
}
|
||||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
Err(e) => {
|
||||||
|
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -97,7 +103,9 @@ impl ApiHandler {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
|
Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}),
|
||||||
Err(e) => serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}),
|
Err(e) => {
|
||||||
|
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,26 +140,26 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
("Records", "Read") => {
|
("Records", "Read") => {
|
||||||
let record_id = message["descriptor"]["recordId"]
|
let record_id = message["descriptor"]["recordId"].as_str().unwrap_or("");
|
||||||
.as_str()
|
|
||||||
.unwrap_or("");
|
|
||||||
match store.read_message(record_id).await {
|
match store.read_message(record_id).await {
|
||||||
Ok(Some(msg)) => {
|
Ok(Some(msg)) => {
|
||||||
serde_json::json!({"status": {"code": 200}, "entry": msg})
|
serde_json::json!({"status": {"code": 200}, "entry": msg})
|
||||||
}
|
}
|
||||||
Ok(None) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
Ok(None) => {
|
||||||
|
serde_json::json!({"status": {"code": 404, "detail": "Record not found"}})
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
("Records", "Delete") => {
|
("Records", "Delete") => {
|
||||||
let record_id = message["descriptor"]["recordId"]
|
let record_id = message["descriptor"]["recordId"].as_str().unwrap_or("");
|
||||||
.as_str()
|
|
||||||
.unwrap_or("");
|
|
||||||
match store.delete_message(record_id).await {
|
match store.delete_message(record_id).await {
|
||||||
Ok(true) => serde_json::json!({"status": {"code": 200}}),
|
Ok(true) => serde_json::json!({"status": {"code": 200}}),
|
||||||
Ok(false) => serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}),
|
Ok(false) => {
|
||||||
|
serde_json::json!({"status": {"code": 404, "detail": "Record not found"}})
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
serde_json::json!({"status": {"code": 500, "detail": e.to_string()}})
|
||||||
}
|
}
|
||||||
@@ -184,6 +192,10 @@ impl ApiHandler {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(build_response(http_status, "application/json", hyper::Body::from(response_body)))
|
Ok(build_response(
|
||||||
|
http_status,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(response_body),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod blob;
|
||||||
mod content;
|
mod content;
|
||||||
mod dwn;
|
mod dwn;
|
||||||
mod node_message;
|
mod node_message;
|
||||||
@@ -7,12 +8,14 @@ mod remote_relay;
|
|||||||
mod websocket;
|
mod websocket;
|
||||||
|
|
||||||
use crate::api::rpc::RpcHandler;
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use crate::blobs::BlobStore;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::monitoring::MetricsStore;
|
use crate::monitoring::MetricsStore;
|
||||||
use crate::session::{self, SessionStore};
|
use crate::session::{self, SessionStore};
|
||||||
use crate::state::StateManager;
|
use crate::state::StateManager;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use hyper::{Method, Request, Response, StatusCode};
|
use hyper::{Method, Request, Response, StatusCode};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
@@ -20,7 +23,11 @@ use tracing::debug;
|
|||||||
/// Build an HTTP response without unwrap. Falls back to a plain 500 if builder fails.
|
/// Build an HTTP response without unwrap. Falls back to a plain 500 if builder fails.
|
||||||
// Used by handler submodules after unwrap elimination
|
// Used by handler submodules after unwrap elimination
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(super) fn build_response(status: StatusCode, content_type: &str, body: hyper::Body) -> Response<hyper::Body> {
|
pub(super) fn build_response(
|
||||||
|
status: StatusCode,
|
||||||
|
content_type: &str,
|
||||||
|
body: hyper::Body,
|
||||||
|
) -> Response<hyper::Body> {
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(status)
|
.status(status)
|
||||||
.header("Content-Type", content_type)
|
.header("Content-Type", content_type)
|
||||||
@@ -36,6 +43,10 @@ pub struct ApiHandler {
|
|||||||
session_store: SessionStore,
|
session_store: SessionStore,
|
||||||
/// Broadcast channel for relaying companion app input to remote browsers.
|
/// Broadcast channel for relaying companion app input to remote browsers.
|
||||||
input_relay_tx: broadcast::Sender<String>,
|
input_relay_tx: broadcast::Sender<String>,
|
||||||
|
/// Content-addressed blob store for attachments shared over mesh/federation.
|
||||||
|
blob_store: Arc<BlobStore>,
|
||||||
|
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
|
||||||
|
self_pubkey_hex: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiHandler {
|
impl ApiHandler {
|
||||||
@@ -56,6 +67,27 @@ impl ApiHandler {
|
|||||||
);
|
);
|
||||||
let (input_relay_tx, _) = broadcast::channel(64);
|
let (input_relay_tx, _) = broadcast::channel(64);
|
||||||
|
|
||||||
|
// Derive a blob-store capability key from the node's Ed25519 signing
|
||||||
|
// key. SHA-256 domain-separated so rotating the identity rotates
|
||||||
|
// every outstanding capability token (intentional — prevents a
|
||||||
|
// replaced node from honouring old caps).
|
||||||
|
let identity_dir = config.data_dir.join("identity");
|
||||||
|
let identity = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(identity.signing_key().to_bytes());
|
||||||
|
hasher.update(b"|archipelago-blob-cap-v1");
|
||||||
|
let mut cap_key = [0u8; 32];
|
||||||
|
cap_key.copy_from_slice(&hasher.finalize());
|
||||||
|
let blob_store = Arc::new(BlobStore::open(&config.data_dir, cap_key).await?);
|
||||||
|
let self_pubkey_hex = hex::encode(identity.signing_key().verifying_key().as_bytes());
|
||||||
|
|
||||||
|
// Share blob store with the RPC layer so mesh.send-content /
|
||||||
|
// mesh.fetch-content can reach the same instance (single cap_key,
|
||||||
|
// single on-disk root) without re-opening it.
|
||||||
|
rpc_handler
|
||||||
|
.set_blob_store(blob_store.clone(), self_pubkey_hex.clone())
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
config,
|
config,
|
||||||
rpc_handler,
|
rpc_handler,
|
||||||
@@ -63,6 +95,8 @@ impl ApiHandler {
|
|||||||
metrics_store,
|
metrics_store,
|
||||||
session_store,
|
session_store,
|
||||||
input_relay_tx,
|
input_relay_tx,
|
||||||
|
blob_store,
|
||||||
|
self_pubkey_hex,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,9 +139,7 @@ impl ApiHandler {
|
|||||||
/// Validate the Origin header against allowed origins.
|
/// Validate the Origin header against allowed origins.
|
||||||
/// Returns the matched origin if valid, None if cross-origin is not allowed.
|
/// Returns the matched origin if valid, None if cross-origin is not allowed.
|
||||||
fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
|
fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
|
||||||
let origin = headers
|
let origin = headers.get("origin").and_then(|v| v.to_str().ok())?;
|
||||||
.get("origin")
|
|
||||||
.and_then(|v| v.to_str().ok())?;
|
|
||||||
let allowed = self.allowed_origins();
|
let allowed = self.allowed_origins();
|
||||||
if allowed.iter().any(|a| a == origin) {
|
if allowed.iter().any(|a| a == origin) {
|
||||||
Some(origin.to_string())
|
Some(origin.to_string())
|
||||||
@@ -116,10 +148,37 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_request(
|
/// Permissive origin check for the share-to-mesh iframe intent: any scheme
|
||||||
&self,
|
/// http(s):// followed by the configured host_ip, optionally `:port`. Apps
|
||||||
req: Request<hyper::Body>,
|
/// proxied under other ports (APP_PORTS) call this from within the same
|
||||||
) -> Result<Response<hyper::Body>> {
|
/// node, so they share host_ip but not port. The session cookie still has
|
||||||
|
/// to be valid — this is a sanity check, not the primary auth.
|
||||||
|
fn validate_app_origin(&self, headers: &hyper::HeaderMap) -> Option<String> {
|
||||||
|
let origin = headers.get("origin").and_then(|v| v.to_str().ok())?;
|
||||||
|
// Allow localhost dev server too so the Vite frontend can exercise it.
|
||||||
|
if self.config.dev_mode && origin == "http://localhost:8100" {
|
||||||
|
return Some(origin.to_string());
|
||||||
|
}
|
||||||
|
let host_ip = &self.config.host_ip;
|
||||||
|
let matches = |scheme: &str| -> bool {
|
||||||
|
let prefix = format!("{}{}", scheme, host_ip);
|
||||||
|
if origin == prefix {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let with_port = format!("{}:", prefix);
|
||||||
|
origin.starts_with(&with_port)
|
||||||
|
&& origin[with_port.len()..]
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_digit())
|
||||||
|
};
|
||||||
|
if matches("http://") || matches("https://") {
|
||||||
|
Some(origin.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_request(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
|
||||||
let path = req.uri().path().to_string();
|
let path = req.uri().path().to_string();
|
||||||
let method = req.method().clone();
|
let method = req.method().clone();
|
||||||
|
|
||||||
@@ -144,7 +203,12 @@ impl ApiHandler {
|
|||||||
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
|
tracing::warn!("401 WebSocket /ws/db — session invalid or missing");
|
||||||
return Ok(Self::unauthorized());
|
return Ok(Self::unauthorized());
|
||||||
}
|
}
|
||||||
return Self::handle_websocket(req, self.state_manager.clone(), self.metrics_store.clone()).await;
|
return Self::handle_websocket(
|
||||||
|
req,
|
||||||
|
self.state_manager.clone(),
|
||||||
|
self.metrics_store.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote input WebSocket — companion app sends keyboard/mouse events
|
// Remote input WebSocket — companion app sends keyboard/mouse events
|
||||||
@@ -167,8 +231,10 @@ impl ApiHandler {
|
|||||||
|
|
||||||
// Convert body to bytes for non-WS routes
|
// Convert body to bytes for non-WS routes
|
||||||
let headers = req.headers().clone();
|
let headers = req.headers().clone();
|
||||||
|
let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default();
|
||||||
let (parts, body) = req.into_parts();
|
let (parts, body) = req.into_parts();
|
||||||
let body_bytes = hyper::body::to_bytes(body).await
|
let body_bytes = hyper::body::to_bytes(body)
|
||||||
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?;
|
||||||
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
|
let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone()));
|
||||||
|
|
||||||
@@ -196,7 +262,9 @@ impl ApiHandler {
|
|||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(hyper::Body::from(serde_json::to_vec(&status).unwrap_or_default()))
|
.body(hyper::Body::from(
|
||||||
|
serde_json::to_vec(&status).unwrap_or_default(),
|
||||||
|
))
|
||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,15 +273,81 @@ impl ApiHandler {
|
|||||||
Self::handle_node_message(body_bytes).await
|
Self::handle_node_message(body_bytes).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mesh typed envelope relay over federation — peers POST
|
||||||
|
// pre-encoded TypedEnvelope wire bytes here when the envelope is
|
||||||
|
// too large for a single LoRa frame (primarily ContentRef). No
|
||||||
|
// session auth: the body carries a pubkey + ed25519 signature
|
||||||
|
// over the wire bytes which we verify before dispatching.
|
||||||
|
(Method::POST, "/archipelago/mesh-typed") => {
|
||||||
|
Self::handle_mesh_typed_relay(self.rpc_handler.clone(), body_bytes).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blob upload — local/session use only. Session-authenticated so
|
||||||
|
// only the node owner can push attachments into the blob store.
|
||||||
|
(Method::POST, "/api/blob") => {
|
||||||
|
if !self.is_authenticated(&headers).await {
|
||||||
|
return Ok(Self::unauthorized());
|
||||||
|
}
|
||||||
|
Self::handle_blob_upload(
|
||||||
|
&self.blob_store,
|
||||||
|
&self.self_pubkey_hex,
|
||||||
|
&self.config.data_dir,
|
||||||
|
&headers,
|
||||||
|
body_bytes,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share-to-mesh intent — marketplace app iframes POST a file here
|
||||||
|
// to stage it as a mesh attachment. Same body format as /api/blob
|
||||||
|
// (raw bytes + X-Blob-Mime/X-Blob-Filename headers). The app is
|
||||||
|
// expected to postMessage `{type:'share-to-mesh', cid, ...}` to
|
||||||
|
// its parent window afterwards so the Mesh view can pick it up.
|
||||||
|
// Authenticated by session cookie + a relaxed Origin check (any
|
||||||
|
// port on the archipelago host is allowed, so proxied apps on
|
||||||
|
// their own ports can reach it with credentials:'include').
|
||||||
|
(Method::POST, "/api/share-to-mesh") => {
|
||||||
|
if !self.is_authenticated(&headers).await {
|
||||||
|
return Ok(Self::unauthorized());
|
||||||
|
}
|
||||||
|
let origin = match self.validate_app_origin(&headers) {
|
||||||
|
Some(o) => o,
|
||||||
|
None => {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from("origin not allowed"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Self::handle_share_to_mesh(
|
||||||
|
&self.blob_store,
|
||||||
|
&self.self_pubkey_hex,
|
||||||
|
&headers,
|
||||||
|
body_bytes,
|
||||||
|
&origin,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blob download — peer-facing. No session required; authenticated
|
||||||
|
// by HMAC capability token signed when the blob ref was shared.
|
||||||
|
(Method::GET, p) if p.starts_with("/blob/") => {
|
||||||
|
Self::handle_blob_download(&self.blob_store, p, &query_string).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content preview — degraded previews for paid content (no auth, no payment)
|
||||||
|
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => {
|
||||||
|
Self::handle_content_preview(p, &self.config).await
|
||||||
|
}
|
||||||
|
|
||||||
// Content serving — peers access shared content over Tor (no session auth)
|
// Content serving — peers access shared content over Tor (no session auth)
|
||||||
(Method::GET, p) if p.starts_with("/content/") => {
|
(Method::GET, p) if p.starts_with("/content/") => {
|
||||||
Self::handle_content_request(p, &headers, &self.config).await
|
Self::handle_content_request(p, &headers, &self.config).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content catalog — list available content (no session auth, for peers)
|
// Content catalog — list available content (no session auth, for peers)
|
||||||
(Method::GET, "/content") => {
|
(Method::GET, "/content") => Self::handle_content_catalog(&self.config).await,
|
||||||
Self::handle_content_catalog(&self.config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// Electrs status — unauthenticated (read-only sync status)
|
// Electrs status — unauthenticated (read-only sync status)
|
||||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||||
@@ -245,14 +379,10 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DWN health — unauthenticated
|
// DWN health — unauthenticated
|
||||||
(Method::GET, "/dwn/health") => {
|
(Method::GET, "/dwn/health") => Self::handle_dwn_health(&self.config).await,
|
||||||
Self::handle_dwn_health(&self.config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// DWN message processing — peers access over Tor for sync (no session auth)
|
// DWN message processing — peers access over Tor for sync (no session auth)
|
||||||
(Method::POST, "/dwn") => {
|
(Method::POST, "/dwn") => Self::handle_dwn_message(body_bytes, &self.config).await,
|
||||||
Self::handle_dwn_message(body_bytes, &self.config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => Ok(Response::builder()
|
_ => Ok(Response::builder()
|
||||||
.status(StatusCode::NOT_FOUND)
|
.status(StatusCode::NOT_FOUND)
|
||||||
@@ -266,7 +396,9 @@ impl ApiHandler {
|
|||||||
fn is_valid_app_id(id: &str) -> bool {
|
fn is_valid_app_id(id: &str) -> bool {
|
||||||
!id.is_empty()
|
!id.is_empty()
|
||||||
&& id.len() <= 64
|
&& id.len() <= 64
|
||||||
&& id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
&& id
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
&& id.as_bytes()[0] != b'-'
|
&& id.as_bytes()[0] != b'-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
|
use super::build_response;
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
use crate::node_message as node_msg;
|
use crate::node_message as node_msg;
|
||||||
use super::build_response;use anyhow::Result;
|
use anyhow::Result;
|
||||||
use hyper::{Response, StatusCode};
|
use hyper::{Response, StatusCode};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::{ApiHandler, is_valid_pubkey_hex, sanitize_html, sanitize_log_string};
|
use super::{is_valid_pubkey_hex, sanitize_html, sanitize_log_string, ApiHandler};
|
||||||
|
|
||||||
impl ApiHandler {
|
impl ApiHandler {
|
||||||
pub(super) async fn handle_node_message(body: hyper::body::Bytes) -> Result<Response<hyper::Body>> {
|
pub(super) async fn handle_node_message(
|
||||||
|
body: hyper::body::Bytes,
|
||||||
|
) -> Result<Response<hyper::Body>> {
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct Incoming {
|
struct Incoming {
|
||||||
from_pubkey: Option<String>,
|
from_pubkey: Option<String>,
|
||||||
|
from_name: Option<String>,
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
signature: Option<String>,
|
signature: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -16,21 +22,31 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
||||||
from_pubkey: None,
|
from_pubkey: None,
|
||||||
|
from_name: None,
|
||||||
message: None,
|
message: None,
|
||||||
signature: None,
|
signature: None,
|
||||||
encrypted: false,
|
encrypted: false,
|
||||||
});
|
});
|
||||||
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref()) {
|
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref())
|
||||||
|
{
|
||||||
// Validate from_pubkey is a valid hex ed25519 pubkey
|
// Validate from_pubkey is a valid hex ed25519 pubkey
|
||||||
if !is_valid_pubkey_hex(from) {
|
if !is_valid_pubkey_hex(from) {
|
||||||
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#)));
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
// Verify ed25519 signature if provided (required for trusted messages)
|
// Verify ed25519 signature if provided (required for trusted messages)
|
||||||
if let Some(sig_hex) = &incoming.signature {
|
if let Some(sig_hex) = &incoming.signature {
|
||||||
match crate::identity::NodeIdentity::verify(from, msg.as_bytes(), sig_hex) {
|
match crate::identity::NodeIdentity::verify(from, msg.as_bytes(), sig_hex) {
|
||||||
Ok(true) => {}
|
Ok(true) => {}
|
||||||
_ => {
|
_ => {
|
||||||
return Ok(build_response(StatusCode::FORBIDDEN, "application/json", hyper::Body::from(r#"{"error":"Invalid signature"}"#)));
|
return Ok(build_response(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"Invalid signature"}"#),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,12 +60,23 @@ impl ApiHandler {
|
|||||||
Ok(node_id) => {
|
Ok(node_id) => {
|
||||||
match node_msg::decrypt_from_peer(node_id.signing_key(), from, msg) {
|
match node_msg::decrypt_from_peer(node_id.signing_key(), from, msg) {
|
||||||
Ok(decrypted) => {
|
Ok(decrypted) => {
|
||||||
tracing::info!("Decrypted E2E message from {}...", &from[..16.min(from.len())]);
|
tracing::info!(
|
||||||
|
"Decrypted E2E message from {}...",
|
||||||
|
&from[..16.min(from.len())]
|
||||||
|
);
|
||||||
decrypted
|
decrypted
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("E2E decryption failed from {}: {}", &from[..16.min(from.len())], e);
|
tracing::warn!(
|
||||||
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(r#"{"error":"Decryption failed"}"#)));
|
"E2E decryption failed from {}: {}",
|
||||||
|
&from[..16.min(from.len())],
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"Decryption failed"}"#),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,13 +89,152 @@ impl ApiHandler {
|
|||||||
msg.clone()
|
msg.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Detect a `connection_accepted` reply: the remote peer just
|
||||||
|
// approved an outbound request we sent, so mirror their add on
|
||||||
|
// our side (bidirectional peering without a manual second
|
||||||
|
// click). JSON-shape only — any non-matching payload stays in
|
||||||
|
// the normal received-messages store below.
|
||||||
|
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&plaintext) {
|
||||||
|
if val.get("type").and_then(|v| v.as_str()) == Some("connection_accepted") {
|
||||||
|
if let (Some(their_onion), Some(their_pubkey)) = (
|
||||||
|
val.get("from_onion").and_then(|v| v.as_str()),
|
||||||
|
val.get("from_pubkey").and_then(|v| v.as_str()),
|
||||||
|
) {
|
||||||
|
let data_dir = std::path::Path::new("/var/lib/archipelago");
|
||||||
|
let peer = crate::peers::KnownPeer {
|
||||||
|
onion: their_onion.to_string(),
|
||||||
|
pubkey: their_pubkey.to_string(),
|
||||||
|
name: val
|
||||||
|
.get("from_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
added_at: Some(chrono::Utc::now().to_rfc3339()),
|
||||||
|
};
|
||||||
|
match crate::peers::add_peer(data_dir, peer).await {
|
||||||
|
Ok(_) => tracing::info!(
|
||||||
|
from = %sanitize_log_string(from),
|
||||||
|
"Auto-added peer after connection_accepted"
|
||||||
|
),
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
from = %sanitize_log_string(from),
|
||||||
|
error = %e,
|
||||||
|
"Failed to auto-add peer on connection_accepted"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"ok":true,"handled":"connection_accepted"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let safe_from = sanitize_log_string(from);
|
let safe_from = sanitize_log_string(from);
|
||||||
let safe_msg = sanitize_log_string(&plaintext);
|
let safe_msg = sanitize_log_string(&plaintext);
|
||||||
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
|
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
|
||||||
let clean_from = sanitize_html(from);
|
let clean_from = sanitize_html(from);
|
||||||
let clean_msg = sanitize_html(&plaintext);
|
let clean_msg = sanitize_html(&plaintext);
|
||||||
node_msg::store_received(&clean_from, &clean_msg).await;
|
let clean_name = incoming.from_name.as_deref().map(sanitize_html);
|
||||||
|
node_msg::store_received(&clean_from, &clean_msg, clean_name.as_deref()).await;
|
||||||
}
|
}
|
||||||
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(r#"{"ok":true}"#)))
|
Ok(build_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"ok":true}"#),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Federation-routed mesh typed envelope. Body:
|
||||||
|
/// `{from_pubkey, from_name?, typed_envelope_b64, signature}`
|
||||||
|
/// Signature is ed25519 over the raw wire bytes, verified against
|
||||||
|
/// from_pubkey before dispatch.
|
||||||
|
pub(super) async fn handle_mesh_typed_relay(
|
||||||
|
rpc_handler: Arc<RpcHandler>,
|
||||||
|
body: hyper::body::Bytes,
|
||||||
|
) -> Result<Response<hyper::Body>> {
|
||||||
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Incoming {
|
||||||
|
from_pubkey: String,
|
||||||
|
#[serde(default)]
|
||||||
|
from_name: Option<String>,
|
||||||
|
typed_envelope_b64: String,
|
||||||
|
signature: String,
|
||||||
|
}
|
||||||
|
let incoming: Incoming = match serde_json::from_slice(&body) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(format!(r#"{{"error":"bad json: {}"}}"#, e)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !is_valid_pubkey_hex(&incoming.from_pubkey) {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"invalid pubkey"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let wire = match BASE64.decode(incoming.typed_envelope_b64.as_bytes()) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"bad base64"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match crate::identity::NodeIdentity::verify(
|
||||||
|
&incoming.from_pubkey,
|
||||||
|
&wire,
|
||||||
|
&incoming.signature,
|
||||||
|
) {
|
||||||
|
Ok(true) => {}
|
||||||
|
_ => {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"signature rejected"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject into mesh state via the shared MeshService. Mirrors a radio
|
||||||
|
// receive, so the message lands in the same chat stream as LoRa-
|
||||||
|
// delivered messages from the same peer.
|
||||||
|
let service = rpc_handler.mesh_service_arc();
|
||||||
|
let svc_guard = service.read().await;
|
||||||
|
let Some(svc) = svc_guard.as_ref() else {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"mesh not running"}"#),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if let Err(e) = svc
|
||||||
|
.inject_typed_from_federation(
|
||||||
|
&incoming.from_pubkey,
|
||||||
|
incoming.from_name.as_deref(),
|
||||||
|
wire,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("mesh-typed relay inject failed: {}", e);
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(format!(r#"{{"error":"{}"}}"#, e)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(build_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"ok":true}"#),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
use super::build_response;
|
||||||
use crate::api::rpc::RpcHandler;
|
use crate::api::rpc::RpcHandler;
|
||||||
use super::build_response;use crate::electrs_status;
|
use crate::electrs_status;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use hyper::{Response, StatusCode};
|
use hyper::{Response, StatusCode};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::{ApiHandler, is_valid_app_id};
|
use super::{is_valid_app_id, ApiHandler};
|
||||||
|
|
||||||
impl ApiHandler {
|
impl ApiHandler {
|
||||||
pub(super) async fn handle_container_logs_http(
|
pub(super) async fn handle_container_logs_http(
|
||||||
@@ -16,16 +17,15 @@ impl ApiHandler {
|
|||||||
.strip_prefix("/api/container/logs")
|
.strip_prefix("/api/container/logs")
|
||||||
.and_then(|s| s.strip_prefix('?'))
|
.and_then(|s| s.strip_prefix('?'))
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
let params: std::collections::HashMap<String, String> =
|
let params: std::collections::HashMap<String, String> = query
|
||||||
query
|
.split('&')
|
||||||
.split('&')
|
.filter_map(|p| {
|
||||||
.filter_map(|p| {
|
let mut it = p.splitn(2, '=');
|
||||||
let mut it = p.splitn(2, '=');
|
let k = it.next()?.to_string();
|
||||||
let k = it.next()?.to_string();
|
let v = it.next()?.to_string();
|
||||||
let v = it.next()?.to_string();
|
Some((k, v))
|
||||||
Some((k, v))
|
})
|
||||||
})
|
.collect();
|
||||||
.collect();
|
|
||||||
|
|
||||||
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
|
let app_id = params.get("app_id").map(|s| s.as_str()).unwrap_or("lnd");
|
||||||
|
|
||||||
@@ -33,7 +33,11 @@ impl ApiHandler {
|
|||||||
if !is_valid_app_id(app_id) {
|
if !is_valid_app_id(app_id) {
|
||||||
let body = serde_json::json!({ "error": "Invalid app_id" });
|
let body = serde_json::json!({ "error": "Invalid app_id" });
|
||||||
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
|
||||||
return Ok(build_response(StatusCode::BAD_REQUEST, "application/json", hyper::Body::from(body_bytes)));
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(body_bytes),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let lines = params
|
let lines = params
|
||||||
@@ -72,7 +76,11 @@ impl ApiHandler {
|
|||||||
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
|
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
|
||||||
let status = electrs_status::get_electrs_sync_status().await;
|
let status = electrs_status::get_electrs_sync_status().await;
|
||||||
let body = serde_json::to_vec(&status).unwrap_or_default();
|
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||||
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
|
Ok(build_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(body),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_lnd_connect_info(
|
pub(super) async fn handle_lnd_connect_info(
|
||||||
@@ -81,7 +89,11 @@ impl ApiHandler {
|
|||||||
match rpc.handle_lnd_connect_info().await {
|
match rpc.handle_lnd_connect_info().await {
|
||||||
Ok(val) => {
|
Ok(val) => {
|
||||||
let body = serde_json::to_vec(&val).unwrap_or_default();
|
let body = serde_json::to_vec(&val).unwrap_or_default();
|
||||||
Ok(build_response(StatusCode::OK, "application/json", hyper::Body::from(body)))
|
Ok(build_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(body),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
Err(e) => Ok(Response::builder()
|
Err(e) => Ok(Response::builder()
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
@@ -93,7 +105,10 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_lnd_proxy(path: &str, cors_origin: &str) -> Result<Response<hyper::Body>> {
|
pub(super) async fn handle_lnd_proxy(
|
||||||
|
path: &str,
|
||||||
|
cors_origin: &str,
|
||||||
|
) -> Result<Response<hyper::Body>> {
|
||||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||||
let url = format!("http://127.0.0.1:8080{}", suffix);
|
let url = format!("http://127.0.0.1:8080{}", suffix);
|
||||||
match reqwest::get(&url).await {
|
match reqwest::get(&url).await {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ use hyper::{Request, Response};
|
|||||||
use hyper_ws_listener::WsStream;
|
use hyper_ws_listener::WsStream;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::process::Command;
|
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
@@ -14,27 +13,131 @@ use super::ApiHandler;
|
|||||||
/// Allowed xdotool key names. Only these pass validation.
|
/// Allowed xdotool key names. Only these pass validation.
|
||||||
const ALLOWED_KEYS: &[&str] = &[
|
const ALLOWED_KEYS: &[&str] = &[
|
||||||
// Letters
|
// Letters
|
||||||
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
|
"a",
|
||||||
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
|
"b",
|
||||||
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
|
"c",
|
||||||
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
|
"d",
|
||||||
|
"e",
|
||||||
|
"f",
|
||||||
|
"g",
|
||||||
|
"h",
|
||||||
|
"i",
|
||||||
|
"j",
|
||||||
|
"k",
|
||||||
|
"l",
|
||||||
|
"m",
|
||||||
|
"n",
|
||||||
|
"o",
|
||||||
|
"p",
|
||||||
|
"q",
|
||||||
|
"r",
|
||||||
|
"s",
|
||||||
|
"t",
|
||||||
|
"u",
|
||||||
|
"v",
|
||||||
|
"w",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"A",
|
||||||
|
"B",
|
||||||
|
"C",
|
||||||
|
"D",
|
||||||
|
"E",
|
||||||
|
"F",
|
||||||
|
"G",
|
||||||
|
"H",
|
||||||
|
"I",
|
||||||
|
"J",
|
||||||
|
"K",
|
||||||
|
"L",
|
||||||
|
"M",
|
||||||
|
"N",
|
||||||
|
"O",
|
||||||
|
"P",
|
||||||
|
"Q",
|
||||||
|
"R",
|
||||||
|
"S",
|
||||||
|
"T",
|
||||||
|
"U",
|
||||||
|
"V",
|
||||||
|
"W",
|
||||||
|
"X",
|
||||||
|
"Y",
|
||||||
|
"Z",
|
||||||
// Numbers
|
// Numbers
|
||||||
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
"0",
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"5",
|
||||||
|
"6",
|
||||||
|
"7",
|
||||||
|
"8",
|
||||||
|
"9",
|
||||||
// Navigation
|
// Navigation
|
||||||
"Up", "Down", "Left", "Right",
|
"Up",
|
||||||
"Return", "Escape", "Tab", "BackSpace", "Delete",
|
"Down",
|
||||||
"Home", "End", "Prior", "Next", // Prior=PageUp, Next=PageDown
|
"Left",
|
||||||
|
"Right",
|
||||||
|
"Return",
|
||||||
|
"Escape",
|
||||||
|
"Tab",
|
||||||
|
"BackSpace",
|
||||||
|
"Delete",
|
||||||
|
"Home",
|
||||||
|
"End",
|
||||||
|
"Prior",
|
||||||
|
"Next", // Prior=PageUp, Next=PageDown
|
||||||
// Modifiers (for combos like shift+a)
|
// Modifiers (for combos like shift+a)
|
||||||
"space", "minus", "equal", "bracketleft", "bracketright",
|
"space",
|
||||||
"backslash", "semicolon", "apostrophe", "grave", "comma",
|
"minus",
|
||||||
"period", "slash",
|
"equal",
|
||||||
|
"bracketleft",
|
||||||
|
"bracketright",
|
||||||
|
"backslash",
|
||||||
|
"semicolon",
|
||||||
|
"apostrophe",
|
||||||
|
"grave",
|
||||||
|
"comma",
|
||||||
|
"period",
|
||||||
|
"slash",
|
||||||
// Function keys
|
// Function keys
|
||||||
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12",
|
"F1",
|
||||||
|
"F2",
|
||||||
|
"F3",
|
||||||
|
"F4",
|
||||||
|
"F5",
|
||||||
|
"F6",
|
||||||
|
"F7",
|
||||||
|
"F8",
|
||||||
|
"F9",
|
||||||
|
"F10",
|
||||||
|
"F11",
|
||||||
|
"F12",
|
||||||
// Symbols — xdotool names
|
// Symbols — xdotool names
|
||||||
"exclam", "at", "numbersign", "dollar", "percent", "asciicircum",
|
"exclam",
|
||||||
"ampersand", "asterisk", "parenleft", "parenright", "underscore",
|
"at",
|
||||||
"plus", "braceleft", "braceright", "bar", "colon", "quotedbl",
|
"numbersign",
|
||||||
"less", "greater", "question", "asciitilde",
|
"dollar",
|
||||||
|
"percent",
|
||||||
|
"asciicircum",
|
||||||
|
"ampersand",
|
||||||
|
"asterisk",
|
||||||
|
"parenleft",
|
||||||
|
"parenright",
|
||||||
|
"underscore",
|
||||||
|
"plus",
|
||||||
|
"braceleft",
|
||||||
|
"braceright",
|
||||||
|
"bar",
|
||||||
|
"colon",
|
||||||
|
"quotedbl",
|
||||||
|
"less",
|
||||||
|
"greater",
|
||||||
|
"question",
|
||||||
|
"asciitilde",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Validate a key name against the whitelist.
|
/// Validate a key name against the whitelist.
|
||||||
@@ -55,7 +158,14 @@ fn validate_key(key: &str) -> bool {
|
|||||||
#[serde(tag = "t")]
|
#[serde(tag = "t")]
|
||||||
enum InputCommand {
|
enum InputCommand {
|
||||||
#[serde(rename = "k")]
|
#[serde(rename = "k")]
|
||||||
Key { k: String },
|
Key {
|
||||||
|
k: String,
|
||||||
|
/// Optional player ID (1 or 2) for multi-player arcade games.
|
||||||
|
/// When absent, input is broadcast without player tagging.
|
||||||
|
#[serde(default)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
p: Option<u8>,
|
||||||
|
},
|
||||||
#[serde(rename = "m")]
|
#[serde(rename = "m")]
|
||||||
MouseMove { x: i32, y: i32 },
|
MouseMove { x: i32, y: i32 },
|
||||||
#[serde(rename = "c")]
|
#[serde(rename = "c")]
|
||||||
@@ -66,50 +176,28 @@ enum InputCommand {
|
|||||||
Ping,
|
Ping,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn xdotool(args: &[&str]) -> Result<()> {
|
/// Validate and acknowledge input — relay-only, no xdotool.
|
||||||
let output = Command::new("xdotool")
|
/// All input is forwarded to browser clients via the broadcast channel;
|
||||||
.env("DISPLAY", ":0")
|
/// the browser's remote-relay.ts dispatches DOM events from there.
|
||||||
.args(args)
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.context("xdotool execution failed")?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
debug!("xdotool error: {}", stderr);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_input(msg: &str) -> Result<Option<String>> {
|
async fn handle_input(msg: &str) -> Result<Option<String>> {
|
||||||
let cmd: InputCommand = serde_json::from_str(msg)
|
let cmd: InputCommand = serde_json::from_str(msg).context("invalid input command")?;
|
||||||
.context("invalid input command")?;
|
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
InputCommand::Key { ref k } => {
|
InputCommand::Key { ref k, .. } => {
|
||||||
if !validate_key(k) {
|
if !validate_key(k) {
|
||||||
warn!("rejected key: {}", k);
|
warn!("rejected key: {}", k);
|
||||||
return Ok(Some(r#"{"t":"e","m":"invalid key"}"#.to_string()));
|
return Ok(Some(r#"{"t":"e","m":"invalid key"}"#.to_string()));
|
||||||
}
|
}
|
||||||
xdotool(&["key", "--clearmodifiers", k]).await?;
|
|
||||||
}
|
}
|
||||||
InputCommand::MouseMove { x, y } => {
|
InputCommand::MouseMove { x, y } => {
|
||||||
let x = x.clamp(-50, 50);
|
let _x = x.clamp(-50, 50);
|
||||||
let y = y.clamp(-50, 50);
|
let _y = y.clamp(-50, 50);
|
||||||
let xs = x.to_string();
|
|
||||||
let ys = y.to_string();
|
|
||||||
xdotool(&["mousemove_relative", "--", &xs, &ys]).await?;
|
|
||||||
}
|
}
|
||||||
InputCommand::Click { b } => {
|
InputCommand::Click { b } => {
|
||||||
let b = b.clamp(1, 3);
|
let _b = b.clamp(1, 3);
|
||||||
let bs = b.to_string();
|
|
||||||
xdotool(&["click", &bs]).await?;
|
|
||||||
}
|
}
|
||||||
InputCommand::Scroll { y } => {
|
InputCommand::Scroll { y } => {
|
||||||
// xdotool: button 4 = scroll up, button 5 = scroll down
|
let _y = y.clamp(-10, 10);
|
||||||
let btn = if y < 0 { "4" } else { "5" };
|
|
||||||
let count = y.unsigned_abs().clamp(1, 10).to_string();
|
|
||||||
xdotool(&["click", "--repeat", &count, btn]).await?;
|
|
||||||
}
|
}
|
||||||
InputCommand::Ping => {
|
InputCommand::Ping => {
|
||||||
return Ok(Some(r#"{"t":"p"}"#.to_string()));
|
return Ok(Some(r#"{"t":"p"}"#.to_string()));
|
||||||
@@ -124,6 +212,15 @@ impl ApiHandler {
|
|||||||
req: Request<hyper::Body>,
|
req: Request<hyper::Body>,
|
||||||
relay_tx: broadcast::Sender<String>,
|
relay_tx: broadcast::Sender<String>,
|
||||||
) -> Result<Response<hyper::Body>> {
|
) -> Result<Response<hyper::Body>> {
|
||||||
|
// Extract optional player ID from query string: /ws/remote-input?p=1
|
||||||
|
let player_id: Option<u8> = req
|
||||||
|
.uri()
|
||||||
|
.query()
|
||||||
|
.and_then(|q| q.split('&').find(|s| s.starts_with("p=")))
|
||||||
|
.and_then(|s| s.get(2..))
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.filter(|&p: &u8| p == 1 || p == 2);
|
||||||
|
|
||||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||||
|
|
||||||
@@ -185,8 +282,28 @@ impl ApiHandler {
|
|||||||
continue; // silently drop
|
continue; // silently drop
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relay to connected browsers (best-effort, ignore if no receivers)
|
// Relay to browser clients. If this connection has a
|
||||||
let _ = relay_tx.send(text.clone());
|
// player ID from query string and the message is a key
|
||||||
|
// event without a player field, inject it so the browser
|
||||||
|
// can route input to the correct player.
|
||||||
|
let relay_text = if let Some(pid) = player_id {
|
||||||
|
if text.contains(r#""t":"k""#) && !text.contains(r#""p":"#) {
|
||||||
|
// Insert "p":N before the closing brace
|
||||||
|
if let Some(pos) = text.rfind('}') {
|
||||||
|
let mut tagged = text[..pos].to_string();
|
||||||
|
tagged.push_str(&format!(r#","p":{}"#, pid));
|
||||||
|
tagged.push('}');
|
||||||
|
tagged
|
||||||
|
} else {
|
||||||
|
text.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text.clone()
|
||||||
|
};
|
||||||
|
let _ = relay_tx.send(relay_text);
|
||||||
|
|
||||||
match handle_input(&text).await {
|
match handle_input(&text).await {
|
||||||
Ok(Some(reply)) => {
|
Ok(Some(reply)) => {
|
||||||
@@ -219,11 +336,13 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Remote input disconnected ({} messages processed)", msg_count);
|
info!(
|
||||||
|
"Remote input disconnected ({} messages processed)",
|
||||||
|
msg_count
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ impl RpcHandler {
|
|||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
|
||||||
let app_count = data.package_data.len();
|
let app_count = data.package_data.len();
|
||||||
let running_count = data.package_data.values()
|
let running_count = data
|
||||||
|
.package_data
|
||||||
|
.values()
|
||||||
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
|
.filter(|p| matches!(p.state, crate::data_model::PackageState::Running))
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
@@ -88,7 +90,8 @@ impl RpcHandler {
|
|||||||
.args(["MemTotal", "/proc/meminfo"])
|
.args(["MemTotal", "/proc/meminfo"])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
let total_ram_mb = mem_output.ok()
|
let total_ram_mb = mem_output
|
||||||
|
.ok()
|
||||||
.and_then(|o| {
|
.and_then(|o| {
|
||||||
let s = String::from_utf8_lossy(&o.stdout);
|
let s = String::from_utf8_lossy(&o.stdout);
|
||||||
s.split_whitespace().nth(1)?.parse::<u64>().ok()
|
s.split_whitespace().nth(1)?.parse::<u64>().ok()
|
||||||
@@ -139,46 +142,66 @@ impl RpcHandler {
|
|||||||
|
|
||||||
// Anonymous node ID — SHA-256 hash of the DID (not the DID itself)
|
// Anonymous node ID — SHA-256 hash of the DID (not the DID itself)
|
||||||
let node_id = {
|
let node_id = {
|
||||||
use sha2::{Sha256, Digest};
|
use sha2::{Digest, Sha256};
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(data.server_info.pubkey.as_bytes());
|
hasher.update(data.server_info.pubkey.as_bytes());
|
||||||
hex::encode(hasher.finalize())[..16].to_string()
|
hex::encode(hasher.finalize())[..16].to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Container states
|
// Container states
|
||||||
let containers: Vec<serde_json::Value> = data.package_data.iter().map(|(id, pkg)| {
|
let containers: Vec<serde_json::Value> = data
|
||||||
serde_json::json!({
|
.package_data
|
||||||
"id": id,
|
.iter()
|
||||||
"state": format!("{:?}", pkg.state),
|
.map(|(id, pkg)| {
|
||||||
"version": pkg.manifest.version,
|
serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"state": format!("{:?}", pkg.state),
|
||||||
|
"version": pkg.manifest.version,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
// System stats
|
// System stats
|
||||||
let cpu_cores = std::thread::available_parallelism()
|
let cpu_cores = std::thread::available_parallelism()
|
||||||
.map(|n| n.get()).unwrap_or(0);
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(0);
|
||||||
let mem_output = tokio::process::Command::new("grep")
|
let mem_output = tokio::process::Command::new("grep")
|
||||||
.args(["MemTotal", "/proc/meminfo"])
|
.args(["MemTotal", "/proc/meminfo"])
|
||||||
.output().await;
|
.output()
|
||||||
let total_ram_mb = mem_output.ok()
|
.await;
|
||||||
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().nth(1)?.parse::<u64>().ok())
|
let total_ram_mb = mem_output
|
||||||
.map(|kb| kb / 1024).unwrap_or(0);
|
.ok()
|
||||||
|
.and_then(|o| {
|
||||||
|
String::from_utf8_lossy(&o.stdout)
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)?
|
||||||
|
.parse::<u64>()
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.map(|kb| kb / 1024)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
// Uptime
|
// Uptime
|
||||||
let uptime_secs = tokio::fs::read_to_string("/proc/uptime").await
|
let uptime_secs = tokio::fs::read_to_string("/proc/uptime")
|
||||||
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
|
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
|
||||||
.map(|f| f as u64)
|
.map(|f| f as u64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
// Recent alerts from metrics store
|
// Recent alerts from metrics store
|
||||||
let recent_alerts: Vec<serde_json::Value> = self.metrics_store.get_fired_alerts(10).await
|
let recent_alerts: Vec<serde_json::Value> = self
|
||||||
|
.metrics_store
|
||||||
|
.get_fired_alerts(10)
|
||||||
|
.await
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|a| serde_json::json!({
|
.map(|a| {
|
||||||
"rule": format!("{:?}", a.kind),
|
serde_json::json!({
|
||||||
"message": a.message,
|
"rule": format!("{:?}", a.kind),
|
||||||
"timestamp": a.timestamp,
|
"message": a.message,
|
||||||
}))
|
"timestamp": a.timestamp,
|
||||||
|
})
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let report = serde_json::json!({
|
let report = serde_json::json!({
|
||||||
@@ -208,11 +231,15 @@ impl RpcHandler {
|
|||||||
/// Receive a telemetry report from a fleet node.
|
/// Receive a telemetry report from a fleet node.
|
||||||
/// Stores it in telemetry-fleet/ directory, indexed by node_id.
|
/// Stores it in telemetry-fleet/ directory, indexed by node_id.
|
||||||
/// Does NOT require auth — called by remote nodes posting reports.
|
/// Does NOT require auth — called by remote nodes posting reports.
|
||||||
pub(super) async fn handle_telemetry_ingest(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
pub(super) async fn handle_telemetry_ingest(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let report = params.context("Missing telemetry report payload")?;
|
let report = params.context("Missing telemetry report payload")?;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
let node_id = report.get("node_id")
|
let node_id = report
|
||||||
|
.get("node_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.context("Missing required field: node_id")?;
|
.context("Missing required field: node_id")?;
|
||||||
if node_id.is_empty() || node_id.len() > 64 {
|
if node_id.is_empty() || node_id.len() > 64 {
|
||||||
@@ -222,39 +249,45 @@ impl RpcHandler {
|
|||||||
if node_id.contains('/') || node_id.contains('\\') || node_id.contains("..") {
|
if node_id.contains('/') || node_id.contains('\\') || node_id.contains("..") {
|
||||||
anyhow::bail!("Invalid node_id: contains disallowed characters");
|
anyhow::bail!("Invalid node_id: contains disallowed characters");
|
||||||
}
|
}
|
||||||
let _version = report.get("version")
|
let _version = report
|
||||||
|
.get("version")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.context("Missing required field: version")?;
|
.context("Missing required field: version")?;
|
||||||
let _reported_at = report.get("reported_at")
|
let _reported_at = report
|
||||||
|
.get("reported_at")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.context("Missing required field: reported_at")?;
|
.context("Missing required field: reported_at")?;
|
||||||
|
|
||||||
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||||
tokio::fs::create_dir_all(&fleet_dir).await
|
tokio::fs::create_dir_all(&fleet_dir)
|
||||||
|
.await
|
||||||
.context("Failed to create telemetry-fleet directory")?;
|
.context("Failed to create telemetry-fleet directory")?;
|
||||||
|
|
||||||
// Write latest report (overwrites previous)
|
// Write latest report (overwrites previous)
|
||||||
let latest_path = fleet_dir.join(format!("{}.json", node_id));
|
let latest_path = fleet_dir.join(format!("{}.json", node_id));
|
||||||
let report_json = serde_json::to_string_pretty(&report)
|
let report_json =
|
||||||
.context("Failed to serialize report")?;
|
serde_json::to_string_pretty(&report).context("Failed to serialize report")?;
|
||||||
tokio::fs::write(&latest_path, &report_json).await
|
tokio::fs::write(&latest_path, &report_json)
|
||||||
|
.await
|
||||||
.context("Failed to write latest fleet report")?;
|
.context("Failed to write latest fleet report")?;
|
||||||
|
|
||||||
// Append to history file (cap at 200 entries)
|
// Append to history file (cap at 200 entries)
|
||||||
let history_path = fleet_dir.join(format!("{}-history.json", node_id));
|
let history_path = fleet_dir.join(format!("{}-history.json", node_id));
|
||||||
let mut history: Vec<serde_json::Value> = match tokio::fs::read_to_string(&history_path).await {
|
let mut history: Vec<serde_json::Value> =
|
||||||
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
match tokio::fs::read_to_string(&history_path).await {
|
||||||
Err(_) => Vec::new(),
|
Ok(data) => serde_json::from_str(&data).unwrap_or_default(),
|
||||||
};
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
history.push(report.clone());
|
history.push(report.clone());
|
||||||
// Keep only the last 200 entries
|
// Keep only the last 200 entries
|
||||||
if history.len() > 200 {
|
if history.len() > 200 {
|
||||||
let start = history.len() - 200;
|
let start = history.len() - 200;
|
||||||
history = history.split_off(start);
|
history = history.split_off(start);
|
||||||
}
|
}
|
||||||
let history_json = serde_json::to_string_pretty(&history)
|
let history_json =
|
||||||
.context("Failed to serialize history")?;
|
serde_json::to_string_pretty(&history).context("Failed to serialize history")?;
|
||||||
tokio::fs::write(&history_path, &history_json).await
|
tokio::fs::write(&history_path, &history_json)
|
||||||
|
.await
|
||||||
.context("Failed to write fleet history")?;
|
.context("Failed to write fleet history")?;
|
||||||
|
|
||||||
debug!(node_id = %node_id, "Ingested fleet telemetry report");
|
debug!(node_id = %node_id, "Ingested fleet telemetry report");
|
||||||
@@ -274,7 +307,8 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut nodes: Vec<serde_json::Value> = Vec::new();
|
let mut nodes: Vec<serde_json::Value> = Vec::new();
|
||||||
let mut entries = tokio::fs::read_dir(&fleet_dir).await
|
let mut entries = tokio::fs::read_dir(&fleet_dir)
|
||||||
|
.await
|
||||||
.context("Failed to read telemetry-fleet directory")?;
|
.context("Failed to read telemetry-fleet directory")?;
|
||||||
|
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
@@ -290,7 +324,8 @@ impl RpcHandler {
|
|||||||
match serde_json::from_str::<serde_json::Value>(&data) {
|
match serde_json::from_str::<serde_json::Value>(&data) {
|
||||||
Ok(mut report) => {
|
Ok(mut report) => {
|
||||||
// Compute online/offline status from reported_at
|
// Compute online/offline status from reported_at
|
||||||
let is_online = report.get("reported_at")
|
let is_online = report
|
||||||
|
.get("reported_at")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||||
.map(|dt| {
|
.map(|dt| {
|
||||||
@@ -300,7 +335,8 @@ impl RpcHandler {
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Compute human-readable last_seen
|
// Compute human-readable last_seen
|
||||||
let last_seen = report.get("reported_at")
|
let last_seen = report
|
||||||
|
.get("reported_at")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||||
.map(|dt| {
|
.map(|dt| {
|
||||||
@@ -349,20 +385,29 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Get history for a specific fleet node.
|
/// Get history for a specific fleet node.
|
||||||
/// Reads telemetry-fleet/{node_id}-history.json.
|
/// Reads telemetry-fleet/{node_id}-history.json.
|
||||||
pub(super) async fn handle_telemetry_fleet_node_history(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
pub(super) async fn handle_telemetry_fleet_node_history(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let p = params.context("Missing params")?;
|
let p = params.context("Missing params")?;
|
||||||
let node_id = p.get("node_id")
|
let node_id = p
|
||||||
|
.get("node_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.context("Missing required field: node_id")?;
|
.context("Missing required field: node_id")?;
|
||||||
|
|
||||||
// Sanitize node_id
|
// Sanitize node_id
|
||||||
if node_id.is_empty() || node_id.len() > 64
|
if node_id.is_empty()
|
||||||
|| node_id.contains('/') || node_id.contains('\\') || node_id.contains("..")
|
|| node_id.len() > 64
|
||||||
|
|| node_id.contains('/')
|
||||||
|
|| node_id.contains('\\')
|
||||||
|
|| node_id.contains("..")
|
||||||
{
|
{
|
||||||
anyhow::bail!("Invalid node_id");
|
anyhow::bail!("Invalid node_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
let history_path = self.config.data_dir
|
let history_path = self
|
||||||
|
.config
|
||||||
|
.data_dir
|
||||||
.join("telemetry-fleet")
|
.join("telemetry-fleet")
|
||||||
.join(format!("{}-history.json", node_id));
|
.join(format!("{}-history.json", node_id));
|
||||||
|
|
||||||
@@ -387,7 +432,8 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut all_alerts: Vec<serde_json::Value> = Vec::new();
|
let mut all_alerts: Vec<serde_json::Value> = Vec::new();
|
||||||
let mut entries = tokio::fs::read_dir(&fleet_dir).await
|
let mut entries = tokio::fs::read_dir(&fleet_dir)
|
||||||
|
.await
|
||||||
.context("Failed to read telemetry-fleet directory")?;
|
.context("Failed to read telemetry-fleet directory")?;
|
||||||
|
|
||||||
while let Some(entry) = entries.next_entry().await? {
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
@@ -407,7 +453,8 @@ impl RpcHandler {
|
|||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
let node_id = report.get("node_id")
|
let node_id = report
|
||||||
|
.get("node_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|||||||
@@ -32,6 +32,26 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("[onboarding] login successful");
|
tracing::info!("[onboarding] login successful");
|
||||||
|
|
||||||
|
// Ensure NostrVPN config exists — covers the case where onboardingComplete
|
||||||
|
// was never called (e.g., user took the "already set up" shortcut).
|
||||||
|
let data_dir = self.config.data_dir.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Quick check: if config.toml already exists, skip
|
||||||
|
let config_path = data_dir.join("nostr-vpn/.config/nvpn/config.toml");
|
||||||
|
if config_path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Identity must exist for VPN config
|
||||||
|
if !data_dir.join("identity/nostr_pubkey").exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match crate::vpn::configure_nostr_vpn(&data_dir).await {
|
||||||
|
Ok(()) => tracing::info!("[login] NostrVPN auto-configured on first login"),
|
||||||
|
Err(e) => tracing::debug!("[login] NostrVPN auto-config skipped: {}", e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(serde_json::Value::Null)
|
Ok(serde_json::Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +104,9 @@ impl RpcHandler {
|
|||||||
let is_setup = self.auth_manager.is_setup().await?;
|
let is_setup = self.auth_manager.is_setup().await?;
|
||||||
if is_setup {
|
if is_setup {
|
||||||
tracing::warn!("[onboarding] setup rejected — already set up");
|
tracing::warn!("[onboarding] setup rejected — already set up");
|
||||||
return Err(anyhow::anyhow!("Already set up. Use auth.changePassword to change."));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Already set up. Use auth.changePassword to change."
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
@@ -106,6 +128,16 @@ impl RpcHandler {
|
|||||||
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_auth_onboarding_complete(&self) -> Result<serde_json::Value> {
|
||||||
self.auth_manager.complete_onboarding().await?;
|
self.auth_manager.complete_onboarding().await?;
|
||||||
tracing::info!("[onboarding] onboarding marked complete");
|
tracing::info!("[onboarding] onboarding marked complete");
|
||||||
|
|
||||||
|
// Auto-configure NostrVPN with the node's Nostr identity
|
||||||
|
let data_dir = self.config.data_dir.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match crate::vpn::configure_nostr_vpn(&data_dir).await {
|
||||||
|
Ok(()) => tracing::info!("[onboarding] NostrVPN configured and started"),
|
||||||
|
Err(e) => tracing::warn!("[onboarding] NostrVPN setup (non-fatal): {}", e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(serde_json::json!(true))
|
Ok(serde_json::json!(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ fn validate_s3_endpoint(endpoint: &str) -> Result<()> {
|
|||||||
// Strip port if present (handle IPv6 bracket notation)
|
// Strip port if present (handle IPv6 bracket notation)
|
||||||
let host = if host_port.starts_with('[') {
|
let host = if host_port.starts_with('[') {
|
||||||
// IPv6: [::1]:443
|
// IPv6: [::1]:443
|
||||||
host_port.split(']').next().unwrap_or("").trim_start_matches('[')
|
host_port
|
||||||
|
.split(']')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_start_matches('[')
|
||||||
} else {
|
} else {
|
||||||
host_port.split(':').next().unwrap_or("")
|
host_port.split(':').next().unwrap_or("")
|
||||||
};
|
};
|
||||||
@@ -40,12 +44,12 @@ fn validate_s3_endpoint(endpoint: &str) -> Result<()> {
|
|||||||
|| (v4.octets()[0] == 172 && (v4.octets()[1] & 0xf0) == 16) // 172.16.0.0/12
|
|| (v4.octets()[0] == 172 && (v4.octets()[1] & 0xf0) == 16) // 172.16.0.0/12
|
||||||
|| (v4.octets()[0] == 192 && v4.octets()[1] == 168) // 192.168.0.0/16
|
|| (v4.octets()[0] == 192 && v4.octets()[1] == 168) // 192.168.0.0/16
|
||||||
|| (v4.octets()[0] == 169 && v4.octets()[1] == 254) // 169.254.0.0/16
|
|| (v4.octets()[0] == 169 && v4.octets()[1] == 254) // 169.254.0.0/16
|
||||||
|| v4.is_unspecified() // 0.0.0.0
|
|| v4.is_unspecified() // 0.0.0.0
|
||||||
}
|
}
|
||||||
IpAddr::V6(v6) => {
|
IpAddr::V6(v6) => {
|
||||||
v6.is_loopback() // ::1
|
v6.is_loopback() // ::1
|
||||||
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7
|
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7
|
||||||
|| v6.is_unspecified() // ::
|
|| v6.is_unspecified() // ::
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if is_private {
|
if is_private {
|
||||||
@@ -109,7 +113,13 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||||
|
|
||||||
// Validate backup ID to prevent path traversal
|
// Validate backup ID to prevent path traversal
|
||||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
if id.is_empty()
|
||||||
|
|| id.len() > 128
|
||||||
|
|| id.contains('/')
|
||||||
|
|| id.contains('\\')
|
||||||
|
|| id.contains("..")
|
||||||
|
|| id.contains('\0')
|
||||||
|
{
|
||||||
anyhow::bail!("Invalid backup ID");
|
anyhow::bail!("Invalid backup ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +147,13 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||||
|
|
||||||
// Validate backup ID to prevent path traversal
|
// Validate backup ID to prevent path traversal
|
||||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
if id.is_empty()
|
||||||
|
|| id.len() > 128
|
||||||
|
|| id.contains('/')
|
||||||
|
|| id.contains('\\')
|
||||||
|
|| id.contains("..")
|
||||||
|
|| id.contains('\0')
|
||||||
|
{
|
||||||
anyhow::bail!("Invalid backup ID");
|
anyhow::bail!("Invalid backup ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +172,13 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?;
|
||||||
|
|
||||||
// Validate backup ID to prevent path traversal
|
// Validate backup ID to prevent path traversal
|
||||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
if id.is_empty()
|
||||||
|
|| id.len() > 128
|
||||||
|
|| id.contains('/')
|
||||||
|
|| id.contains('\\')
|
||||||
|
|| id.contains("..")
|
||||||
|
|| id.contains('\0')
|
||||||
|
{
|
||||||
anyhow::bail!("Invalid backup ID");
|
anyhow::bail!("Invalid backup ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +264,13 @@ impl RpcHandler {
|
|||||||
let _region = params["region"].as_str().unwrap_or("us-east-1");
|
let _region = params["region"].as_str().unwrap_or("us-east-1");
|
||||||
|
|
||||||
// Validate backup ID
|
// Validate backup ID
|
||||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
if id.is_empty()
|
||||||
|
|| id.len() > 128
|
||||||
|
|| id.contains('/')
|
||||||
|
|| id.contains('\\')
|
||||||
|
|| id.contains("..")
|
||||||
|
|| id.contains('\0')
|
||||||
|
{
|
||||||
anyhow::bail!("Invalid backup ID");
|
anyhow::bail!("Invalid backup ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +309,11 @@ impl RpcHandler {
|
|||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
anyhow::bail!("S3 upload failed ({}): {}", status, &body[..200.min(body.len())]);
|
anyhow::bail!(
|
||||||
|
"S3 upload failed ({}): {}",
|
||||||
|
status,
|
||||||
|
&body[..200.min(body.len())]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(id = %id, bucket = %bucket, size = %size, "Backup uploaded to S3");
|
info!(id = %id, bucket = %bucket, size = %size, "Backup uploaded to S3");
|
||||||
@@ -317,7 +349,13 @@ impl RpcHandler {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'secret_key' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'secret_key' parameter"))?;
|
||||||
|
|
||||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
if id.is_empty()
|
||||||
|
|| id.len() > 128
|
||||||
|
|| id.contains('/')
|
||||||
|
|| id.contains('\\')
|
||||||
|
|| id.contains("..")
|
||||||
|
|| id.contains('\0')
|
||||||
|
{
|
||||||
anyhow::bail!("Invalid backup ID");
|
anyhow::bail!("Invalid backup ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,14 +381,19 @@ impl RpcHandler {
|
|||||||
anyhow::bail!("S3 download failed ({})", status);
|
anyhow::bail!("S3 download failed ({})", status);
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes = response.bytes().await.context("Failed to read S3 response")?;
|
let bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.context("Failed to read S3 response")?;
|
||||||
let size = bytes.len();
|
let size = bytes.len();
|
||||||
|
|
||||||
// Save to backups directory
|
// Save to backups directory
|
||||||
let bak_dir = self.config.data_dir.join("backups");
|
let bak_dir = self.config.data_dir.join("backups");
|
||||||
tokio::fs::create_dir_all(&bak_dir).await?;
|
tokio::fs::create_dir_all(&bak_dir).await?;
|
||||||
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
let bak_path = full::backup_file_path(&self.config.data_dir, id);
|
||||||
tokio::fs::write(&bak_path, &bytes).await.context("Failed to write backup file")?;
|
tokio::fs::write(&bak_path, &bytes)
|
||||||
|
.await
|
||||||
|
.context("Failed to write backup file")?;
|
||||||
|
|
||||||
info!(id = %id, bucket = %bucket, size = %size, "Backup downloaded from S3");
|
info!(id = %id, bucket = %bucket, size = %size, "Backup downloaded from S3");
|
||||||
|
|
||||||
@@ -376,13 +419,10 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'passphrase' parameter"))?;
|
||||||
|
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
let (did, pubkey) = crate::backup::restore_encrypted_backup(
|
let (did, pubkey) =
|
||||||
&identity_dir,
|
crate::backup::restore_encrypted_backup(&identity_dir, backup, passphrase)
|
||||||
backup,
|
.await
|
||||||
passphrase,
|
.context("Identity restore failed")?;
|
||||||
)
|
|
||||||
.await
|
|
||||||
.context("Identity restore failed")?;
|
|
||||||
|
|
||||||
info!(did = %did, "Identity restored from backup");
|
info!(did = %did, "Identity restored from backup");
|
||||||
|
|
||||||
|
|||||||
@@ -57,16 +57,12 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let info = BitcoinInfo {
|
let info = BitcoinInfo {
|
||||||
block_height: blockchain_info.blocks.unwrap_or(0),
|
block_height: blockchain_info.blocks.unwrap_or(0),
|
||||||
sync_progress: blockchain_info
|
sync_progress: blockchain_info.verification_progress.unwrap_or(0.0),
|
||||||
.verification_progress
|
|
||||||
.unwrap_or(0.0),
|
|
||||||
chain: blockchain_info.chain.unwrap_or_else(|| "unknown".into()),
|
chain: blockchain_info.chain.unwrap_or_else(|| "unknown".into()),
|
||||||
difficulty: blockchain_info.difficulty.unwrap_or(0.0),
|
difficulty: blockchain_info.difficulty.unwrap_or(0.0),
|
||||||
mempool_size: mempool_info.bytes.unwrap_or(0),
|
mempool_size: mempool_info.bytes.unwrap_or(0),
|
||||||
mempool_tx_count: mempool_info.size.unwrap_or(0),
|
mempool_tx_count: mempool_info.size.unwrap_or(0),
|
||||||
verification_progress: blockchain_info
|
verification_progress: blockchain_info.verification_progress.unwrap_or(0.0),
|
||||||
.verification_progress
|
|
||||||
.unwrap_or(0.0),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(serde_json::to_value(info)?)
|
Ok(serde_json::to_value(info)?)
|
||||||
@@ -116,19 +112,24 @@ impl RpcHandler {
|
|||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let password = params.get("password")
|
let password = params
|
||||||
|
.get("password")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
|
||||||
let wallet_name = params.get("wallet_name")
|
let wallet_name = params
|
||||||
|
.get("wallet_name")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("archipelago");
|
.unwrap_or("archipelago");
|
||||||
|
|
||||||
// Verify user password.
|
// Verify user password.
|
||||||
self.auth_manager.verify_password(password).await
|
self.auth_manager
|
||||||
|
.verify_password(password)
|
||||||
|
.await
|
||||||
.context("Password verification failed")?;
|
.context("Password verification failed")?;
|
||||||
|
|
||||||
// Load encrypted seed.
|
// Load encrypted seed.
|
||||||
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password).await
|
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password)
|
||||||
|
.await
|
||||||
.context("Failed to load encrypted seed")?;
|
.context("Failed to load encrypted seed")?;
|
||||||
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
||||||
|
|
||||||
@@ -142,25 +143,30 @@ impl RpcHandler {
|
|||||||
.context("Failed to create HTTP client")?;
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
// Step 1: Create a blank descriptor wallet.
|
// Step 1: Create a blank descriptor wallet.
|
||||||
let create_result = self.bitcoin_rpc_call::<serde_json::Value>(
|
let create_result = self
|
||||||
&client,
|
.bitcoin_rpc_call::<serde_json::Value>(
|
||||||
"createwallet",
|
&client,
|
||||||
&[
|
"createwallet",
|
||||||
serde_json::json!(wallet_name), // wallet_name
|
&[
|
||||||
serde_json::json!(false), // disable_private_keys
|
serde_json::json!(wallet_name), // wallet_name
|
||||||
serde_json::json!(true), // blank
|
serde_json::json!(false), // disable_private_keys
|
||||||
serde_json::json!(""), // passphrase
|
serde_json::json!(true), // blank
|
||||||
serde_json::json!(false), // avoid_reuse
|
serde_json::json!(""), // passphrase
|
||||||
serde_json::json!(true), // descriptors
|
serde_json::json!(false), // avoid_reuse
|
||||||
],
|
serde_json::json!(true), // descriptors
|
||||||
).await;
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
match create_result {
|
match create_result {
|
||||||
Ok(_) => tracing::info!("Created blank descriptor wallet '{}'", wallet_name),
|
Ok(_) => tracing::info!("Created blank descriptor wallet '{}'", wallet_name),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = e.to_string();
|
let msg = e.to_string();
|
||||||
if msg.contains("already exists") {
|
if msg.contains("already exists") {
|
||||||
tracing::info!("Wallet '{}' already exists, importing descriptors", wallet_name);
|
tracing::info!(
|
||||||
|
"Wallet '{}' already exists, importing descriptors",
|
||||||
|
wallet_name
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
xprv_str.zeroize();
|
xprv_str.zeroize();
|
||||||
return Err(e.context("Failed to create wallet"));
|
return Err(e.context("Failed to create wallet"));
|
||||||
@@ -174,18 +180,30 @@ impl RpcHandler {
|
|||||||
let internal_desc = format!("wpkh({}/1/*)", xprv_str);
|
let internal_desc = format!("wpkh({}/1/*)", xprv_str);
|
||||||
|
|
||||||
// Get checksums from Bitcoin Core.
|
// Get checksums from Bitcoin Core.
|
||||||
let ext_info: serde_json::Value = self.bitcoin_rpc_call(
|
let ext_info: serde_json::Value = self
|
||||||
&client, "getdescriptorinfo", &[serde_json::json!(external_desc)],
|
.bitcoin_rpc_call(
|
||||||
).await.context("getdescriptorinfo failed for external descriptor")?;
|
&client,
|
||||||
|
"getdescriptorinfo",
|
||||||
|
&[serde_json::json!(external_desc)],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("getdescriptorinfo failed for external descriptor")?;
|
||||||
|
|
||||||
let int_info: serde_json::Value = self.bitcoin_rpc_call(
|
let int_info: serde_json::Value = self
|
||||||
&client, "getdescriptorinfo", &[serde_json::json!(internal_desc)],
|
.bitcoin_rpc_call(
|
||||||
).await.context("getdescriptorinfo failed for internal descriptor")?;
|
&client,
|
||||||
|
"getdescriptorinfo",
|
||||||
|
&[serde_json::json!(internal_desc)],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.context("getdescriptorinfo failed for internal descriptor")?;
|
||||||
|
|
||||||
let ext_desc_with_checksum = ext_info.get("descriptor")
|
let ext_desc_with_checksum = ext_info
|
||||||
|
.get("descriptor")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
|
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
|
||||||
let int_desc_with_checksum = int_info.get("descriptor")
|
let int_desc_with_checksum = int_info
|
||||||
|
.get("descriptor")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
|
.ok_or_else(|| anyhow::anyhow!("No descriptor in getdescriptorinfo response"))?;
|
||||||
|
|
||||||
@@ -206,14 +224,18 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let _import_result: serde_json::Value = self.bitcoin_rpc_call(
|
let _import_result: serde_json::Value = self
|
||||||
&client, "importdescriptors", &[import_params],
|
.bitcoin_rpc_call(&client, "importdescriptors", &[import_params])
|
||||||
).await.context("importdescriptors failed")?;
|
.await
|
||||||
|
.context("importdescriptors failed")?;
|
||||||
|
|
||||||
// Zeroize the xprv string from memory.
|
// Zeroize the xprv string from memory.
|
||||||
xprv_str.zeroize();
|
xprv_str.zeroize();
|
||||||
|
|
||||||
tracing::info!("Bitcoin Core wallet '{}' initialized from master seed (BIP-84)", wallet_name);
|
tracing::info!(
|
||||||
|
"Bitcoin Core wallet '{}' initialized from master seed (BIP-84)",
|
||||||
|
wallet_name
|
||||||
|
);
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"initialized": true,
|
"initialized": true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::RpcHandler;
|
|
||||||
use super::package::validate_app_id;
|
use super::package::validate_app_id;
|
||||||
|
use super::RpcHandler;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
@@ -7,10 +7,9 @@ impl RpcHandler {
|
|||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let orchestrator = self
|
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||||
.orchestrator
|
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||||
.as_ref()
|
})?;
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
|
||||||
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let manifest_path = params
|
let manifest_path = params
|
||||||
@@ -43,8 +42,8 @@ impl RpcHandler {
|
|||||||
let manifest_content = tokio::fs::read_to_string(&canonical)
|
let manifest_content = tokio::fs::read_to_string(&canonical)
|
||||||
.await
|
.await
|
||||||
.context("Failed to read manifest file")?;
|
.context("Failed to read manifest file")?;
|
||||||
let manifest: archipelago_container::AppManifest = serde_yaml::from_str(&manifest_content)
|
let manifest: archipelago_container::AppManifest =
|
||||||
.context("Failed to parse manifest")?;
|
serde_yaml::from_str(&manifest_content).context("Failed to parse manifest")?;
|
||||||
|
|
||||||
let container_name = orchestrator
|
let container_name = orchestrator
|
||||||
.install_container(&manifest, manifest_path)
|
.install_container(&manifest, manifest_path)
|
||||||
@@ -58,10 +57,9 @@ impl RpcHandler {
|
|||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let orchestrator = self
|
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||||
.orchestrator
|
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||||
.as_ref()
|
})?;
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
|
||||||
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let app_id = params
|
let app_id = params
|
||||||
@@ -82,10 +80,9 @@ impl RpcHandler {
|
|||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let orchestrator = self
|
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||||
.orchestrator
|
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||||
.as_ref()
|
})?;
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
|
||||||
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let app_id = params
|
let app_id = params
|
||||||
@@ -106,10 +103,9 @@ impl RpcHandler {
|
|||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let orchestrator = self
|
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||||
.orchestrator
|
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||||
.as_ref()
|
})?;
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
|
||||||
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let app_id = params
|
let app_id = params
|
||||||
@@ -137,27 +133,33 @@ impl RpcHandler {
|
|||||||
// 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;
|
||||||
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 containers: Vec<serde_json::Value> = data.package_data.iter().map(|(id, pkg)| {
|
let containers: Vec<serde_json::Value> = data
|
||||||
let state = match &pkg.state {
|
.package_data
|
||||||
crate::data_model::PackageState::Running => "running",
|
.iter()
|
||||||
crate::data_model::PackageState::Stopped => "stopped",
|
.map(|(id, pkg)| {
|
||||||
crate::data_model::PackageState::Exited => "exited",
|
let state = match &pkg.state {
|
||||||
crate::data_model::PackageState::Starting => "created",
|
crate::data_model::PackageState::Running => "running",
|
||||||
_ => "unknown",
|
crate::data_model::PackageState::Stopped => "stopped",
|
||||||
};
|
crate::data_model::PackageState::Exited => "exited",
|
||||||
let lan = pkg.installed.as_ref()
|
crate::data_model::PackageState::Starting => "created",
|
||||||
.and_then(|i| i.interface_addresses.get("main"))
|
_ => "unknown",
|
||||||
.and_then(|a| a.lan_address.as_deref());
|
};
|
||||||
serde_json::json!({
|
let lan = pkg
|
||||||
"id": id,
|
.installed
|
||||||
"name": id,
|
.as_ref()
|
||||||
"state": state,
|
.and_then(|i| i.interface_addresses.get("main"))
|
||||||
"image": "",
|
.and_then(|a| a.lan_address.as_deref());
|
||||||
"created": "",
|
serde_json::json!({
|
||||||
"ports": [],
|
"id": id,
|
||||||
"lan_address": lan,
|
"name": id,
|
||||||
|
"state": state,
|
||||||
|
"image": "",
|
||||||
|
"created": "",
|
||||||
|
"ports": [],
|
||||||
|
"lan_address": lan,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
return Ok(serde_json::json!(containers));
|
return Ok(serde_json::json!(containers));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +187,8 @@ impl RpcHandler {
|
|||||||
return Ok(serde_json::json!([]));
|
return Ok(serde_json::json!([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
let podman_containers: Vec<serde_json::Value> = serde_json::from_str(&stdout)
|
let podman_containers: Vec<serde_json::Value> =
|
||||||
.unwrap_or_else(|_| Vec::new());
|
serde_json::from_str(&stdout).unwrap_or_else(|_| Vec::new());
|
||||||
|
|
||||||
let containers: Vec<serde_json::Value> = podman_containers
|
let containers: Vec<serde_json::Value> = podman_containers
|
||||||
.iter()
|
.iter()
|
||||||
@@ -200,16 +202,25 @@ impl RpcHandler {
|
|||||||
"paused" => "paused",
|
"paused" => "paused",
|
||||||
_ => "unknown",
|
_ => "unknown",
|
||||||
};
|
};
|
||||||
let name = c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or("");
|
let name = c
|
||||||
let ports: Vec<String> = c.get("Ports")
|
.get("Names")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.and_then(|a| a.first())
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let ports: Vec<String> = c
|
||||||
|
.get("Ports")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.map(|a| {
|
.map(|a| {
|
||||||
a.iter().filter_map(|p| {
|
a.iter()
|
||||||
let host = p.get("host_port").and_then(|v| v.as_u64())?;
|
.filter_map(|p| {
|
||||||
let container = p.get("container_port").and_then(|v| v.as_u64())?;
|
let host = p.get("host_port").and_then(|v| v.as_u64())?;
|
||||||
let proto = p.get("protocol").and_then(|v| v.as_str()).unwrap_or("tcp");
|
let container = p.get("container_port").and_then(|v| v.as_u64())?;
|
||||||
Some(format!("0.0.0.0:{}->{}/{}", host, container, proto))
|
let proto =
|
||||||
}).collect()
|
p.get("protocol").and_then(|v| v.as_str()).unwrap_or("tcp");
|
||||||
|
Some(format!("0.0.0.0:{}->{}/{}", host, container, proto))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -231,10 +242,9 @@ impl RpcHandler {
|
|||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let orchestrator = self
|
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||||
.orchestrator
|
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||||
.as_ref()
|
})?;
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
|
||||||
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let app_id = params
|
let app_id = params
|
||||||
@@ -255,10 +265,9 @@ impl RpcHandler {
|
|||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let orchestrator = self
|
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||||
.orchestrator
|
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||||
.as_ref()
|
})?;
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
|
||||||
|
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let app_id = params
|
let app_id = params
|
||||||
@@ -266,10 +275,7 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||||
validate_app_id(app_id)?;
|
validate_app_id(app_id)?;
|
||||||
let lines = params
|
let lines = params.get("lines").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
|
||||||
.get("lines")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(100) as u32;
|
|
||||||
|
|
||||||
let logs = orchestrator
|
let logs = orchestrator
|
||||||
.get_container_logs(app_id, lines)
|
.get_container_logs(app_id, lines)
|
||||||
@@ -285,10 +291,9 @@ impl RpcHandler {
|
|||||||
app_id: &str,
|
app_id: &str,
|
||||||
lines: u32,
|
lines: u32,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let orchestrator = self
|
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||||
.orchestrator
|
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||||
.as_ref()
|
})?;
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
|
||||||
|
|
||||||
let logs = orchestrator
|
let logs = orchestrator
|
||||||
.get_container_logs(app_id, lines)
|
.get_container_logs(app_id, lines)
|
||||||
@@ -302,10 +307,9 @@ impl RpcHandler {
|
|||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let orchestrator = self
|
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
|
||||||
.orchestrator
|
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
|
||||||
.as_ref()
|
})?;
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
|
||||||
|
|
||||||
// If app_id is provided, get health for that app
|
// If app_id is provided, get health for that app
|
||||||
if let Some(params) = params {
|
if let Some(params) = params {
|
||||||
@@ -330,10 +334,14 @@ impl RpcHandler {
|
|||||||
if let Some(app_id) = app_id.strip_suffix("-dev") {
|
if let Some(app_id) = app_id.strip_suffix("-dev") {
|
||||||
match orchestrator.get_health_status(app_id).await {
|
match orchestrator.get_health_status(app_id).await {
|
||||||
Ok(health) => {
|
Ok(health) => {
|
||||||
health_map.insert(app_id.to_string(), serde_json::Value::String(health));
|
health_map
|
||||||
|
.insert(app_id.to_string(), serde_json::Value::String(health));
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
health_map.insert(app_id.to_string(), serde_json::Value::String("unknown".to_string()));
|
health_map.insert(
|
||||||
|
app_id.to_string(),
|
||||||
|
serde_json::Value::String("unknown".to_string()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use crate::content_server::{self, AccessControl, Availability, ContentItem};
|
use crate::content_server::{self, AccessControl, Availability, ContentItem};
|
||||||
use crate::network::dwn_store::DwnStore;
|
use crate::network::dwn_store::DwnStore;
|
||||||
|
use crate::wallet::ecash;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
@@ -11,16 +12,16 @@ fn is_valid_v3_onion(addr: &str) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let prefix = &addr[..56];
|
let prefix = &addr[..56];
|
||||||
prefix.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
|
prefix
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
|
||||||
}
|
}
|
||||||
|
|
||||||
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// List content I'm sharing.
|
/// List content I'm sharing.
|
||||||
pub(super) async fn handle_content_list_mine(
|
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
||||||
&self,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let catalog = content_server::load_catalog(&self.config.data_dir).await?;
|
let catalog = content_server::load_catalog(&self.config.data_dir).await?;
|
||||||
Ok(serde_json::json!({ "items": catalog.items }))
|
Ok(serde_json::json!({ "items": catalog.items }))
|
||||||
}
|
}
|
||||||
@@ -45,7 +46,10 @@ impl RpcHandler {
|
|||||||
anyhow::bail!("Invalid filename: absolute paths and hidden files not allowed");
|
anyhow::bail!("Invalid filename: absolute paths and hidden files not allowed");
|
||||||
}
|
}
|
||||||
// Reject any path segment starting with . (hidden dirs)
|
// Reject any path segment starting with . (hidden dirs)
|
||||||
if filename.split('/').any(|seg| seg.starts_with('.') || seg.is_empty()) {
|
if filename
|
||||||
|
.split('/')
|
||||||
|
.any(|seg| seg.starts_with('.') || seg.is_empty())
|
||||||
|
{
|
||||||
anyhow::bail!("Invalid filename: hidden files/dirs or empty segments not allowed");
|
anyhow::bail!("Invalid filename: hidden files/dirs or empty segments not allowed");
|
||||||
}
|
}
|
||||||
if filename.is_empty() || filename.len() > 512 {
|
if filename.is_empty() || filename.len() > 512 {
|
||||||
@@ -191,14 +195,21 @@ impl RpcHandler {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
Availability::Specific { peers }
|
Availability::Specific { peers }
|
||||||
}
|
}
|
||||||
_ => return Err(anyhow::anyhow!("Invalid availability: {}", availability_type)),
|
_ => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid availability: {}",
|
||||||
|
availability_type
|
||||||
|
))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
content_server::set_availability(&self.config.data_dir, id, availability).await?;
|
content_server::set_availability(&self.config.data_dir, id, availability).await?;
|
||||||
Ok(serde_json::json!({ "updated": true }))
|
Ok(serde_json::json!({ "updated": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download content from a peer over Tor, returning base64-encoded data.
|
/// Download content from a peer. Prefers FIPS when the peer is known
|
||||||
|
/// in our federation and has advertised a FIPS npub; falls back to
|
||||||
|
/// Tor on any network failure.
|
||||||
pub(super) async fn handle_content_download_peer(
|
pub(super) async fn handle_content_download_peer(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
@@ -218,25 +229,20 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
|
||||||
.context("Failed to create SOCKS proxy")?;
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.proxy(socks_proxy)
|
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
|
||||||
.build()
|
|
||||||
.context("Failed to build Tor HTTP client")?;
|
|
||||||
|
|
||||||
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)?;
|
||||||
|
let fips_npub =
|
||||||
|
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
|
|
||||||
let url = format!("http://{}/content/{}", onion, content_id);
|
let path = format!("/content/{}", content_id);
|
||||||
let response = client
|
let (response, _transport) =
|
||||||
.get(&url)
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||||
.header("X-Federation-DID", &local_did)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.send()
|
.header("X-Federation-DID", local_did)
|
||||||
.await
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
.context("Failed to connect to peer over Tor")?;
|
.send_get()
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to peer")?;
|
||||||
|
|
||||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||||
@@ -264,7 +270,8 @@ impl RpcHandler {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Browse a peer's content catalog over Tor.
|
/// Browse a peer's content catalog. FIPS if the peer is federated,
|
||||||
|
/// otherwise Tor.
|
||||||
pub(super) async fn handle_content_browse_peer(
|
pub(super) async fn handle_content_browse_peer(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
@@ -280,24 +287,18 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect via Tor SOCKS proxy to the peer's content catalog endpoint
|
let fips_npub =
|
||||||
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
.context("Failed to create SOCKS proxy")?;
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
debug!("Browsing peer content at {} (fips={})", onion, fips_npub.is_some());
|
||||||
.proxy(socks_proxy)
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.context("Failed to build Tor HTTP client")?;
|
|
||||||
|
|
||||||
let url = format!("http://{}/content", onion);
|
let (response, _transport) =
|
||||||
debug!("Browsing peer content at {}", url);
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
|
||||||
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
let response = client
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
.get(&url)
|
.send_get()
|
||||||
.send()
|
.await
|
||||||
.await
|
.context("Failed to connect to peer")?;
|
||||||
.context("Failed to connect to peer over Tor")?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
@@ -313,4 +314,147 @@ impl RpcHandler {
|
|||||||
|
|
||||||
Ok(body)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download paid content from a peer: mint ecash token, send with request.
|
||||||
|
pub(super) async fn handle_content_download_peer_paid(
|
||||||
|
&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"))?;
|
||||||
|
let price_sats = params
|
||||||
|
.get("price_sats")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?;
|
||||||
|
|
||||||
|
if price_sats == 0 {
|
||||||
|
return Err(anyhow::anyhow!("price_sats must be > 0"));
|
||||||
|
}
|
||||||
|
if !is_valid_v3_onion(onion) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint ecash payment token
|
||||||
|
let token_str = ecash::send_token(&self.config.data_dir, price_sats)
|
||||||
|
.await
|
||||||
|
.context("Failed to create ecash payment token — check wallet balance")?;
|
||||||
|
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
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 path = format!("/content/{}", content_id);
|
||||||
|
let (response, _transport) =
|
||||||
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||||
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
|
.header("X-Federation-DID", local_did)
|
||||||
|
.header("X-Payment-Token", token_str)
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.send_get()
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to peer")?;
|
||||||
|
|
||||||
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||||
|
// Payment was rejected — token is spent but content not received
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Payment rejected by peer — token may have been insufficient or invalid"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.context("Failed to read response body")?;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"data": encoded,
|
||||||
|
"size": bytes.len(),
|
||||||
|
"paid_sats": price_sats,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a preview of paid content from a peer (no payment required).
|
||||||
|
pub(super) async fn handle_content_preview_peer(
|
||||||
|
&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"))?;
|
||||||
|
|
||||||
|
if !is_valid_v3_onion(onion) {
|
||||||
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let fips_npub =
|
||||||
|
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
|
|
||||||
|
let path = format!("/content/{}/preview", content_id);
|
||||||
|
debug!("Fetching content preview from {}{} (fips={})", onion, path, fips_npub.is_some());
|
||||||
|
|
||||||
|
let (response, _transport) =
|
||||||
|
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||||
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.send_get()
|
||||||
|
.await
|
||||||
|
.context("Failed to connect to peer for preview")?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Peer returned error for preview: {}",
|
||||||
|
response.status()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_preview = response
|
||||||
|
.headers()
|
||||||
|
.get("X-Content-Preview")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get("content-type")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.context("Failed to read preview response")?;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"data": encoded,
|
||||||
|
"size": bytes.len(),
|
||||||
|
"content_type": content_type,
|
||||||
|
"preview_mode": is_preview,
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ impl RpcHandler {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let status = if credentials::is_revoked(&vc) { "revoked" } else { "active" };
|
let status = if credentials::is_revoked(&vc) {
|
||||||
|
"revoked"
|
||||||
|
} else {
|
||||||
|
"active"
|
||||||
|
};
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"id": vc.id,
|
"id": vc.id,
|
||||||
@@ -113,7 +117,11 @@ impl RpcHandler {
|
|||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let status = if credentials::is_revoked(vc) { "revoked" } else { "active" };
|
let status = if credentials::is_revoked(vc) {
|
||||||
|
"revoked"
|
||||||
|
} else {
|
||||||
|
"active"
|
||||||
|
};
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"id": vc.id,
|
"id": vc.id,
|
||||||
@@ -136,7 +144,11 @@ impl RpcHandler {
|
|||||||
let items: Vec<serde_json::Value> = creds
|
let items: Vec<serde_json::Value> = creds
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
let status = if credentials::is_revoked(&c) { "revoked" } else { "active" };
|
let status = if credentials::is_revoked(&c) {
|
||||||
|
"revoked"
|
||||||
|
} else {
|
||||||
|
"active"
|
||||||
|
};
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"@context": c.context,
|
"@context": c.context,
|
||||||
"id": c.id,
|
"id": c.id,
|
||||||
@@ -228,8 +240,7 @@ impl RpcHandler {
|
|||||||
.get("presentation")
|
.get("presentation")
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing presentation"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing presentation"))?;
|
||||||
|
|
||||||
let vp: credentials::VerifiablePresentation =
|
let vp: credentials::VerifiablePresentation = serde_json::from_value(presentation.clone())?;
|
||||||
serde_json::from_value(presentation.clone())?;
|
|
||||||
|
|
||||||
let data_dir = self.config.data_dir.clone();
|
let data_dir = self.config.data_dir.clone();
|
||||||
let result = credentials::verify_presentation(&vp, |did, bytes, signature| {
|
let result = credentials::verify_presentation(&vp, |did, bytes, signature| {
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ impl RpcHandler {
|
|||||||
"health" => self.handle_health().await,
|
"health" => self.handle_health().await,
|
||||||
"auth.login" => self.handle_auth_login(params).await,
|
"auth.login" => self.handle_auth_login(params).await,
|
||||||
"auth.logout" => self.handle_auth_logout().await,
|
"auth.logout" => self.handle_auth_logout().await,
|
||||||
"auth.changePassword" => self.handle_auth_change_password(params, session_token).await,
|
"auth.changePassword" => {
|
||||||
|
self.handle_auth_change_password(params, session_token)
|
||||||
|
.await
|
||||||
|
}
|
||||||
"auth.isSetup" => self.handle_auth_is_setup().await,
|
"auth.isSetup" => self.handle_auth_is_setup().await,
|
||||||
"auth.setup" => self.handle_auth_setup(params).await,
|
"auth.setup" => self.handle_auth_setup(params).await,
|
||||||
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
"auth.onboardingComplete" => self.handle_auth_onboarding_complete().await,
|
||||||
@@ -45,6 +48,7 @@ impl RpcHandler {
|
|||||||
"package.stop" => self.handle_package_stop(params).await,
|
"package.stop" => self.handle_package_stop(params).await,
|
||||||
"package.restart" => self.handle_package_restart(params).await,
|
"package.restart" => self.handle_package_restart(params).await,
|
||||||
"package.uninstall" => self.handle_package_uninstall(params).await,
|
"package.uninstall" => self.handle_package_uninstall(params).await,
|
||||||
|
"package.update" => self.handle_package_update(params).await,
|
||||||
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
||||||
|
|
||||||
// Bundled app management (for pre-loaded container images)
|
// Bundled app management (for pre-loaded container images)
|
||||||
@@ -74,6 +78,8 @@ impl RpcHandler {
|
|||||||
"handshake.discover" => self.handle_handshake_discover().await,
|
"handshake.discover" => self.handle_handshake_discover().await,
|
||||||
"handshake.connect" => self.handle_handshake_connect(params).await,
|
"handshake.connect" => self.handle_handshake_connect(params).await,
|
||||||
"handshake.poll" => self.handle_handshake_poll().await,
|
"handshake.poll" => self.handle_handshake_poll().await,
|
||||||
|
"nostr.discovery-status" => self.handle_nostr_discovery_status().await,
|
||||||
|
"nostr.set-discovery" => self.handle_nostr_set_discovery(params).await,
|
||||||
|
|
||||||
// TOTP 2FA
|
// TOTP 2FA
|
||||||
"auth.totp.setup.begin" => self.handle_totp_setup_begin(params).await,
|
"auth.totp.setup.begin" => self.handle_totp_setup_begin(params).await,
|
||||||
@@ -85,7 +91,9 @@ impl RpcHandler {
|
|||||||
|
|
||||||
// Bitcoin & Lightning deep data
|
// Bitcoin & Lightning deep data
|
||||||
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
|
"bitcoin.getinfo" => self.handle_bitcoin_getinfo().await,
|
||||||
"bitcoin.init-wallet-from-seed" => self.handle_bitcoin_init_wallet_from_seed(params).await,
|
"bitcoin.init-wallet-from-seed" => {
|
||||||
|
self.handle_bitcoin_init_wallet_from_seed(params).await
|
||||||
|
}
|
||||||
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
"lnd.getinfo" => self.handle_lnd_getinfo().await,
|
||||||
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
"lnd.listchannels" => self.handle_lnd_listchannels().await,
|
||||||
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
"lnd.openchannel" => self.handle_lnd_openchannel(params).await,
|
||||||
@@ -112,7 +120,9 @@ impl RpcHandler {
|
|||||||
"identity.verify" => self.handle_identity_verify(params).await,
|
"identity.verify" => self.handle_identity_verify(params).await,
|
||||||
"identity.resolve-did" => self.handle_identity_resolve_did(params).await,
|
"identity.resolve-did" => self.handle_identity_resolve_did(params).await,
|
||||||
"identity.resolve-remote-did" => self.handle_identity_resolve_remote_did(params).await,
|
"identity.resolve-remote-did" => self.handle_identity_resolve_remote_did(params).await,
|
||||||
"identity.verify-did-document" => self.handle_identity_verify_did_document(params).await,
|
"identity.verify-did-document" => {
|
||||||
|
self.handle_identity_verify_did_document(params).await
|
||||||
|
}
|
||||||
"identity.create-dht-did" => self.handle_identity_create_dht_did(params).await,
|
"identity.create-dht-did" => self.handle_identity_create_dht_did(params).await,
|
||||||
"identity.resolve-dht-did" => self.handle_identity_resolve_dht_did(params).await,
|
"identity.resolve-dht-did" => self.handle_identity_resolve_dht_did(params).await,
|
||||||
"identity.refresh-dht-did" => self.handle_identity_refresh_dht_did(params).await,
|
"identity.refresh-dht-did" => self.handle_identity_refresh_dht_did(params).await,
|
||||||
@@ -122,10 +132,18 @@ impl RpcHandler {
|
|||||||
"identity.export-keys" => self.handle_identity_export_keys(params).await,
|
"identity.export-keys" => self.handle_identity_export_keys(params).await,
|
||||||
"identity.create-nostr-key" => self.handle_identity_create_nostr_key(params).await,
|
"identity.create-nostr-key" => self.handle_identity_create_nostr_key(params).await,
|
||||||
"identity.nostr-sign" => self.handle_identity_nostr_sign(params).await,
|
"identity.nostr-sign" => self.handle_identity_nostr_sign(params).await,
|
||||||
"identity.nostr-encrypt-nip04" => self.handle_identity_nostr_encrypt_nip04(params).await,
|
"identity.nostr-encrypt-nip04" => {
|
||||||
"identity.nostr-decrypt-nip04" => self.handle_identity_nostr_decrypt_nip04(params).await,
|
self.handle_identity_nostr_encrypt_nip04(params).await
|
||||||
"identity.nostr-encrypt-nip44" => self.handle_identity_nostr_encrypt_nip44(params).await,
|
}
|
||||||
"identity.nostr-decrypt-nip44" => self.handle_identity_nostr_decrypt_nip44(params).await,
|
"identity.nostr-decrypt-nip04" => {
|
||||||
|
self.handle_identity_nostr_decrypt_nip04(params).await
|
||||||
|
}
|
||||||
|
"identity.nostr-encrypt-nip44" => {
|
||||||
|
self.handle_identity_nostr_encrypt_nip44(params).await
|
||||||
|
}
|
||||||
|
"identity.nostr-decrypt-nip44" => {
|
||||||
|
self.handle_identity_nostr_decrypt_nip44(params).await
|
||||||
|
}
|
||||||
|
|
||||||
// Bitcoin domain names (NIP-05)
|
// Bitcoin domain names (NIP-05)
|
||||||
"identity.register-name" => self.handle_identity_register_name(params).await,
|
"identity.register-name" => self.handle_identity_register_name(params).await,
|
||||||
@@ -139,8 +157,12 @@ impl RpcHandler {
|
|||||||
"identity.verify-credential" => self.handle_identity_verify_credential(params).await,
|
"identity.verify-credential" => self.handle_identity_verify_credential(params).await,
|
||||||
"identity.list-credentials" => self.handle_identity_list_credentials(params).await,
|
"identity.list-credentials" => self.handle_identity_list_credentials(params).await,
|
||||||
"identity.revoke-credential" => self.handle_identity_revoke_credential(params).await,
|
"identity.revoke-credential" => self.handle_identity_revoke_credential(params).await,
|
||||||
"identity.create-presentation" => self.handle_identity_create_presentation(params).await,
|
"identity.create-presentation" => {
|
||||||
"identity.verify-presentation" => self.handle_identity_verify_presentation(params).await,
|
self.handle_identity_create_presentation(params).await
|
||||||
|
}
|
||||||
|
"identity.verify-presentation" => {
|
||||||
|
self.handle_identity_verify_presentation(params).await
|
||||||
|
}
|
||||||
|
|
||||||
// Network overlay
|
// Network overlay
|
||||||
"network.get-visibility" => self.handle_network_get_visibility().await,
|
"network.get-visibility" => self.handle_network_get_visibility().await,
|
||||||
@@ -186,12 +208,35 @@ impl RpcHandler {
|
|||||||
// Ecash wallet
|
// Ecash wallet
|
||||||
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
|
"wallet.ecash-balance" => self.handle_wallet_ecash_balance().await,
|
||||||
"wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await,
|
"wallet.ecash-mint" => self.handle_wallet_ecash_mint(params).await,
|
||||||
|
"wallet.ecash-mint-claim" => self.handle_wallet_ecash_mint_claim(params).await,
|
||||||
"wallet.ecash-melt" => self.handle_wallet_ecash_melt(params).await,
|
"wallet.ecash-melt" => self.handle_wallet_ecash_melt(params).await,
|
||||||
|
"wallet.ecash-melt-confirm" => self.handle_wallet_ecash_melt_confirm(params).await,
|
||||||
"wallet.ecash-send" => self.handle_wallet_ecash_send(params).await,
|
"wallet.ecash-send" => self.handle_wallet_ecash_send(params).await,
|
||||||
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
|
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
|
||||||
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
|
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
|
||||||
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
|
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
|
||||||
|
|
||||||
|
// Container registries
|
||||||
|
"registry.list" => self.handle_registry_list().await,
|
||||||
|
"registry.add" => self.handle_registry_add(params).await,
|
||||||
|
"registry.remove" => self.handle_registry_remove(params).await,
|
||||||
|
"registry.test" => self.handle_registry_test(params).await,
|
||||||
|
|
||||||
|
// Streaming ecash payments
|
||||||
|
"streaming.list-services" => self.handle_streaming_list_services().await,
|
||||||
|
"streaming.configure-service" => self.handle_streaming_configure_service(params).await,
|
||||||
|
"streaming.toggle-service" => self.handle_streaming_toggle_service(params).await,
|
||||||
|
"streaming.pay" => self.handle_streaming_pay(params).await,
|
||||||
|
"streaming.discover" => self.handle_streaming_discover().await,
|
||||||
|
"streaming.usage" => self.handle_streaming_usage(params).await,
|
||||||
|
"streaming.session" => self.handle_streaming_session(params).await,
|
||||||
|
"streaming.list-sessions" => self.handle_streaming_list_sessions().await,
|
||||||
|
"streaming.close-session" => self.handle_streaming_close_session(params).await,
|
||||||
|
"streaming.advertise" => self.handle_streaming_advertise().await,
|
||||||
|
"streaming.list-mints" => self.handle_streaming_list_mints().await,
|
||||||
|
"streaming.configure-mints" => self.handle_streaming_configure_mints(params).await,
|
||||||
|
"streaming.maintenance" => self.handle_streaming_maintenance().await,
|
||||||
|
|
||||||
// Content catalog management
|
// Content catalog management
|
||||||
"content.list-mine" => self.handle_content_list_mine().await,
|
"content.list-mine" => self.handle_content_list_mine().await,
|
||||||
"content.add" => self.handle_content_add(params).await,
|
"content.add" => self.handle_content_add(params).await,
|
||||||
@@ -200,6 +245,8 @@ impl RpcHandler {
|
|||||||
"content.set-availability" => self.handle_content_set_availability(params).await,
|
"content.set-availability" => self.handle_content_set_availability(params).await,
|
||||||
"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.preview-peer" => self.handle_content_preview_peer(params).await,
|
||||||
|
|
||||||
// DWN (Decentralized Web Node)
|
// DWN (Decentralized Web Node)
|
||||||
"dwn.status" => self.handle_dwn_status().await,
|
"dwn.status" => self.handle_dwn_status().await,
|
||||||
@@ -232,14 +279,30 @@ impl RpcHandler {
|
|||||||
"federation.get-state" => self.handle_federation_get_state().await,
|
"federation.get-state" => self.handle_federation_get_state().await,
|
||||||
"federation.peer-joined" => self.handle_federation_peer_joined(params).await,
|
"federation.peer-joined" => self.handle_federation_peer_joined(params).await,
|
||||||
"federation.deploy-app" => self.handle_federation_deploy_app(params).await,
|
"federation.deploy-app" => self.handle_federation_deploy_app(params).await,
|
||||||
"federation.peer-address-changed" => self.handle_federation_peer_address_changed(params).await,
|
"federation.peer-address-changed" => {
|
||||||
"federation.notify-did-change" => self.handle_federation_notify_did_change(params).await,
|
self.handle_federation_peer_address_changed(params).await
|
||||||
|
}
|
||||||
|
"federation.notify-did-change" => {
|
||||||
|
self.handle_federation_notify_did_change(params).await
|
||||||
|
}
|
||||||
"federation.peer-did-changed" => self.handle_federation_peer_did_changed(params).await,
|
"federation.peer-did-changed" => self.handle_federation_peer_did_changed(params).await,
|
||||||
|
"federation.list-pending-requests" => {
|
||||||
|
self.handle_federation_list_pending_requests().await
|
||||||
|
}
|
||||||
|
"federation.approve-request" => self.handle_federation_approve_request(params).await,
|
||||||
|
"federation.reject-request" => self.handle_federation_reject_request(params).await,
|
||||||
|
"federation.cancel-request" => self.handle_federation_cancel_request(params).await,
|
||||||
|
|
||||||
// VPN & Remote Access
|
// VPN & Remote Access
|
||||||
"vpn.status" => self.handle_vpn_status().await,
|
"vpn.status" => self.handle_vpn_status().await,
|
||||||
"vpn.configure" => self.handle_vpn_configure(params).await,
|
"vpn.configure" => self.handle_vpn_configure(params).await,
|
||||||
"vpn.disconnect" => self.handle_vpn_disconnect().await,
|
"vpn.disconnect" => self.handle_vpn_disconnect().await,
|
||||||
|
"vpn.invite" => self.handle_vpn_invite(params).await,
|
||||||
|
"vpn.add-participant" => self.handle_vpn_add_participant(params).await,
|
||||||
|
"vpn.create-peer" => self.handle_vpn_create_peer(params).await,
|
||||||
|
"vpn.list-peers" => self.handle_vpn_list_peers().await,
|
||||||
|
"vpn.peer-config" => self.handle_vpn_peer_config(params).await,
|
||||||
|
"vpn.remove-peer" => self.handle_vpn_remove_peer(params).await,
|
||||||
"remote.setup" => self.handle_remote_setup(params).await,
|
"remote.setup" => self.handle_remote_setup(params).await,
|
||||||
|
|
||||||
// Marketplace
|
// Marketplace
|
||||||
@@ -255,12 +318,34 @@ impl RpcHandler {
|
|||||||
"mesh.status" => self.handle_mesh_status().await,
|
"mesh.status" => self.handle_mesh_status().await,
|
||||||
"mesh.peers" => self.handle_mesh_peers().await,
|
"mesh.peers" => self.handle_mesh_peers().await,
|
||||||
"mesh.messages" => self.handle_mesh_messages(params).await,
|
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||||
|
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
||||||
"mesh.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.broadcast" => self.handle_mesh_broadcast().await,
|
"mesh.broadcast" => self.handle_mesh_broadcast().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,
|
||||||
"mesh.send-alert" => self.handle_mesh_send_alert(params).await,
|
"mesh.send-alert" => self.handle_mesh_send_alert(params).await,
|
||||||
|
"mesh.send-content" => self.handle_mesh_send_content(params).await,
|
||||||
|
"mesh.send-content-inline" => self.handle_mesh_send_content_inline(params).await,
|
||||||
|
"mesh.transport-advice" => self.handle_mesh_transport_advice(params).await,
|
||||||
|
"mesh.fetch-content" => self.handle_mesh_fetch_content(params).await,
|
||||||
|
"mesh.send-reply" => self.handle_mesh_send_reply(params).await,
|
||||||
|
"mesh.send-reaction" => self.handle_mesh_send_reaction(params).await,
|
||||||
|
"mesh.send-read-receipt" => self.handle_mesh_send_read_receipt(params).await,
|
||||||
|
"mesh.forward-message" => self.handle_mesh_forward_message(params).await,
|
||||||
|
"mesh.edit-message" => self.handle_mesh_edit_message(params).await,
|
||||||
|
"mesh.delete-message" => self.handle_mesh_delete_message(params).await,
|
||||||
|
"mesh.send-psbt" => self.handle_mesh_send_psbt(params).await,
|
||||||
|
"mesh.broadcast-presence" => self.handle_mesh_broadcast_presence(params).await,
|
||||||
|
"mesh.presence-list" => self.handle_mesh_presence_list(params).await,
|
||||||
|
"mesh.contacts-list" => self.handle_mesh_contacts_list(params).await,
|
||||||
|
"mesh.contacts-save" => self.handle_mesh_contacts_save(params).await,
|
||||||
|
"mesh.contacts-block" => self.handle_mesh_contacts_block(params).await,
|
||||||
|
"mesh.send-channel-invite" => self.handle_mesh_send_channel_invite(params).await,
|
||||||
|
"conversations.list" => self.handle_conversations_list(params).await,
|
||||||
|
"conversations.messages" => self.handle_conversations_messages(params).await,
|
||||||
|
"mesh.clear-all" => self.handle_mesh_clear_all().await,
|
||||||
"mesh.outbox" => self.handle_mesh_outbox(params).await,
|
"mesh.outbox" => self.handle_mesh_outbox(params).await,
|
||||||
"mesh.session-status" => self.handle_mesh_session_status(params).await,
|
"mesh.session-status" => self.handle_mesh_session_status(params).await,
|
||||||
"mesh.rotate-prekeys" => self.handle_mesh_rotate_prekeys().await,
|
"mesh.rotate-prekeys" => self.handle_mesh_rotate_prekeys().await,
|
||||||
@@ -279,6 +364,8 @@ impl RpcHandler {
|
|||||||
"transport.peers" => self.handle_transport_peers().await,
|
"transport.peers" => self.handle_transport_peers().await,
|
||||||
"transport.send" => self.handle_transport_send(params).await,
|
"transport.send" => self.handle_transport_send(params).await,
|
||||||
"transport.set-mode" => self.handle_transport_set_mode(params).await,
|
"transport.set-mode" => self.handle_transport_set_mode(params).await,
|
||||||
|
"transport.preferences" => self.handle_transport_preferences().await,
|
||||||
|
"transport.set-preference" => self.handle_transport_set_preference(params).await,
|
||||||
|
|
||||||
// Server settings
|
// Server settings
|
||||||
"server.set-name" => self.handle_server_set_name(params).await,
|
"server.set-name" => self.handle_server_set_name(params).await,
|
||||||
@@ -292,6 +379,8 @@ impl RpcHandler {
|
|||||||
"system.disk-cleanup" => self.handle_system_disk_cleanup().await,
|
"system.disk-cleanup" => self.handle_system_disk_cleanup().await,
|
||||||
"system.reboot" => self.handle_system_reboot(params).await,
|
"system.reboot" => self.handle_system_reboot(params).await,
|
||||||
"system.factory-reset" => self.handle_system_factory_reset(params).await,
|
"system.factory-reset" => self.handle_system_factory_reset(params).await,
|
||||||
|
"system.settings.get" => self.handle_system_settings_get(params).await,
|
||||||
|
"system.settings.set" => self.handle_system_settings_set(params).await,
|
||||||
|
|
||||||
// Opt-in anonymous analytics
|
// Opt-in anonymous analytics
|
||||||
"analytics.get-status" => self.handle_analytics_get_status().await,
|
"analytics.get-status" => self.handle_analytics_get_status().await,
|
||||||
@@ -301,7 +390,9 @@ impl RpcHandler {
|
|||||||
"telemetry.report" => self.handle_telemetry_report().await,
|
"telemetry.report" => self.handle_telemetry_report().await,
|
||||||
"telemetry.ingest" => self.handle_telemetry_ingest(params).await,
|
"telemetry.ingest" => self.handle_telemetry_ingest(params).await,
|
||||||
"telemetry.fleet-status" => self.handle_telemetry_fleet_status().await,
|
"telemetry.fleet-status" => self.handle_telemetry_fleet_status().await,
|
||||||
"telemetry.fleet-node-history" => self.handle_telemetry_fleet_node_history(params).await,
|
"telemetry.fleet-node-history" => {
|
||||||
|
self.handle_telemetry_fleet_node_history(params).await
|
||||||
|
}
|
||||||
"telemetry.fleet-alerts" => self.handle_telemetry_fleet_alerts().await,
|
"telemetry.fleet-alerts" => self.handle_telemetry_fleet_alerts().await,
|
||||||
|
|
||||||
// Real-time metrics monitoring
|
// Real-time metrics monitoring
|
||||||
@@ -311,9 +402,18 @@ impl RpcHandler {
|
|||||||
"monitoring.alerts" => self.handle_monitoring_alerts(params).await,
|
"monitoring.alerts" => self.handle_monitoring_alerts(params).await,
|
||||||
"monitoring.alert-rules" => self.handle_monitoring_alert_rules().await,
|
"monitoring.alert-rules" => self.handle_monitoring_alert_rules().await,
|
||||||
"monitoring.configure-alert" => self.handle_monitoring_configure_alert(params).await,
|
"monitoring.configure-alert" => self.handle_monitoring_configure_alert(params).await,
|
||||||
"monitoring.acknowledge-alert" => self.handle_monitoring_acknowledge_alert(params).await,
|
"monitoring.acknowledge-alert" => {
|
||||||
|
self.handle_monitoring_acknowledge_alert(params).await
|
||||||
|
}
|
||||||
"monitoring.export" => self.handle_monitoring_export(params).await,
|
"monitoring.export" => self.handle_monitoring_export(params).await,
|
||||||
|
|
||||||
|
// FIPS mesh transport
|
||||||
|
"fips.status" => self.handle_fips_status().await,
|
||||||
|
"fips.check-update" => self.handle_fips_check_update().await,
|
||||||
|
"fips.apply-update" => self.handle_fips_apply_update().await,
|
||||||
|
"fips.install" => self.handle_fips_install().await,
|
||||||
|
"fips.restart" => self.handle_fips_restart().await,
|
||||||
|
|
||||||
// System updates
|
// System updates
|
||||||
"update.check" => self.handle_update_check().await,
|
"update.check" => self.handle_update_check().await,
|
||||||
"update.status" => self.handle_update_status().await,
|
"update.status" => self.handle_update_status().await,
|
||||||
@@ -379,13 +479,14 @@ impl RpcHandler {
|
|||||||
"webhook.configure" => self.handle_webhook_configure(params).await,
|
"webhook.configure" => self.handle_webhook_configure(params).await,
|
||||||
"webhook.test" => self.handle_webhook_test().await,
|
"webhook.test" => self.handle_webhook_test().await,
|
||||||
|
|
||||||
_ => {
|
_ => Err(anyhow::anyhow!("Unknown method: {}", method)),
|
||||||
Err(anyhow::anyhow!("Unknown method: {}", method))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_echo(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
pub(super) async fn handle_echo(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
if let Some(p) = params {
|
if let Some(p) = params {
|
||||||
if let Some(msg) = p.get("message").and_then(|v| v.as_str()) {
|
if let Some(msg) = p.get("message").and_then(|v| v.as_str()) {
|
||||||
return Ok(serde_json::json!({ "message": msg }));
|
return Ok(serde_json::json!({ "message": msg }));
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ impl RpcHandler {
|
|||||||
/// Get DWN status and sync state.
|
/// Get DWN status and sync state.
|
||||||
pub(super) async fn handle_dwn_status(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_dwn_status(&self) -> Result<serde_json::Value> {
|
||||||
let sync_state = dwn_sync::load_sync_state(&self.config.data_dir).await?;
|
let sync_state = dwn_sync::load_sync_state(&self.config.data_dir).await?;
|
||||||
let server_status = dwn_sync::get_dwn_status().await.unwrap_or(dwn_sync::DwnStatusResponse {
|
let server_status =
|
||||||
running: false,
|
dwn_sync::get_dwn_status()
|
||||||
version: String::new(),
|
.await
|
||||||
});
|
.unwrap_or(dwn_sync::DwnStatusResponse {
|
||||||
|
running: false,
|
||||||
|
version: String::new(),
|
||||||
|
});
|
||||||
|
|
||||||
let store = DwnStore::new(&self.config.data_dir).await?;
|
let store = DwnStore::new(&self.config.data_dir).await?;
|
||||||
let stats = store.stats().await?;
|
let stats = store.stats().await?;
|
||||||
|
|||||||
@@ -1,33 +1,62 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::api::rpc::RpcHandler;
|
use crate::api::rpc::RpcHandler;
|
||||||
use crate::credentials;
|
use crate::credentials;
|
||||||
use crate::federation::{self, FederatedNode, TrustLevel};
|
use crate::federation::{self, pending, FederatedNode, TrustLevel};
|
||||||
use crate::identity;
|
use crate::identity;
|
||||||
|
use crate::mesh;
|
||||||
use crate::network::dwn_store::DwnStore;
|
use crate::network::dwn_store::DwnStore;
|
||||||
use anyhow::{Context, Result};
|
use crate::nostr_handshake;
|
||||||
|
use anyhow::Result;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
|
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// Register a federation node with the running mesh service so it's
|
||||||
|
/// immediately addressable as a chat target. The mesh service seeds
|
||||||
|
/// federation peers at startup, but federation nodes added or rotated
|
||||||
|
/// later in the session would otherwise stay invisible to the mesh
|
||||||
|
/// chat UI until the next mesh restart, and `mesh.send` against the
|
||||||
|
/// frontend's synthesised contact_id would fail with "Unknown
|
||||||
|
/// federation peer". Best-effort: silently no-ops when mesh is off.
|
||||||
|
async fn register_federation_peer_in_mesh(
|
||||||
|
&self,
|
||||||
|
pubkey_hex: &str,
|
||||||
|
did: &str,
|
||||||
|
name: Option<&str>,
|
||||||
|
) {
|
||||||
|
let svc = self.mesh_service.read().await;
|
||||||
|
if let Some(svc) = svc.as_ref() {
|
||||||
|
mesh::upsert_federation_peer(&svc.shared_state(), pubkey_hex, did, name).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
||||||
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_federation_invite(&self) -> Result<serde_json::Value> {
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
let onion = data
|
let onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||||
.server_info
|
|
||||||
.tor_address
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let pubkey = data.server_info.pubkey.clone();
|
let pubkey = data.server_info.pubkey.clone();
|
||||||
|
|
||||||
if onion.is_empty() {
|
if onion.is_empty() {
|
||||||
anyhow::bail!("Tor address not available. Tor may not be running.");
|
anyhow::bail!("Tor address not available. Tor may not be running.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let code = federation::create_invite(&self.config.data_dir, &did, &onion, &pubkey).await?;
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None);
|
||||||
|
|
||||||
info!(did = %did, "Generated federation invite");
|
let code = federation::create_invite(
|
||||||
|
&self.config.data_dir,
|
||||||
|
&did,
|
||||||
|
&onion,
|
||||||
|
&pubkey,
|
||||||
|
fips_npub.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!(did = %did, fips_advertised = fips_npub.is_some(), "Generated federation invite");
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"code": code,
|
"code": code,
|
||||||
"did": did,
|
"did": did,
|
||||||
@@ -53,18 +82,26 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||||
|
let local_fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None);
|
||||||
let node = federation::accept_invite(
|
let node = federation::accept_invite(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
code,
|
code,
|
||||||
&local_did,
|
&local_did,
|
||||||
&local_onion,
|
&local_onion,
|
||||||
&local_pubkey,
|
&local_pubkey,
|
||||||
|
local_fips_npub.as_deref(),
|
||||||
|data| node_identity.sign(data),
|
|data| node_identity.sign(data),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
info!(peer_did = %node.did, "Joined federation with peer");
|
info!(peer_did = %node.did, "Joined federation with peer");
|
||||||
|
|
||||||
|
// Make the new peer immediately addressable from the mesh chat UI.
|
||||||
|
// Without this, the row exists in the federation list but `mesh.send`
|
||||||
|
// against it fails until the next mesh service restart re-seeds.
|
||||||
|
self.register_federation_peer_in_mesh(&node.pubkey, &node.did, node.name.as_deref())
|
||||||
|
.await;
|
||||||
|
|
||||||
// Store federation membership as DWN message
|
// Store federation membership as DWN message
|
||||||
if let Ok(store) = DwnStore::new(&self.config.data_dir).await {
|
if let Ok(store) = DwnStore::new(&self.config.data_dir).await {
|
||||||
let dwn_data = serde_json::json!({
|
let dwn_data = serde_json::json!({
|
||||||
@@ -110,7 +147,9 @@ impl RpcHandler {
|
|||||||
tokio::task::block_in_place(|| {
|
tokio::task::block_in_place(|| {
|
||||||
let rt = tokio::runtime::Handle::current();
|
let rt = tokio::runtime::Handle::current();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
let id =
|
||||||
|
crate::identity::NodeIdentity::load_or_create(&identity_dir)
|
||||||
|
.await?;
|
||||||
Ok(id.sign(bytes))
|
Ok(id.sign(bytes))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -118,7 +157,9 @@ impl RpcHandler {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(vc) => debug!(vc_id = %vc.id, peer = %peer_did, "Issued federation trust VC"),
|
Ok(vc) => {
|
||||||
|
debug!(vc_id = %vc.id, peer = %peer_did, "Issued federation trust VC")
|
||||||
|
}
|
||||||
Err(e) => debug!(error = %e, "Federation trust VC issuance failed (non-fatal)"),
|
Err(e) => debug!(error = %e, "Federation trust VC issuance failed (non-fatal)"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -136,18 +177,24 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.list-nodes — List all federated nodes with their status, last state, and VC verification.
|
/// federation.list-nodes — List all federated nodes with their status, last state, and VC verification.
|
||||||
pub(in crate::api::rpc) async fn handle_federation_list_nodes(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_federation_list_nodes(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
|
|
||||||
// Load credentials to check for federation VCs
|
// Load credentials to check for federation VCs
|
||||||
let cred_store = credentials::load_credentials(&self.config.data_dir).await.ok();
|
let cred_store = credentials::load_credentials(&self.config.data_dir)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
let vc_subjects: std::collections::HashSet<String> = cred_store
|
let vc_subjects: std::collections::HashSet<String> = cred_store
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
s.credentials
|
s.credentials
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|vc| {
|
.filter(|vc| {
|
||||||
vc.credential_type.iter().any(|t| t == "FederationTrustCredential")
|
vc.credential_type
|
||||||
|
.iter()
|
||||||
|
.any(|t| t == "FederationTrustCredential")
|
||||||
&& !credentials::is_revoked(vc)
|
&& !credentials::is_revoked(vc)
|
||||||
})
|
})
|
||||||
.map(|vc| vc.credential_subject.id.clone())
|
.map(|vc| vc.credential_subject.id.clone())
|
||||||
@@ -223,7 +270,10 @@ impl RpcHandler {
|
|||||||
"trusted" => TrustLevel::Trusted,
|
"trusted" => TrustLevel::Trusted,
|
||||||
"observer" => TrustLevel::Observer,
|
"observer" => TrustLevel::Observer,
|
||||||
"untrusted" => TrustLevel::Untrusted,
|
"untrusted" => TrustLevel::Untrusted,
|
||||||
_ => anyhow::bail!("Invalid trust level: {} (expected trusted/observer/untrusted)", trust_str),
|
_ => anyhow::bail!(
|
||||||
|
"Invalid trust level: {} (expected trusted/observer/untrusted)",
|
||||||
|
trust_str
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
federation::set_trust_level(&self.config.data_dir, did, trust).await?;
|
federation::set_trust_level(&self.config.data_dir, did, trust).await?;
|
||||||
@@ -236,7 +286,9 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.sync-state — Manually trigger state sync with all federated peers.
|
/// federation.sync-state — Manually trigger state sync with all federated peers.
|
||||||
pub(in crate::api::rpc) async fn handle_federation_sync_state(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_federation_sync_state(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
|
|
||||||
if nodes.is_empty() {
|
if nodes.is_empty() {
|
||||||
@@ -263,12 +315,9 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let did_clone = local_did.clone();
|
let did_clone = local_did.clone();
|
||||||
match federation::sync_with_peer(
|
match federation::sync_with_peer(&self.config.data_dir, node, &did_clone, |bytes| {
|
||||||
&self.config.data_dir,
|
node_identity.sign(bytes)
|
||||||
node,
|
})
|
||||||
&did_clone,
|
|
||||||
|bytes| node_identity.sign(bytes),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(state) => {
|
Ok(state) => {
|
||||||
@@ -298,7 +347,9 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.get-state — Return this node's state snapshot (called by peers during sync).
|
/// federation.get-state — Return this node's state snapshot (called by peers during sync).
|
||||||
pub(in crate::api::rpc) async fn handle_federation_get_state(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_federation_get_state(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
|
||||||
// Build app statuses from package_data
|
// Build app statuses from package_data
|
||||||
@@ -315,8 +366,57 @@ impl RpcHandler {
|
|||||||
let tor_active = data.server_info.tor_address.is_some();
|
let tor_active = data.server_info.tor_address.is_some();
|
||||||
|
|
||||||
let server_name = data.server_info.name.clone().filter(|n| !n.is_empty());
|
let server_name = data.server_info.name.clone().filter(|n| !n.is_empty());
|
||||||
|
|
||||||
|
// Encode our local Nostr identity as bech32 npub so federated peers
|
||||||
|
// can display it under our name in the mesh UI without each peer
|
||||||
|
// having to know how to convert hex → bech32 themselves.
|
||||||
|
let nostr_npub =
|
||||||
|
tokio::fs::read_to_string(self.config.data_dir.join("identity/nostr_pubkey"))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.and_then(|hex| nostr_sdk::PublicKey::from_hex(&hex).ok())
|
||||||
|
.and_then(|pk| nostr_sdk::ToBech32::to_bech32(&pk).ok());
|
||||||
|
|
||||||
|
// Pass the current federated-peer list so the snapshot can include
|
||||||
|
// a `federated_peers` hint for transitive federation — receivers
|
||||||
|
// who trust us learn our Trusted peers and can route to them
|
||||||
|
// over FIPS without a separate invite round-trip.
|
||||||
|
let federated_peers = federation::load_nodes(&self.config.data_dir)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Our own FIPS npub, so pre-v1.4 federation pairs (whose
|
||||||
|
// invite codes didn't carry it) can learn it on the next sync.
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let own_fips_npub = crate::identity::fips_npub(&identity_dir)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.or_else(|| {
|
||||||
|
// Legacy/dev nodes without a seed-derived key fall back
|
||||||
|
// to the upstream daemon's public key on disk.
|
||||||
|
None
|
||||||
|
});
|
||||||
|
let own_fips_npub = match own_fips_npub {
|
||||||
|
Some(n) => Some(n),
|
||||||
|
None => crate::fips::service::read_upstream_npub().await.ok().flatten(),
|
||||||
|
};
|
||||||
|
|
||||||
let state = federation::build_local_state(
|
let state = federation::build_local_state(
|
||||||
apps, 0.0, 0, 0, 0, 0, 0, tor_active, server_name,
|
apps,
|
||||||
|
0.0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
tor_active,
|
||||||
|
server_name,
|
||||||
|
nostr_npub,
|
||||||
|
own_fips_npub,
|
||||||
|
&federated_peers,
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(serde_json::to_value(&state)?)
|
Ok(serde_json::to_value(&state)?)
|
||||||
@@ -341,11 +441,15 @@ impl RpcHandler {
|
|||||||
.get("pubkey")
|
.get("pubkey")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?;
|
||||||
|
// Optional, unsigned: peer's FIPS mesh npub. Carried for transport
|
||||||
|
// selection only; FIPS handshake re-authenticates the session.
|
||||||
|
let fips_npub = params
|
||||||
|
.get("fips_npub")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
|
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
|
||||||
let signature = params
|
let signature = params.get("signature").and_then(|v| v.as_str());
|
||||||
.get("signature")
|
|
||||||
.and_then(|v| v.as_str());
|
|
||||||
match signature {
|
match signature {
|
||||||
Some(sig) => {
|
Some(sig) => {
|
||||||
let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey);
|
let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey);
|
||||||
@@ -359,24 +463,32 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(peer_did = %did, "Rejected peer-joined: missing signature");
|
tracing::warn!(peer_did = %did, "Rejected peer-joined: missing signature");
|
||||||
anyhow::bail!("Missing signature — all federation peers must be cryptographically verified");
|
anyhow::bail!(
|
||||||
|
"Missing signature — all federation peers must be cryptographically verified"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
if let Some(existing) = nodes.iter().find(|n| n.did == did) {
|
if let Some(existing) = nodes.iter().find(|n| n.did == did) {
|
||||||
// If already known but missing onion/pubkey, update them
|
// If already known but missing onion/pubkey/fips_npub, update them
|
||||||
if existing.onion.is_empty() || existing.pubkey.is_empty() {
|
let needs_onion = existing.onion.is_empty();
|
||||||
|
let needs_pubkey = existing.pubkey.is_empty();
|
||||||
|
let needs_fips = existing.fips_npub.is_none() && fips_npub.is_some();
|
||||||
|
if needs_onion || needs_pubkey || needs_fips {
|
||||||
let mut updated = existing.clone();
|
let mut updated = existing.clone();
|
||||||
if existing.onion.is_empty() && !onion.is_empty() {
|
if needs_onion && !onion.is_empty() {
|
||||||
updated.onion = onion.to_string();
|
updated.onion = onion.to_string();
|
||||||
}
|
}
|
||||||
if existing.pubkey.is_empty() && !pubkey.is_empty() {
|
if needs_pubkey && !pubkey.is_empty() {
|
||||||
updated.pubkey = pubkey.to_string();
|
updated.pubkey = pubkey.to_string();
|
||||||
}
|
}
|
||||||
|
if needs_fips {
|
||||||
|
updated.fips_npub = fips_npub.clone();
|
||||||
|
}
|
||||||
updated.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
updated.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
||||||
federation::update_node(&self.config.data_dir, &updated).await?;
|
federation::update_node(&self.config.data_dir, &updated).await?;
|
||||||
info!(peer_did = %did, peer_onion = %onion, "Updated existing peer with missing onion/pubkey");
|
info!(peer_did = %did, peer_onion = %onion, "Updated existing peer with fresh identity fields");
|
||||||
}
|
}
|
||||||
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
||||||
}
|
}
|
||||||
@@ -390,11 +502,19 @@ impl RpcHandler {
|
|||||||
added_at: chrono::Utc::now().to_rfc3339(),
|
added_at: chrono::Utc::now().to_rfc3339(),
|
||||||
last_seen: None,
|
last_seen: None,
|
||||||
last_state: None,
|
last_state: None,
|
||||||
|
fips_npub,
|
||||||
|
last_transport: None,
|
||||||
|
last_transport_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
federation::add_node(&self.config.data_dir, node).await?;
|
federation::add_node(&self.config.data_dir, node).await?;
|
||||||
info!(peer_did = %did, "Peer joined our federation");
|
info!(peer_did = %did, "Peer joined our federation");
|
||||||
|
|
||||||
|
// Mirror into mesh state so the inbound peer is addressable from
|
||||||
|
// the chat UI without waiting for the next mesh restart.
|
||||||
|
self.register_federation_peer_in_mesh(pubkey, did, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(serde_json::json!({ "accepted": true }))
|
Ok(serde_json::json!({ "accepted": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +596,8 @@ impl RpcHandler {
|
|||||||
Some(node) => {
|
Some(node) => {
|
||||||
// Verify signature using the peer's KNOWN pubkey (H3 security fix)
|
// Verify signature using the peer's KNOWN pubkey (H3 security fix)
|
||||||
let sign_data = format!("address-changed:{}:{}", did, new_onion);
|
let sign_data = format!("address-changed:{}:{}", did, new_onion);
|
||||||
match identity::NodeIdentity::verify(&node.pubkey, sign_data.as_bytes(), signature) {
|
match identity::NodeIdentity::verify(&node.pubkey, sign_data.as_bytes(), signature)
|
||||||
|
{
|
||||||
Ok(true) => {}
|
Ok(true) => {}
|
||||||
_ => {
|
_ => {
|
||||||
tracing::warn!(did = %did, "Rejected address change: invalid signature");
|
tracing::warn!(did = %did, "Rejected address change: invalid signature");
|
||||||
@@ -538,14 +659,6 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
|
|
||||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
|
||||||
.context("Invalid Tor proxy")?;
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.proxy(proxy)
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.build()
|
|
||||||
.context("Failed to build HTTP client")?;
|
|
||||||
|
|
||||||
let mut notified = 0u32;
|
let mut notified = 0u32;
|
||||||
let mut failed = 0u32;
|
let mut failed = 0u32;
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
@@ -556,13 +669,6 @@ impl RpcHandler {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let host = if node.onion.ends_with(".onion") {
|
|
||||||
node.onion.clone()
|
|
||||||
} else {
|
|
||||||
format!("{}.onion", node.onion)
|
|
||||||
};
|
|
||||||
let url = format!("http://{}/rpc/v1", host);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"method": "federation.peer-did-changed",
|
"method": "federation.peer-did-changed",
|
||||||
"params": {
|
"params": {
|
||||||
@@ -574,23 +680,32 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
match client.post(&url).json(&body).send().await {
|
let req = crate::fips::dial::PeerRequest::new(
|
||||||
Ok(resp) if resp.status().is_success() => {
|
node.fips_npub.as_deref(),
|
||||||
|
&node.onion,
|
||||||
|
"/rpc/v1",
|
||||||
|
)
|
||||||
|
.service(crate::settings::transport::PeerService::Peers)
|
||||||
|
.timeout(std::time::Duration::from_secs(30));
|
||||||
|
|
||||||
|
match req.send_json(&body).await {
|
||||||
|
Ok((resp, transport)) if resp.status().is_success() => {
|
||||||
notified += 1;
|
notified += 1;
|
||||||
results.push(serde_json::json!({
|
results.push(serde_json::json!({
|
||||||
"did": node.did,
|
"did": node.did,
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
|
"transport": transport.to_string(),
|
||||||
}));
|
}));
|
||||||
info!(peer_did = %node.did, "Notified peer of DID rotation");
|
info!(peer_did = %node.did, transport = %transport, "Notified peer of DID rotation");
|
||||||
}
|
}
|
||||||
Ok(resp) => {
|
Ok((resp, transport)) => {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
results.push(serde_json::json!({
|
results.push(serde_json::json!({
|
||||||
"did": node.did,
|
"did": node.did,
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": format!("Peer returned {}", resp.status()),
|
"error": format!("Peer returned {} (via {})", resp.status(), transport),
|
||||||
}));
|
}));
|
||||||
warn!(peer_did = %node.did, status = %resp.status(), "Peer rejected DID rotation notification");
|
warn!(peer_did = %node.did, status = %resp.status(), transport = %transport, "Peer rejected DID rotation notification");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
@@ -667,9 +782,7 @@ impl RpcHandler {
|
|||||||
// Verify the rotation proof: the old key signed
|
// Verify the rotation proof: the old key signed
|
||||||
// "did-rotate:{old_did}:{new_did}:{timestamp}" and the sender
|
// "did-rotate:{old_did}:{new_did}:{timestamp}" and the sender
|
||||||
// forwards both the signature and the full proof_message.
|
// forwards both the signature and the full proof_message.
|
||||||
let proof_message = params
|
let proof_message = params.get("proof_message").and_then(|v| v.as_str());
|
||||||
.get("proof_message")
|
|
||||||
.and_then(|v| v.as_str());
|
|
||||||
|
|
||||||
let verified = if let Some(msg) = proof_message {
|
let verified = if let Some(msg) = proof_message {
|
||||||
// Verify the proof_message starts with the expected prefix
|
// Verify the proof_message starts with the expected prefix
|
||||||
@@ -687,7 +800,11 @@ impl RpcHandler {
|
|||||||
// Fallback: verify without timestamp (backwards-compatible)
|
// Fallback: verify without timestamp (backwards-compatible)
|
||||||
let fallback_msg = format!("did-rotate:{}:{}", old_did, new_did);
|
let fallback_msg = format!("did-rotate:{}:{}", old_did, new_did);
|
||||||
matches!(
|
matches!(
|
||||||
identity::NodeIdentity::verify(&node.pubkey, fallback_msg.as_bytes(), signature),
|
identity::NodeIdentity::verify(
|
||||||
|
&node.pubkey,
|
||||||
|
fallback_msg.as_bytes(),
|
||||||
|
signature
|
||||||
|
),
|
||||||
Ok(true)
|
Ok(true)
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -698,11 +815,31 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let old_pubkey = node.pubkey.clone();
|
let old_pubkey = node.pubkey.clone();
|
||||||
|
let rotated_name = node.name.clone();
|
||||||
node.did = new_did.to_string();
|
node.did = new_did.to_string();
|
||||||
node.pubkey = new_pubkey.to_string();
|
node.pubkey = new_pubkey.to_string();
|
||||||
node.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
node.last_seen = Some(chrono::Utc::now().to_rfc3339());
|
||||||
federation::save_nodes(&self.config.data_dir, &nodes).await?;
|
federation::save_nodes(&self.config.data_dir, &nodes).await?;
|
||||||
|
|
||||||
|
// Drop the stale mesh peer entry keyed by the old pubkey's
|
||||||
|
// synthetic contact_id, then upsert a fresh one under the
|
||||||
|
// new pubkey so the chat UI doesn't show two rows post-rotation.
|
||||||
|
{
|
||||||
|
let svc = self.mesh_service.read().await;
|
||||||
|
if let Some(svc) = svc.as_ref() {
|
||||||
|
let state = svc.shared_state();
|
||||||
|
let stale_id = mesh::federation_peer_contact_id(&old_pubkey);
|
||||||
|
state.peers.write().await.remove(&stale_id);
|
||||||
|
mesh::upsert_federation_peer(
|
||||||
|
&state,
|
||||||
|
new_pubkey,
|
||||||
|
new_did,
|
||||||
|
rotated_name.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
old_did = %old_did,
|
old_did = %old_did,
|
||||||
new_did = %new_did,
|
new_did = %new_did,
|
||||||
@@ -725,4 +862,213 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// federation.list-pending-requests — return the inbox of inbound peer
|
||||||
|
/// requests received over Nostr (and our outbound `Sent` rows). Each
|
||||||
|
/// row carries a stable `id` the FE refers to when calling
|
||||||
|
/// `federation.approve-request` / `federation.reject-request`.
|
||||||
|
pub(in crate::api::rpc) async fn handle_federation_list_pending_requests(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let requests = pending::load_pending(&self.config.data_dir).await?;
|
||||||
|
Ok(serde_json::json!({ "requests": requests }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// federation.approve-request — turn a pending peer request into a
|
||||||
|
/// federation invite, ship it back via NIP-44, and add the requester
|
||||||
|
/// to our federation list as `Observer` (NOT Trusted — the user must
|
||||||
|
/// explicitly promote afterwards via `federation.set-trust`).
|
||||||
|
///
|
||||||
|
/// This is the *only* code path that ever causes our onion to leave
|
||||||
|
/// this box over Nostr, and the onion only travels inside a NIP-44
|
||||||
|
/// ciphertext addressed to the requester's specific nostr pubkey.
|
||||||
|
pub(in crate::api::rpc) async fn handle_federation_approve_request(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
|
||||||
|
|
||||||
|
let req = pending::find_by_id(&self.config.data_dir, id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?;
|
||||||
|
if !matches!(req.state, pending::PendingState::Pending) || req.outbound {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Pending request is not awaiting approval (state={:?})",
|
||||||
|
req.state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
|
let local_onion = data
|
||||||
|
.server_info
|
||||||
|
.tor_address
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Tor address not available"))?;
|
||||||
|
let local_pubkey = data.server_info.pubkey.clone();
|
||||||
|
|
||||||
|
// Generate a one-shot federation invite. The code embeds OUR onion
|
||||||
|
// and OUR pubkey, but it leaves this box only inside the NIP-44
|
||||||
|
// ciphertext below.
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let local_fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None);
|
||||||
|
let invite_code = federation::create_invite(
|
||||||
|
&self.config.data_dir,
|
||||||
|
&local_did,
|
||||||
|
&local_onion,
|
||||||
|
&local_pubkey,
|
||||||
|
local_fips_npub.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Pre-add the requester to OUR federation list as Observer so that
|
||||||
|
// when their `federation.peer-joined` callback arrives over Tor we
|
||||||
|
// already trust their pubkey enough to accept the join. Their DID
|
||||||
|
// and pubkey come from the request — we'll cross-check the pubkey
|
||||||
|
// against the eventual peer-joined signature in the existing
|
||||||
|
// verification path (handlers.rs line ~365).
|
||||||
|
if !req.from_did.is_empty() {
|
||||||
|
// We don't know the requester's onion or ed25519 pubkey yet —
|
||||||
|
// they'll send those in the federation.peer-joined callback
|
||||||
|
// after they apply our invite. Until then we can't add a real
|
||||||
|
// FederatedNode entry. We just store the pending row as
|
||||||
|
// Approved so the UI shows progress, and trust the existing
|
||||||
|
// peer-joined handler to admit them as Observer when they call.
|
||||||
|
//
|
||||||
|
// Caveat: peer-joined currently hardcodes TrustLevel::Trusted.
|
||||||
|
// We override that below by demoting on success.
|
||||||
|
debug!(
|
||||||
|
requester_did = %req.from_did,
|
||||||
|
"Approval pending — waiting for federation.peer-joined callback over Tor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt + send the invite over NIP-44 to the requester.
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
nostr_handshake::send_peer_invite(
|
||||||
|
&identity_dir,
|
||||||
|
&req.from_nostr_pubkey,
|
||||||
|
&invite_code,
|
||||||
|
&self.config.nostr_relays,
|
||||||
|
self.config.nostr_tor_proxy.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
pending::set_state(&self.config.data_dir, id, pending::PendingState::Approved).await?;
|
||||||
|
info!(
|
||||||
|
id = %id,
|
||||||
|
from = %req.from_nostr_pubkey,
|
||||||
|
"Approved peer request and shipped invite over NIP-44"
|
||||||
|
);
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"approved": true,
|
||||||
|
"id": id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// federation.reject-request — drop a pending request and, if requested,
|
||||||
|
/// ship a NIP-44 `PeerReject` to the sender so their UI can update.
|
||||||
|
pub(in crate::api::rpc) async fn handle_federation_reject_request(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
|
||||||
|
let reason = params.get("reason").and_then(|v| v.as_str());
|
||||||
|
let notify = params
|
||||||
|
.get("notify")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let req = pending::find_by_id(&self.config.data_dir, id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?;
|
||||||
|
if !matches!(req.state, pending::PendingState::Pending) || req.outbound {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Pending request is not awaiting approval (state={:?})",
|
||||||
|
req.state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if notify {
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let _ = nostr_handshake::send_peer_reject(
|
||||||
|
&identity_dir,
|
||||||
|
&req.from_nostr_pubkey,
|
||||||
|
reason,
|
||||||
|
&self.config.nostr_relays,
|
||||||
|
self.config.nostr_tor_proxy.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending::set_state(&self.config.data_dir, id, pending::PendingState::Rejected).await?;
|
||||||
|
info!(id = %id, from = %req.from_nostr_pubkey, "Rejected peer request");
|
||||||
|
Ok(serde_json::json!({ "rejected": true, "id": id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// federation.cancel-request — withdraw an outbound peer request we
|
||||||
|
/// sent but haven't heard back on. The local row is deleted and,
|
||||||
|
/// unless `notify=false`, a PeerCancel nostr DM is sent so the
|
||||||
|
/// target drops their inbound pending row.
|
||||||
|
pub(in crate::api::rpc) async fn handle_federation_cancel_request(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
|
||||||
|
let reason = params.get("reason").and_then(|v| v.as_str());
|
||||||
|
// Default TRUE — cancelling without notifying is a footgun (the
|
||||||
|
// recipient's UI keeps showing an unanswerable request).
|
||||||
|
let notify = params
|
||||||
|
.get("notify")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
let req = pending::find_by_id(&self.config.data_dir, id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?;
|
||||||
|
if !req.outbound || !matches!(req.state, pending::PendingState::Sent) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Can only cancel outbound requests in Sent state (outbound={}, state={:?})",
|
||||||
|
req.outbound,
|
||||||
|
req.state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if notify {
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
// Best-effort: log but don't fail the cancel if the nostr
|
||||||
|
// relay is unreachable — the local row is still dropped.
|
||||||
|
if let Err(e) = nostr_handshake::send_peer_cancel(
|
||||||
|
&identity_dir,
|
||||||
|
&req.from_nostr_pubkey,
|
||||||
|
reason,
|
||||||
|
&self.config.nostr_relays,
|
||||||
|
self.config.nostr_tor_proxy.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
id = %id,
|
||||||
|
error = %e,
|
||||||
|
"peer-cancel DM failed; local row dropped anyway"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pending::delete(&self.config.data_dir, id).await?;
|
||||||
|
info!(id = %id, to = %req.from_nostr_pubkey, notified = notify, "Cancelled outbound peer request");
|
||||||
|
Ok(serde_json::json!({ "cancelled": true, "id": id, "notified": notify }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,3 @@ pub(super) fn validate_did(did: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//! RPC handlers for the FIPS mesh transport subsystem.
|
||||||
|
//!
|
||||||
|
//! Surface is deliberately thin: a read-only `fips.status`, a user-gated
|
||||||
|
//! `fips.check-update`, a stubbed `fips.apply-update`, and a
|
||||||
|
//! `fips.install` that (re-)materialises the daemon config + key and
|
||||||
|
//! activates the service. All writes go through `sudo` helpers in
|
||||||
|
//! `crate::fips`.
|
||||||
|
|
||||||
|
use super::RpcHandler;
|
||||||
|
use crate::fips;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
pub(super) async fn handle_fips_status(&self) -> Result<serde_json::Value> {
|
||||||
|
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||||
|
let status = fips::FipsStatus::query(&identity_dir).await;
|
||||||
|
Ok(serde_json::to_value(status)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_fips_check_update(&self) -> Result<serde_json::Value> {
|
||||||
|
let check = fips::update::check().await?;
|
||||||
|
Ok(serde_json::to_value(check)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_fips_apply_update(&self) -> Result<serde_json::Value> {
|
||||||
|
fips::update::apply().await?;
|
||||||
|
Ok(serde_json::json!({ "applied": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install config + key into /etc/fips and activate the service.
|
||||||
|
/// Intended to be called:
|
||||||
|
/// - once by the seed-onboarding flow, right after the FIPS key
|
||||||
|
/// is written to /data/identity/fips_key, and
|
||||||
|
/// - on user demand from the dashboard if something drifted.
|
||||||
|
pub(super) async fn handle_fips_install(&self) -> Result<serde_json::Value> {
|
||||||
|
let identity_dir = fips::identity_dir_from(&self.config.data_dir);
|
||||||
|
fips::config::install(&identity_dir).await?;
|
||||||
|
fips::service::activate(fips::SERVICE_UNIT).await?;
|
||||||
|
let status = fips::FipsStatus::query(&identity_dir).await;
|
||||||
|
Ok(serde_json::to_value(status)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_fips_restart(&self) -> Result<serde_json::Value> {
|
||||||
|
fips::service::restart(fips::SERVICE_UNIT).await?;
|
||||||
|
Ok(serde_json::json!({ "restarted": true }))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,117 @@
|
|||||||
|
//! Nostr peer-discovery RPCs.
|
||||||
|
//!
|
||||||
|
//! `handshake.discover` — browse other nodes' presence events on configured
|
||||||
|
//! relays. Returns DID + nostr pubkey only; no onion is ever exposed.
|
||||||
|
//!
|
||||||
|
//! `handshake.connect` — send a `PeerRequest` to a discovered node's nostr
|
||||||
|
//! pubkey. Records the outbound request locally so the user can see what
|
||||||
|
//! they've sent. Does NOT include our onion address on the wire.
|
||||||
|
//!
|
||||||
|
//! `handshake.poll` — fetch new NIP-44 DMs addressed to our nostr pubkey
|
||||||
|
//! and dispatch them: inbound `PeerRequest` is queued in
|
||||||
|
//! `federation::pending` for manual approval; inbound `PeerInvite` is
|
||||||
|
//! applied via the existing federation invite-acceptance flow (which
|
||||||
|
//! adds the new peer as `Observer` — see federation.rs); inbound
|
||||||
|
//! `PeerReject` is recorded against the matching outbound row.
|
||||||
|
|
||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use crate::{nostr_handshake, peers};
|
use crate::federation::pending::{self, PendingPeerRequest, PendingState};
|
||||||
use anyhow::Result;
|
use crate::nostr_handshake::{self, HandshakeMessage};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
use nostr_sdk::FromBech32;
|
use nostr_sdk::FromBech32;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json";
|
||||||
|
|
||||||
|
/// Runtime override for `Config::nostr_discovery_enabled`. The OS-level
|
||||||
|
/// config file is read once at boot and is OFF by default; this state file
|
||||||
|
/// lets the user flip discoverability on/off at runtime via the Federation
|
||||||
|
/// UI without restarting the service. Both the boot-time presence publish
|
||||||
|
/// and the `handshake.poll` handler check this file before doing anything.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
struct NostrDiscoveryState {
|
||||||
|
#[serde(default)]
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_discovery_state(data_dir: &std::path::Path) -> NostrDiscoveryState {
|
||||||
|
let path = data_dir.join(NOSTR_STATE_FILE);
|
||||||
|
match tokio::fs::read_to_string(&path).await {
|
||||||
|
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||||
|
Err(_) => NostrDiscoveryState::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_discovery_state(
|
||||||
|
data_dir: &std::path::Path,
|
||||||
|
state: &NostrDiscoveryState,
|
||||||
|
) -> Result<()> {
|
||||||
|
let path = data_dir.join(NOSTR_STATE_FILE);
|
||||||
|
let content = serde_json::to_string_pretty(state).context("serialize discovery state")?;
|
||||||
|
tokio::fs::write(&path, content)
|
||||||
|
.await
|
||||||
|
.context("write discovery state")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// Discover nodes (presence-only — returns Nostr pubkeys + DIDs, no onion addresses).
|
/// Read the current runtime discoverability flag.
|
||||||
|
pub(super) async fn handle_nostr_discovery_status(&self) -> Result<serde_json::Value> {
|
||||||
|
let state = load_discovery_state(&self.config.data_dir).await;
|
||||||
|
Ok(serde_json::json!({ "enabled": state.enabled }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the runtime discoverability flag. If turning ON, publish presence
|
||||||
|
/// once immediately so the user gets visible feedback that the relays
|
||||||
|
/// have been notified. If turning OFF, do NOT actively scrub the relays
|
||||||
|
/// here — `nostr_handshake::publish_presence` is replaceable, so the
|
||||||
|
/// next reboot's startup pass plus the existing legacy revocation in
|
||||||
|
/// `nostr_discovery::revoke_legacy_advertisements` are sufficient. A
|
||||||
|
/// future Layer 3 task adds an explicit "tombstone" publish if needed.
|
||||||
|
pub(super) async fn handle_nostr_set_discovery(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let enabled = params
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
|
||||||
|
|
||||||
|
save_discovery_state(&self.config.data_dir, &NostrDiscoveryState { enabled }).await?;
|
||||||
|
|
||||||
|
if enabled && !self.config.nostr_relays.is_empty() {
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let version = data.server_info.version.clone();
|
||||||
|
let relays = self.config.nostr_relays.clone();
|
||||||
|
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = nostr_handshake::publish_presence(
|
||||||
|
&identity_dir,
|
||||||
|
&did,
|
||||||
|
&version,
|
||||||
|
&relays,
|
||||||
|
tor_proxy.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("Initial presence publish failed: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "enabled": enabled }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover discoverable nodes via Nostr presence events.
|
||||||
|
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
|
||||||
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
|
||||||
|
// Discoverability gate: respect the runtime toggle. We allow `discover`
|
||||||
|
// to query relays as long as the user is actively browsing — they're
|
||||||
|
// an anonymous observer of presence events, not publishing anything.
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
let nodes = nostr_handshake::discover_nodes(
|
let nodes = nostr_handshake::discover_nodes(
|
||||||
&identity_dir,
|
&identity_dir,
|
||||||
@@ -16,59 +122,90 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "nodes": nodes }))
|
Ok(serde_json::json!({ "nodes": nodes }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send encrypted connection request to a peer's Nostr pubkey.
|
/// Send a `PeerRequest` to a discovered node. Onion is never sent.
|
||||||
/// Params: { recipient_nostr_pubkey }
|
/// Params: `{ recipient_nostr_pubkey, message?, name? }`.
|
||||||
pub(super) async fn handle_handshake_connect(
|
pub(super) async fn handle_handshake_connect(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
// Accept either hex pubkey or npub1... bech32 format
|
|
||||||
let recipient_raw = params
|
let recipient_raw = params
|
||||||
.get("recipient_nostr_pubkey")
|
.get("recipient_nostr_pubkey")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing recipient_nostr_pubkey"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing recipient_nostr_pubkey"))?;
|
||||||
let recipient = if recipient_raw.starts_with("npub1") {
|
let recipient_hex = if recipient_raw.starts_with("npub1") {
|
||||||
nostr_sdk::PublicKey::from_bech32(recipient_raw)
|
nostr_sdk::PublicKey::from_bech32(recipient_raw)
|
||||||
.map_err(|e| anyhow::anyhow!("Invalid npub: {}", e))?
|
.map_err(|e| anyhow::anyhow!("Invalid npub: {}", e))?
|
||||||
.to_hex()
|
.to_hex()
|
||||||
} else {
|
} else {
|
||||||
recipient_raw.to_string()
|
recipient_raw.to_string()
|
||||||
};
|
};
|
||||||
let recipient = recipient.as_str();
|
let recipient_npub = nostr_sdk::PublicKey::from_hex(&recipient_hex)
|
||||||
|
.ok()
|
||||||
|
.and_then(|pk| nostr_sdk::ToBech32::to_bech32(&pk).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let message = params.get("message").and_then(|v| v.as_str());
|
||||||
|
let optional_name = params.get("name").and_then(|v| v.as_str());
|
||||||
|
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let our_onion = data
|
let our_did =
|
||||||
.server_info
|
crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||||
.tor_address
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("No Tor address available — is Tor running?"))?;
|
|
||||||
let our_node_pubkey = &data.server_info.pubkey;
|
|
||||||
let our_did = crate::identity::did_key_from_pubkey_hex(our_node_pubkey)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let our_version = &data.server_info.version;
|
let our_version = &data.server_info.version;
|
||||||
let our_name = data.server_info.name.as_deref();
|
let our_name = optional_name.or(data.server_info.name.as_deref());
|
||||||
|
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
nostr_handshake::send_connect_request(
|
nostr_handshake::send_peer_request(
|
||||||
&identity_dir,
|
&identity_dir,
|
||||||
recipient,
|
&recipient_hex,
|
||||||
our_onion,
|
|
||||||
our_node_pubkey,
|
|
||||||
&our_did,
|
&our_did,
|
||||||
our_version,
|
our_version,
|
||||||
our_name,
|
our_name,
|
||||||
|
message,
|
||||||
&self.config.nostr_relays,
|
&self.config.nostr_relays,
|
||||||
self.config.nostr_tor_proxy.as_deref(),
|
self.config.nostr_tor_proxy.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(serde_json::json!({ "ok": true, "sent_to": recipient }))
|
// Record the outbound request so the user can see "Sent" status
|
||||||
|
// and so the eventual NIP-44 PeerInvite reply can be matched.
|
||||||
|
let row = pending::insert_outbound(
|
||||||
|
&self.config.data_dir,
|
||||||
|
recipient_hex.clone(),
|
||||||
|
recipient_npub,
|
||||||
|
String::new(), // remote DID unknown until they reply
|
||||||
|
None,
|
||||||
|
message.map(String::from),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"sent_to": recipient_hex,
|
||||||
|
"id": row.id,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll for incoming encrypted handshake messages (connect requests/responses).
|
/// Poll relays for inbound NIP-44 handshake messages, then dispatch:
|
||||||
/// Auto-adds peers and auto-responds to requests.
|
/// - `PeerRequest` → queue in `federation::pending` for approval
|
||||||
|
/// - `PeerInvite` → apply via federation invite flow (adds as Observer)
|
||||||
|
/// - `PeerReject` → mark matching outbound row as `Rejected`
|
||||||
|
///
|
||||||
|
/// Never auto-adds peers, never auto-responds, never sends our onion.
|
||||||
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
|
||||||
|
// Runtime gate: if the user hasn't enabled discoverability, don't
|
||||||
|
// touch the relays. The poll endpoint is a hard no-op until they
|
||||||
|
// explicitly opt in via the Federation UI toggle.
|
||||||
|
let state = load_discovery_state(&self.config.data_dir).await;
|
||||||
|
if !state.enabled {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"polled": 0,
|
||||||
|
"new_requests": Vec::<PendingPeerRequest>::new(),
|
||||||
|
"applied_invites": Vec::<String>::new(),
|
||||||
|
"rejected_outbound": Vec::<String>::new(),
|
||||||
|
"skipped": Vec::<String>::new(),
|
||||||
|
"discovery_disabled": true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
let handshakes = nostr_handshake::poll_handshakes(
|
let handshakes = nostr_handshake::poll_handshakes(
|
||||||
&identity_dir,
|
&identity_dir,
|
||||||
@@ -78,72 +215,175 @@ impl RpcHandler {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let mut new_requests: Vec<PendingPeerRequest> = Vec::new();
|
||||||
let mut added_peers = Vec::new();
|
let mut applied_invites: Vec<String> = Vec::new();
|
||||||
|
let mut rejected_outbound: Vec<String> = Vec::new();
|
||||||
|
let mut cancelled_inbound: Vec<String> = Vec::new();
|
||||||
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for hs in &handshakes {
|
for hs in &handshakes {
|
||||||
let (onion, node_pubkey, name) = match &hs.message {
|
match &hs.message {
|
||||||
nostr_handshake::HandshakeMessage::ConnectRequest {
|
HandshakeMessage::PeerRequest {
|
||||||
onion,
|
from_did,
|
||||||
node_pubkey,
|
version: _,
|
||||||
name,
|
name,
|
||||||
..
|
message,
|
||||||
} => {
|
} => {
|
||||||
// Auto-respond with our details
|
match pending::insert_inbound(
|
||||||
if let Some(our_onion) = data.server_info.tor_address.as_deref() {
|
&self.config.data_dir,
|
||||||
let our_did = crate::identity::did_key_from_pubkey_hex(
|
hs.from_nostr_pubkey.clone(),
|
||||||
&data.server_info.pubkey,
|
hs.from_nostr_npub.clone(),
|
||||||
)
|
from_did.clone(),
|
||||||
.unwrap_or_default();
|
name.clone(),
|
||||||
let _ = nostr_handshake::send_connect_response(
|
message.clone(),
|
||||||
&identity_dir,
|
)
|
||||||
&hs.from_nostr_pubkey,
|
.await
|
||||||
our_onion,
|
{
|
||||||
&data.server_info.pubkey,
|
Ok(Some(row)) => new_requests.push(row),
|
||||||
&our_did,
|
Ok(None) => skipped.push(hs.from_nostr_pubkey.clone()),
|
||||||
&data.server_info.version,
|
Err(e) => {
|
||||||
data.server_info.name.as_deref(),
|
tracing::warn!(
|
||||||
&self.config.nostr_relays,
|
from = %hs.from_nostr_pubkey,
|
||||||
self.config.nostr_tor_proxy.as_deref(),
|
error = %e,
|
||||||
)
|
"Dropped peer request (rate limit or storage error)"
|
||||||
.await;
|
);
|
||||||
|
skipped.push(hs.from_nostr_pubkey.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
(onion.clone(), node_pubkey.clone(), name.clone())
|
|
||||||
}
|
}
|
||||||
nostr_handshake::HandshakeMessage::ConnectResponse {
|
HandshakeMessage::PeerInvite { invite_code } => {
|
||||||
onion,
|
// Match against an outbound Sent request from this nostr
|
||||||
node_pubkey,
|
// pubkey. If we never sent them anything, ignore — we
|
||||||
name,
|
// don't accept unsolicited invites over Nostr.
|
||||||
..
|
let pendings = pending::load_pending(&self.config.data_dir).await?;
|
||||||
} => (onion.clone(), node_pubkey.clone(), name.clone()),
|
let matching = pendings.iter().find(|r| {
|
||||||
};
|
r.outbound
|
||||||
|
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
|
||||||
|
&& matches!(r.state, PendingState::Sent)
|
||||||
|
});
|
||||||
|
let Some(row) = matching else {
|
||||||
|
tracing::warn!(
|
||||||
|
from = %hs.from_nostr_pubkey,
|
||||||
|
"Ignoring unsolicited PeerInvite — no matching Sent request"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let row_id = row.id.clone();
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let local_did =
|
||||||
|
crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||||
|
let local_pubkey = data.server_info.pubkey.clone();
|
||||||
|
|
||||||
// Auto-add as peer
|
let identity_dir2 = self.config.data_dir.join("identity");
|
||||||
let peer = peers::KnownPeer {
|
let node_identity =
|
||||||
onion,
|
crate::identity::NodeIdentity::load_or_create(&identity_dir2).await?;
|
||||||
pubkey: node_pubkey.clone(),
|
let local_fips_npub = crate::identity::fips_npub(&identity_dir2)
|
||||||
name,
|
.await
|
||||||
added_at: Some(chrono::Utc::now().to_rfc3339()),
|
.unwrap_or(None);
|
||||||
};
|
match crate::federation::accept_invite(
|
||||||
let _ = peers::add_peer(&self.config.data_dir, peer).await;
|
&self.config.data_dir,
|
||||||
added_peers.push(node_pubkey);
|
invite_code,
|
||||||
|
&local_did,
|
||||||
|
&local_onion,
|
||||||
|
&local_pubkey,
|
||||||
|
local_fips_npub.as_deref(),
|
||||||
|
|bytes| node_identity.sign(bytes),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(node) => {
|
||||||
|
// Approved-by-them: their box already has us as Observer
|
||||||
|
// (their approval handler added us under that trust level
|
||||||
|
// before sending the invite). Demote our local entry to
|
||||||
|
// Observer too — accept_invite hardcodes Trusted, but the
|
||||||
|
// discovery flow should never auto-trust.
|
||||||
|
let _ = crate::federation::set_trust_level(
|
||||||
|
&self.config.data_dir,
|
||||||
|
&node.did,
|
||||||
|
crate::federation::TrustLevel::Observer,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Mirror into the mesh peer table immediately so the
|
||||||
|
// chat UI can address the new peer without waiting
|
||||||
|
// for the next mesh restart.
|
||||||
|
let svc = self.mesh_service.read().await;
|
||||||
|
if let Some(svc) = svc.as_ref() {
|
||||||
|
crate::mesh::upsert_federation_peer(
|
||||||
|
&svc.shared_state(),
|
||||||
|
&node.pubkey,
|
||||||
|
&node.did,
|
||||||
|
node.name.as_deref(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending::set_state(
|
||||||
|
&self.config.data_dir,
|
||||||
|
&row_id,
|
||||||
|
PendingState::Approved,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
applied_invites.push(node.did);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
from = %hs.from_nostr_pubkey,
|
||||||
|
error = %e,
|
||||||
|
"Failed to apply PeerInvite"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HandshakeMessage::PeerReject { reason } => {
|
||||||
|
let pendings = pending::load_pending(&self.config.data_dir).await?;
|
||||||
|
if let Some(row) = pendings.iter().find(|r| {
|
||||||
|
r.outbound
|
||||||
|
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
|
||||||
|
&& matches!(r.state, PendingState::Sent)
|
||||||
|
}) {
|
||||||
|
let row_id = row.id.clone();
|
||||||
|
pending::set_state(&self.config.data_dir, &row_id, PendingState::Rejected)
|
||||||
|
.await?;
|
||||||
|
rejected_outbound.push(row_id);
|
||||||
|
tracing::info!(
|
||||||
|
from = %hs.from_nostr_pubkey,
|
||||||
|
reason = ?reason,
|
||||||
|
"Outbound peer request rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HandshakeMessage::PeerCancel { reason } => {
|
||||||
|
// Peer withdrew their PeerRequest — drop our matching
|
||||||
|
// inbound pending row so it disappears from the UI.
|
||||||
|
let pendings = pending::load_pending(&self.config.data_dir).await?;
|
||||||
|
if let Some(row) = pendings.iter().find(|r| {
|
||||||
|
!r.outbound
|
||||||
|
&& r.from_nostr_pubkey == hs.from_nostr_pubkey
|
||||||
|
&& matches!(r.state, PendingState::Pending)
|
||||||
|
}) {
|
||||||
|
let row_id = row.id.clone();
|
||||||
|
pending::delete(&self.config.data_dir, &row_id).await?;
|
||||||
|
cancelled_inbound.push(row_id);
|
||||||
|
tracing::info!(
|
||||||
|
from = %hs.from_nostr_pubkey,
|
||||||
|
reason = ?reason,
|
||||||
|
"Inbound peer request cancelled by sender"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let serialized: Vec<serde_json::Value> = handshakes
|
|
||||||
.iter()
|
|
||||||
.map(|hs| {
|
|
||||||
serde_json::json!({
|
|
||||||
"from_nostr_pubkey": hs.from_nostr_pubkey,
|
|
||||||
"from_nostr_npub": hs.from_nostr_npub,
|
|
||||||
"message": hs.message,
|
|
||||||
"timestamp": hs.timestamp,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"handshakes": serialized,
|
"polled": handshakes.len(),
|
||||||
"added_peers": added_peers,
|
"new_requests": new_requests,
|
||||||
|
"applied_invites": applied_invites,
|
||||||
|
"rejected_outbound": rejected_outbound,
|
||||||
|
"cancelled_inbound": cancelled_inbound,
|
||||||
|
"skipped": skipped,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,7 +246,9 @@ impl RpcHandler {
|
|||||||
.as_array()
|
.as_array()
|
||||||
.ok_or_else(|| anyhow::anyhow!("DID Document missing '@context' array"))?;
|
.ok_or_else(|| anyhow::anyhow!("DID Document missing '@context' array"))?;
|
||||||
|
|
||||||
let has_did_context = context.iter().any(|c| c.as_str() == Some("https://www.w3.org/ns/did/v1"));
|
let has_did_context = context
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.as_str() == Some("https://www.w3.org/ns/did/v1"));
|
||||||
if !has_did_context {
|
if !has_did_context {
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"valid": false,
|
"valid": false,
|
||||||
@@ -272,12 +274,14 @@ impl RpcHandler {
|
|||||||
match crate::identity::pubkey_bytes_from_did_key(did) {
|
match crate::identity::pubkey_bytes_from_did_key(did) {
|
||||||
Ok(pubkey_bytes) => {
|
Ok(pubkey_bytes) => {
|
||||||
// Check that at least one verification method has matching key
|
// Check that at least one verification method has matching key
|
||||||
let pubkey_multibase = format!("z{}", bs58::encode(&pubkey_bytes).into_string());
|
let pubkey_multibase =
|
||||||
let has_matching_key = verification_methods.iter().any(|vm| {
|
format!("z{}", bs58::encode(&pubkey_bytes).into_string());
|
||||||
vm["publicKeyMultibase"].as_str() == Some(&pubkey_multibase)
|
let has_matching_key = verification_methods
|
||||||
});
|
.iter()
|
||||||
|
.any(|vm| vm["publicKeyMultibase"].as_str() == Some(&pubkey_multibase));
|
||||||
if !has_matching_key {
|
if !has_matching_key {
|
||||||
errors.push("No verificationMethod matches the DID's public key".to_string());
|
errors
|
||||||
|
.push("No verificationMethod matches the DID's public key".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -287,7 +291,10 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check authentication is present
|
// Check authentication is present
|
||||||
if document["authentication"].as_array().map_or(true, |a| a.is_empty()) {
|
if document["authentication"]
|
||||||
|
.as_array()
|
||||||
|
.is_none_or(|a| a.is_empty())
|
||||||
|
{
|
||||||
errors.push("Missing or empty 'authentication' field".to_string());
|
errors.push("Missing or empty 'authentication' field".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,15 +350,20 @@ impl RpcHandler {
|
|||||||
id.to_string()
|
id.to_string()
|
||||||
} else {
|
} else {
|
||||||
// Prefer an identity with a Nostr key
|
// Prefer an identity with a Nostr key
|
||||||
records.iter()
|
records
|
||||||
|
.iter()
|
||||||
.find(|r| r.nostr_pubkey.is_some())
|
.find(|r| r.nostr_pubkey.is_some())
|
||||||
.map(|r| r.id.clone())
|
.map(|r| r.id.clone())
|
||||||
.ok_or_else(|| anyhow::anyhow!("No identity with Nostr key found"))?
|
.ok_or_else(|| anyhow::anyhow!("No identity with Nostr key found"))?
|
||||||
};
|
};
|
||||||
|
|
||||||
let identity = records.iter().find(|r| r.id == id)
|
let identity = records
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.id == id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Identity not found: {}", id))?;
|
.ok_or_else(|| anyhow::anyhow!("Identity not found: {}", id))?;
|
||||||
let pubkey_hex = identity.nostr_pubkey.clone()
|
let pubkey_hex = identity
|
||||||
|
.nostr_pubkey
|
||||||
|
.clone()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Identity has no Nostr key"))?;
|
.ok_or_else(|| anyhow::anyhow!("Identity has no Nostr key"))?;
|
||||||
|
|
||||||
if let Some(event_hash) = params.get("event_hash").and_then(|v| v.as_str()) {
|
if let Some(event_hash) = params.get("event_hash").and_then(|v| v.as_str()) {
|
||||||
@@ -361,22 +373,32 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Full event signing: compute NIP-01 event hash
|
// Full event signing: compute NIP-01 event hash
|
||||||
let event = params.get("event")
|
let event = params
|
||||||
|
.get("event")
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'event' or 'event_hash' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'event' or 'event_hash' parameter"))?;
|
||||||
|
|
||||||
let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(1);
|
let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||||
let content = event.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
let content = event.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let created_at = event.get("created_at").and_then(|v| v.as_u64())
|
let created_at = event
|
||||||
.unwrap_or_else(|| std::time::SystemTime::now()
|
.get("created_at")
|
||||||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs());
|
.and_then(|v| v.as_u64())
|
||||||
let tags = event.get("tags").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.unwrap_or_else(|| {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
});
|
||||||
|
let tags = event
|
||||||
|
.get("tags")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!([]));
|
||||||
|
|
||||||
// NIP-01 serialization: [0, pubkey, created_at, kind, tags, content]
|
// NIP-01 serialization: [0, pubkey, created_at, kind, tags, content]
|
||||||
let serialized = serde_json::json!([0, pubkey_hex, created_at, kind, tags, content]);
|
let serialized = serde_json::json!([0, pubkey_hex, created_at, kind, tags, content]);
|
||||||
let serialized_str = serde_json::to_string(&serialized)?;
|
let serialized_str = serde_json::to_string(&serialized)?;
|
||||||
|
|
||||||
// SHA-256 hash
|
// SHA-256 hash
|
||||||
use sha2::{Sha256, Digest};
|
use sha2::{Digest, Sha256};
|
||||||
let hash = Sha256::digest(serialized_str.as_bytes());
|
let hash = Sha256::digest(serialized_str.as_bytes());
|
||||||
let event_hash_hex = hex::encode(hash);
|
let event_hash_hex = hex::encode(hash);
|
||||||
|
|
||||||
@@ -406,7 +428,8 @@ impl RpcHandler {
|
|||||||
return Ok(default_id);
|
return Ok(default_id);
|
||||||
}
|
}
|
||||||
// Fall back to first identity with a Nostr key, or just the first identity
|
// Fall back to first identity with a Nostr key, or just the first identity
|
||||||
records.iter()
|
records
|
||||||
|
.iter()
|
||||||
.find(|i| i.nostr_pubkey.is_some())
|
.find(|i| i.nostr_pubkey.is_some())
|
||||||
.or(records.first())
|
.or(records.first())
|
||||||
.map(|i| i.id.clone())
|
.map(|i| i.id.clone())
|
||||||
@@ -420,9 +443,13 @@ impl RpcHandler {
|
|||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let id = self.resolve_identity_id(¶ms).await?;
|
let id = self.resolve_identity_id(¶ms).await?;
|
||||||
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
let pubkey = params
|
||||||
|
.get("pubkey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||||
let plaintext = params.get("plaintext").and_then(|v| v.as_str())
|
let plaintext = params
|
||||||
|
.get("plaintext")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: plaintext"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: plaintext"))?;
|
||||||
|
|
||||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||||
@@ -438,9 +465,13 @@ impl RpcHandler {
|
|||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let id = self.resolve_identity_id(¶ms).await?;
|
let id = self.resolve_identity_id(¶ms).await?;
|
||||||
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
let pubkey = params
|
||||||
|
.get("pubkey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||||
let ciphertext = params.get("ciphertext").and_then(|v| v.as_str())
|
let ciphertext = params
|
||||||
|
.get("ciphertext")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ciphertext"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ciphertext"))?;
|
||||||
|
|
||||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||||
@@ -456,9 +487,13 @@ impl RpcHandler {
|
|||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let id = self.resolve_identity_id(¶ms).await?;
|
let id = self.resolve_identity_id(¶ms).await?;
|
||||||
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
let pubkey = params
|
||||||
|
.get("pubkey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||||
let plaintext = params.get("plaintext").and_then(|v| v.as_str())
|
let plaintext = params
|
||||||
|
.get("plaintext")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: plaintext"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: plaintext"))?;
|
||||||
|
|
||||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||||
@@ -474,9 +509,13 @@ impl RpcHandler {
|
|||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let id = self.resolve_identity_id(¶ms).await?;
|
let id = self.resolve_identity_id(¶ms).await?;
|
||||||
let pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
let pubkey = params
|
||||||
|
.get("pubkey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||||
let ciphertext = params.get("ciphertext").and_then(|v| v.as_str())
|
let ciphertext = params
|
||||||
|
.get("ciphertext")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ciphertext"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ciphertext"))?;
|
||||||
|
|
||||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||||
@@ -528,10 +567,7 @@ impl RpcHandler {
|
|||||||
.await
|
.await
|
||||||
.context("Failed to connect to peer over Tor")?;
|
.context("Failed to connect to peer over Tor")?;
|
||||||
|
|
||||||
let body: serde_json::Value = resp
|
let body: serde_json::Value = resp.json().await.context("Failed to parse peer response")?;
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.context("Failed to parse peer response")?;
|
|
||||||
|
|
||||||
// Extract the DID Document from the RPC response
|
// Extract the DID Document from the RPC response
|
||||||
let document = body
|
let document = body
|
||||||
@@ -539,9 +575,7 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Peer returned error or missing result"))?;
|
.ok_or_else(|| anyhow::anyhow!("Peer returned error or missing result"))?;
|
||||||
|
|
||||||
// Cache the resolved DID locally
|
// Cache the resolved DID locally
|
||||||
let did = document["id"]
|
let did = document["id"].as_str().unwrap_or("unknown");
|
||||||
.as_str()
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
let cache_dir = self.config.data_dir.join("did-cache");
|
let cache_dir = self.config.data_dir.join("did-cache");
|
||||||
tokio::fs::create_dir_all(&cache_dir).await.ok();
|
tokio::fs::create_dir_all(&cache_dir).await.ok();
|
||||||
let cache_file = cache_dir.join(format!("{}.json", onion.replace('.', "_")));
|
let cache_file = cache_dir.join(format!("{}.json", onion.replace('.', "_")));
|
||||||
@@ -550,9 +584,12 @@ impl RpcHandler {
|
|||||||
"resolved_at": chrono::Utc::now().to_rfc3339(),
|
"resolved_at": chrono::Utc::now().to_rfc3339(),
|
||||||
"onion": onion,
|
"onion": onion,
|
||||||
});
|
});
|
||||||
tokio::fs::write(&cache_file, serde_json::to_string_pretty(&cache_entry).unwrap_or_default())
|
tokio::fs::write(
|
||||||
.await
|
&cache_file,
|
||||||
.ok();
|
serde_json::to_string_pretty(&cache_entry).unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"document": document,
|
"document": document,
|
||||||
@@ -627,7 +664,9 @@ impl RpcHandler {
|
|||||||
let record = manager.get(identity_id).await?;
|
let record = manager.get(identity_id).await?;
|
||||||
|
|
||||||
if record.dht_did.is_none() {
|
if record.dht_did.is_none() {
|
||||||
anyhow::bail!("Identity has no did:dht — create one first with identity.create-dht-did");
|
anyhow::bail!(
|
||||||
|
"Identity has no did:dht — create one first with identity.create-dht-did"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let signing_key = manager.get_signing_key(identity_id).await?;
|
let signing_key = manager.get_signing_key(identity_id).await?;
|
||||||
@@ -645,18 +684,41 @@ impl RpcHandler {
|
|||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let id = params.get("id").and_then(|v| v.as_str())
|
let id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||||
validate_identity_id(id)?;
|
validate_identity_id(id)?;
|
||||||
|
|
||||||
let profile = IdentityProfile {
|
let profile = IdentityProfile {
|
||||||
display_name: params.get("display_name").and_then(|v| v.as_str()).map(String::from),
|
display_name: params
|
||||||
about: params.get("about").and_then(|v| v.as_str()).map(String::from),
|
.get("display_name")
|
||||||
picture: params.get("picture").and_then(|v| v.as_str()).map(String::from),
|
.and_then(|v| v.as_str())
|
||||||
banner: params.get("banner").and_then(|v| v.as_str()).map(String::from),
|
.map(String::from),
|
||||||
website: params.get("website").and_then(|v| v.as_str()).map(String::from),
|
about: params
|
||||||
nip05: params.get("nip05").and_then(|v| v.as_str()).map(String::from),
|
.get("about")
|
||||||
lud16: params.get("lud16").and_then(|v| v.as_str()).map(String::from),
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
picture: params
|
||||||
|
.get("picture")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
banner: params
|
||||||
|
.get("banner")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
website: params
|
||||||
|
.get("website")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
nip05: params
|
||||||
|
.get("nip05")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
|
lud16: params
|
||||||
|
.get("lud16")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
};
|
};
|
||||||
|
|
||||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||||
@@ -665,27 +727,53 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "ok": true }))
|
Ok(serde_json::json!({ "ok": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish kind 0 (metadata) profile to the local Nostr relay.
|
/// Publish kind 0 (metadata) profile to every enabled Nostr relay
|
||||||
|
/// configured in Manage Relays. Callers can override the default
|
||||||
|
/// list by passing `relays: [..]` in params (e.g. to publish to a
|
||||||
|
/// single relay for testing).
|
||||||
pub(in crate::api::rpc) async fn handle_identity_publish_profile(
|
pub(in crate::api::rpc) async fn handle_identity_publish_profile(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let id = params.get("id").and_then(|v| v.as_str())
|
let id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||||
validate_identity_id(id)?;
|
validate_identity_id(id)?;
|
||||||
|
|
||||||
let relay_url = params.get("relay")
|
let relay_urls: Vec<String> = if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) {
|
||||||
.and_then(|v| v.as_str())
|
arr.iter()
|
||||||
.unwrap_or("ws://localhost:18081");
|
.filter_map(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect()
|
||||||
|
} else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) {
|
||||||
|
vec![single.to_string()]
|
||||||
|
} else {
|
||||||
|
// Default: every enabled relay in the user's Manage Relays list.
|
||||||
|
let statuses = crate::nostr_relays::list_relays(&self.config.data_dir)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
statuses
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.enabled)
|
||||||
|
.map(|s| s.url)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
if relay_urls.is_empty() {
|
||||||
|
anyhow::bail!("No enabled relays configured; add one under Manage Relays");
|
||||||
|
}
|
||||||
|
|
||||||
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
let manager = IdentityManager::new(&self.config.data_dir).await?;
|
||||||
let event_id = manager.publish_profile(id, relay_url).await?;
|
let outcome = manager.publish_profile(id, &relay_urls).await?;
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"event_id": event_id,
|
"event_id": outcome.event_id,
|
||||||
"relay": relay_url,
|
"accepted": outcome.accepted,
|
||||||
"published": true,
|
"rejected": outcome.rejected,
|
||||||
|
"relays_attempted": relay_urls.len(),
|
||||||
|
"published": !outcome.accepted.is_empty(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ pub(super) fn validate_identity_id(id: &str) -> Result<()> {
|
|||||||
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
|
if id.contains("..") || id.contains('/') || id.contains('\\') || id.contains('\0') {
|
||||||
anyhow::bail!("Invalid identity id: contains forbidden characters");
|
anyhow::bail!("Invalid identity id: contains forbidden characters");
|
||||||
}
|
}
|
||||||
if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':') {
|
if !id
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b':')
|
||||||
|
{
|
||||||
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
|
anyhow::bail!("Invalid identity id: must be alphanumeric, hyphens, underscores, or colons");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,14 +77,10 @@ impl RpcHandler {
|
|||||||
configure_ethernet_dhcp(interface).await?;
|
configure_ethernet_dhcp(interface).await?;
|
||||||
}
|
}
|
||||||
"static" => {
|
"static" => {
|
||||||
let ip = params
|
let ip = params.get("ip").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||||
.get("ip")
|
anyhow::anyhow!("Missing required parameter: ip for static mode")
|
||||||
.and_then(|v| v.as_str())
|
})?;
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: ip for static mode"))?;
|
let gateway = params.get("gateway").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let gateway = params
|
|
||||||
.get("gateway")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
let dns = params
|
let dns = params
|
||||||
.get("dns")
|
.get("dns")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -140,7 +136,10 @@ impl RpcHandler {
|
|||||||
"quad9" => dns::DnsProvider::Quad9,
|
"quad9" => dns::DnsProvider::Quad9,
|
||||||
"mullvad" => dns::DnsProvider::Mullvad,
|
"mullvad" => dns::DnsProvider::Mullvad,
|
||||||
"custom" => dns::DnsProvider::Custom,
|
"custom" => dns::DnsProvider::Custom,
|
||||||
other => anyhow::bail!("Unknown DNS provider: {}. Use: system, cloudflare, google, quad9, mullvad, custom", other),
|
other => anyhow::bail!(
|
||||||
|
"Unknown DNS provider: {}. Use: system, cloudflare, google, quad9, mullvad, custom",
|
||||||
|
other
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
let custom_servers: Vec<String> = if provider == dns::DnsProvider::Custom {
|
let custom_servers: Vec<String> = if provider == dns::DnsProvider::Custom {
|
||||||
@@ -211,10 +210,7 @@ async fn list_interfaces() -> Result<Vec<serde_json::Value>> {
|
|||||||
.get("operstate")
|
.get("operstate")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("UNKNOWN");
|
.unwrap_or("UNKNOWN");
|
||||||
let mac = iface
|
let mac = iface.get("address").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
.get("address")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
// Get IPv4 addresses
|
// Get IPv4 addresses
|
||||||
let addrs: Vec<String> = iface
|
let addrs: Vec<String> = iface
|
||||||
@@ -236,7 +232,11 @@ async fn list_interfaces() -> Result<Vec<serde_json::Value>> {
|
|||||||
"wifi"
|
"wifi"
|
||||||
} else if name.starts_with("en") || name.starts_with("eth") {
|
} else if name.starts_with("en") || name.starts_with("eth") {
|
||||||
"ethernet"
|
"ethernet"
|
||||||
} else if name.starts_with("veth") || name.starts_with("br-") || name.starts_with("docker") || name.starts_with("podman") {
|
} else if name.starts_with("veth")
|
||||||
|
|| name.starts_with("br-")
|
||||||
|
|| name.starts_with("docker")
|
||||||
|
|| name.starts_with("podman")
|
||||||
|
{
|
||||||
"virtual"
|
"virtual"
|
||||||
} else {
|
} else {
|
||||||
"other"
|
"other"
|
||||||
|
|||||||
@@ -86,9 +86,21 @@ impl RpcHandler {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|ch| {
|
.map(|ch| {
|
||||||
let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
let capacity: i64 = ch
|
||||||
let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
.capacity
|
||||||
let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
.as_deref()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let local: i64 = ch
|
||||||
|
.local_balance
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let remote: i64 = ch
|
||||||
|
.remote_balance
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
ChannelInfo {
|
ChannelInfo {
|
||||||
chan_id: ch.chan_id.unwrap_or_default(),
|
chan_id: ch.chan_id.unwrap_or_default(),
|
||||||
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
remote_pubkey: ch.remote_pubkey.unwrap_or_default(),
|
||||||
@@ -96,7 +108,11 @@ impl RpcHandler {
|
|||||||
local_balance: local,
|
local_balance: local,
|
||||||
remote_balance: remote,
|
remote_balance: remote,
|
||||||
active: ch.active.unwrap_or(false),
|
active: ch.active.unwrap_or(false),
|
||||||
status: if ch.active.unwrap_or(false) { "active".into() } else { "inactive".into() },
|
status: if ch.active.unwrap_or(false) {
|
||||||
|
"active".into()
|
||||||
|
} else {
|
||||||
|
"inactive".into()
|
||||||
|
},
|
||||||
channel_point: ch.channel_point.unwrap_or_default(),
|
channel_point: ch.channel_point.unwrap_or_default(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -105,9 +121,21 @@ impl RpcHandler {
|
|||||||
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
let mut pending_channels: Vec<ChannelInfo> = Vec::new();
|
||||||
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
for pch in pending_resp.pending_open_channels.unwrap_or_default() {
|
||||||
if let Some(ch) = pch.channel {
|
if let Some(ch) = pch.channel {
|
||||||
let capacity: i64 = ch.capacity.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
let capacity: i64 = ch
|
||||||
let local: i64 = ch.local_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
.capacity
|
||||||
let remote: i64 = ch.remote_balance.as_deref().and_then(|s| s.parse().ok()).unwrap_or(0);
|
.as_deref()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let local: i64 = ch
|
||||||
|
.local_balance
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let remote: i64 = ch
|
||||||
|
.remote_balance
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
pending_channels.push(ChannelInfo {
|
pending_channels.push(ChannelInfo {
|
||||||
chan_id: String::new(),
|
chan_id: String::new(),
|
||||||
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
|
remote_pubkey: ch.remote_node_pub.unwrap_or_default(),
|
||||||
@@ -136,25 +164,36 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::to_value(result)?)
|
Ok(serde_json::to_value(result)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_openchannel(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_openchannel(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let pubkey = params.get("pubkey")
|
let pubkey = params
|
||||||
|
.get("pubkey")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey' parameter"))?;
|
||||||
let amount = params.get("amount")
|
let amount = params
|
||||||
|
.get("amount")
|
||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||||
|
|
||||||
// Validate pubkey: must be 66-char hex (compressed secp256k1)
|
// Validate pubkey: must be 66-char hex (compressed secp256k1)
|
||||||
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
return Err(anyhow::anyhow!("Invalid pubkey: must be 66-character hex string"));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid pubkey: must be 66-character hex string"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if amount < 20000 {
|
if amount < 20000 {
|
||||||
return Err(anyhow::anyhow!("Channel amount must be at least 20,000 sats"));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Channel amount must be at least 20,000 sats"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if amount > 16_777_215 {
|
if amount > 16_777_215 {
|
||||||
return Err(anyhow::anyhow!("Channel amount exceeds maximum (16,777,215 sats)"));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Channel amount exceeds maximum (16,777,215 sats)"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(peer = pubkey, amount = amount, "Opening Lightning channel");
|
info!(peer = pubkey, amount = amount, "Opening Lightning channel");
|
||||||
@@ -193,36 +232,61 @@ impl RpcHandler {
|
|||||||
.context("Failed to open channel")?;
|
.context("Failed to open channel")?;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp.json().await.context("Failed to parse open channel response")?;
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse open channel response")?;
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
|
return Err(anyhow::anyhow!("Failed to open channel: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(body)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_closechannel(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_closechannel(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let channel_point = params.get("channel_point")
|
let channel_point = params
|
||||||
|
.get("channel_point")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'channel_point' parameter (txid:output_index)"))?;
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Missing 'channel_point' parameter (txid:output_index)")
|
||||||
|
})?;
|
||||||
|
|
||||||
let parts: Vec<&str> = channel_point.split(':').collect();
|
let parts: Vec<&str> = channel_point.split(':').collect();
|
||||||
if parts.len() != 2 {
|
if parts.len() != 2 {
|
||||||
return Err(anyhow::anyhow!("Invalid channel_point format. Expected 'txid:output_index'"));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid channel_point format. Expected 'txid:output_index'"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
// Validate txid is 64-char hex and output_index is numeric
|
// Validate txid is 64-char hex and output_index is numeric
|
||||||
if parts[0].len() != 64 || !parts[0].chars().all(|c| c.is_ascii_hexdigit()) {
|
if parts[0].len() != 64 || !parts[0].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
return Err(anyhow::anyhow!("Invalid txid in channel_point: must be 64-character hex"));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid txid in channel_point: must be 64-character hex"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if parts[1].parse::<u32>().is_err() {
|
if parts[1].parse::<u32>().is_err() {
|
||||||
return Err(anyhow::anyhow!("Invalid output_index in channel_point: must be a number"));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid output_index in channel_point: must be a number"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let force = params.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
let force = params
|
||||||
info!(channel_point = channel_point, force = force, "Closing Lightning channel");
|
.get("force")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
info!(
|
||||||
|
channel_point = channel_point,
|
||||||
|
force = force,
|
||||||
|
"Closing Lightning channel"
|
||||||
|
);
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
@@ -239,10 +303,16 @@ impl RpcHandler {
|
|||||||
.context("Failed to close channel")?;
|
.context("Failed to close channel")?;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp.json().await.context("Failed to parse close channel response")?;
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse close channel response")?;
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
return Err(anyhow::anyhow!("Failed to close channel: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,7 @@ struct LndChannelBalanceResponse {
|
|||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||||
let macaroon_path =
|
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
|
||||||
|
|
||||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||||
.await
|
.await
|
||||||
@@ -115,8 +114,7 @@ impl RpcHandler {
|
|||||||
/// for building lndconnect:// URIs in the frontend.
|
/// for building lndconnect:// URIs in the frontend.
|
||||||
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
|
pub(crate) async fn handle_lnd_connect_info(&self) -> Result<serde_json::Value> {
|
||||||
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
|
let cert_path = "/var/lib/archipelago/lnd/tls.cert";
|
||||||
let macaroon_path =
|
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
|
||||||
|
|
||||||
// Read and encode TLS cert (PEM -> DER -> base64url)
|
// Read and encode TLS cert (PEM -> DER -> base64url)
|
||||||
let cert_pem = tokio::fs::read_to_string(cert_path)
|
let cert_pem = tokio::fs::read_to_string(cert_path)
|
||||||
@@ -182,7 +180,9 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// lnd.export-channel-backup -- Export all channel static backups (SCB).
|
/// lnd.export-channel-backup -- Export all channel static backups (SCB).
|
||||||
/// Returns base64-encoded multi-channel backup that can restore channels on a new node.
|
/// Returns base64-encoded multi-channel backup that can restore channels on a new node.
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_export_channel_backup(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_export_channel_backup(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ impl RpcHandler {
|
|||||||
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
/// Returns an HTTP client configured for LND's self-signed TLS and the
|
||||||
/// hex-encoded admin macaroon for request headers.
|
/// hex-encoded admin macaroon for request headers.
|
||||||
pub(crate) async fn lnd_client(&self) -> Result<(reqwest::Client, String)> {
|
pub(crate) async fn lnd_client(&self) -> Result<(reqwest::Client, String)> {
|
||||||
let macaroon_path =
|
let macaroon_path = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
|
||||||
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
let macaroon_bytes = tokio::fs::read(macaroon_path)
|
||||||
.await
|
.await
|
||||||
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
.context("Failed to read LND admin macaroon — is LND installed?")?;
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ use tracing::info;
|
|||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// Pay a Lightning invoice.
|
/// Pay a Lightning invoice.
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let payment_request = params.get("payment_request")
|
let payment_request = params
|
||||||
|
.get("payment_request")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_request' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_request' parameter"))?;
|
||||||
|
|
||||||
@@ -15,8 +19,11 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("Invalid payment request length"));
|
return Err(anyhow::anyhow!("Invalid payment request length"));
|
||||||
}
|
}
|
||||||
let lower = payment_request.to_lowercase();
|
let lower = payment_request.to_lowercase();
|
||||||
if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") {
|
if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt")
|
||||||
return Err(anyhow::anyhow!("Invalid payment request: must be a Lightning invoice (lnbc...)"));
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid payment request: must be a Lightning invoice (lnbc...)"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Paying Lightning invoice");
|
info!("Paying Lightning invoice");
|
||||||
@@ -36,26 +43,36 @@ impl RpcHandler {
|
|||||||
.context("Failed to pay invoice")?;
|
.context("Failed to pay invoice")?;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp.json().await
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
.context("Failed to parse payment response")?;
|
.context("Failed to parse payment response")?;
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
return Err(anyhow::anyhow!("Payment failed: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
let payment_error = body.get("payment_error").and_then(|v| v.as_str()).unwrap_or("");
|
let payment_error = body
|
||||||
|
.get("payment_error")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
if !payment_error.is_empty() {
|
if !payment_error.is_empty() {
|
||||||
return Err(anyhow::anyhow!("Payment failed: {}", payment_error));
|
return Err(anyhow::anyhow!("Payment failed: {}", payment_error));
|
||||||
}
|
}
|
||||||
|
|
||||||
let amount_sat = body.get("payment_route")
|
let amount_sat = body
|
||||||
|
.get("payment_route")
|
||||||
.and_then(|r| r.get("total_amt"))
|
.and_then(|r| r.get("total_amt"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| s.parse::<i64>().ok())
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let payment_hash = body.get("payment_hash")
|
let payment_hash = body
|
||||||
|
.get("payment_hash")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -68,7 +85,9 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// List on-chain transactions from LND.
|
/// List on-chain transactions from LND.
|
||||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -148,10 +167,7 @@ impl RpcHandler {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let block_height: i64 = tx
|
let block_height: i64 = tx.get("block_height").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
.get("block_height")
|
|
||||||
.and_then(|v| v.as_i64())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let direction = if amount > 0 { "incoming" } else { "outgoing" };
|
let direction = if amount > 0 { "incoming" } else { "outgoing" };
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ impl RpcHandler {
|
|||||||
.await
|
.await
|
||||||
.context("LND REST connection failed")?;
|
.context("LND REST connection failed")?;
|
||||||
|
|
||||||
let body: serde_json::Value = resp.json().await
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
.context("Failed to parse newaddress response")?;
|
.context("Failed to parse newaddress response")?;
|
||||||
|
|
||||||
let address = body.get("address")
|
let address = body
|
||||||
|
.get("address")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -28,17 +31,24 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Send on-chain Bitcoin to an address.
|
/// Send on-chain Bitcoin to an address.
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_sendcoins(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_sendcoins(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let addr = params.get("addr")
|
let addr = params
|
||||||
|
.get("addr")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'addr' parameter"))?;
|
||||||
let amount = params.get("amount")
|
let amount = params
|
||||||
|
.get("amount")
|
||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'amount' parameter (sats)"))?;
|
||||||
|
|
||||||
if amount < 546 {
|
if amount < 546 {
|
||||||
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Amount must be at least 546 sats (dust limit)"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if amount > 21_000_000 * 100_000_000 {
|
if amount > 21_000_000 * 100_000_000 {
|
||||||
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
return Err(anyhow::anyhow!("Amount exceeds maximum Bitcoin supply"));
|
||||||
@@ -67,27 +77,35 @@ impl RpcHandler {
|
|||||||
.context("Failed to send on-chain transaction")?;
|
.context("Failed to send on-chain transaction")?;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp.json().await
|
let body: serde_json::Value = resp.json().await.context("Failed to parse send response")?;
|
||||||
.context("Failed to parse send response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
return Err(anyhow::anyhow!("Failed to send: {}", msg));
|
return Err(anyhow::anyhow!("Failed to send: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
let txid = body.get("txid").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
let txid = body
|
||||||
|
.get("txid")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
Ok(serde_json::json!({ "txid": txid }))
|
Ok(serde_json::json!({ "txid": txid }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a Lightning invoice.
|
/// Create a Lightning invoice.
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_createinvoice(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_createinvoice(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let amount_sats = params.get("amount_sats")
|
let amount_sats = params
|
||||||
|
.get("amount_sats")
|
||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats' parameter"))?;
|
||||||
let memo = params.get("memo")
|
let memo = params.get("memo").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if amount_sats < 0 {
|
if amount_sats < 0 {
|
||||||
return Err(anyhow::anyhow!("Amount must be non-negative"));
|
return Err(anyhow::anyhow!("Amount must be non-negative"));
|
||||||
@@ -119,15 +137,21 @@ impl RpcHandler {
|
|||||||
.context("Failed to create invoice")?;
|
.context("Failed to create invoice")?;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp.json().await
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
.context("Failed to parse invoice response")?;
|
.context("Failed to parse invoice response")?;
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
return Err(anyhow::anyhow!("Failed to create invoice: {}", msg));
|
return Err(anyhow::anyhow!("Failed to create invoice: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
let payment_request = body.get("payment_request")
|
let payment_request = body
|
||||||
|
.get("payment_request")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -140,12 +164,18 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Create an unsigned PSBT for hardware wallet signing.
|
/// Create an unsigned PSBT for hardware wallet signing.
|
||||||
/// Uses LND's WalletKit.FundPsbt to select UTXOs and create a PSBT template.
|
/// Uses LND's WalletKit.FundPsbt to select UTXOs and create a PSBT template.
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_create_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_create_psbt(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
|
||||||
let outputs = params.get("outputs")
|
let outputs = params
|
||||||
|
.get("outputs")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'outputs' array (each: address + amount_sats)"))?;
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Missing 'outputs' array (each: address + amount_sats)")
|
||||||
|
})?;
|
||||||
|
|
||||||
if outputs.is_empty() {
|
if outputs.is_empty() {
|
||||||
return Err(anyhow::anyhow!("outputs must not be empty"));
|
return Err(anyhow::anyhow!("outputs must not be empty"));
|
||||||
@@ -155,28 +185,40 @@ impl RpcHandler {
|
|||||||
let mut lnd_outputs: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
|
let mut lnd_outputs: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
|
||||||
let mut total_amount: i64 = 0;
|
let mut total_amount: i64 = 0;
|
||||||
for output in outputs {
|
for output in outputs {
|
||||||
let addr = output.get("address")
|
let addr = output
|
||||||
|
.get("address")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Each output must have an 'address'"))?;
|
.ok_or_else(|| anyhow::anyhow!("Each output must have an 'address'"))?;
|
||||||
// Validate Bitcoin address format
|
// Validate Bitcoin address format
|
||||||
if addr.len() < 14 || addr.len() > 90 || !addr.chars().all(|c| c.is_ascii_alphanumeric()) {
|
if addr.len() < 14
|
||||||
|
|| addr.len() > 90
|
||||||
|
|| !addr.chars().all(|c| c.is_ascii_alphanumeric())
|
||||||
|
{
|
||||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format in output"));
|
return Err(anyhow::anyhow!("Invalid Bitcoin address format in output"));
|
||||||
}
|
}
|
||||||
let amount = output.get("amount_sats")
|
let amount = output
|
||||||
|
.get("amount_sats")
|
||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Each output must have 'amount_sats'"))?;
|
.ok_or_else(|| anyhow::anyhow!("Each output must have 'amount_sats'"))?;
|
||||||
if amount < 546 {
|
if amount < 546 {
|
||||||
return Err(anyhow::anyhow!("Amount must be at least 546 sats (dust limit)"));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Amount must be at least 546 sats (dust limit)"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
lnd_outputs.insert(addr.to_string(), serde_json::json!(amount));
|
lnd_outputs.insert(addr.to_string(), serde_json::json!(amount));
|
||||||
total_amount += amount;
|
total_amount += amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sat_per_vbyte = params.get("fee_rate_sat_per_vbyte")
|
let sat_per_vbyte = params
|
||||||
|
.get("fee_rate_sat_per_vbyte")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.unwrap_or(10);
|
.unwrap_or(10);
|
||||||
|
|
||||||
info!(total_amount = total_amount, fee_rate = sat_per_vbyte, "Creating PSBT for hardware wallet signing");
|
info!(
|
||||||
|
total_amount = total_amount,
|
||||||
|
fee_rate = sat_per_vbyte,
|
||||||
|
"Creating PSBT for hardware wallet signing"
|
||||||
|
);
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
@@ -197,20 +239,24 @@ impl RpcHandler {
|
|||||||
.context("Failed to create PSBT via LND")?;
|
.context("Failed to create PSBT via LND")?;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp.json().await
|
let body: serde_json::Value = resp.json().await.context("Failed to parse PSBT response")?;
|
||||||
.context("Failed to parse PSBT response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
return Err(anyhow::anyhow!("Failed to create PSBT: {}", msg));
|
return Err(anyhow::anyhow!("Failed to create PSBT: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
let funded_psbt = body.get("funded_psbt")
|
let funded_psbt = body
|
||||||
|
.get("funded_psbt")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let change_output_index = body.get("change_output_index")
|
let change_output_index = body
|
||||||
|
.get("change_output_index")
|
||||||
.and_then(|v| v.as_i64())
|
.and_then(|v| v.as_i64())
|
||||||
.unwrap_or(-1);
|
.unwrap_or(-1);
|
||||||
|
|
||||||
@@ -224,9 +270,13 @@ impl RpcHandler {
|
|||||||
|
|
||||||
/// Finalize a signed PSBT and broadcast the transaction.
|
/// Finalize a signed PSBT and broadcast the transaction.
|
||||||
/// Takes a PSBT that has been signed by a hardware wallet.
|
/// Takes a PSBT that has been signed by a hardware wallet.
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_finalize_psbt(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_finalize_psbt(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let signed_psbt = params.get("signed_psbt_base64")
|
let signed_psbt = params
|
||||||
|
.get("signed_psbt_base64")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'signed_psbt_base64'"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'signed_psbt_base64'"))?;
|
||||||
|
|
||||||
@@ -247,15 +297,21 @@ impl RpcHandler {
|
|||||||
.context("Failed to finalize PSBT via LND")?;
|
.context("Failed to finalize PSBT via LND")?;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp.json().await
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
.context("Failed to parse finalize response")?;
|
.context("Failed to parse finalize response")?;
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
return Err(anyhow::anyhow!("Failed to finalize PSBT: {}", msg));
|
return Err(anyhow::anyhow!("Failed to finalize PSBT: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw_final_tx = body.get("raw_final_tx")
|
let raw_final_tx = body
|
||||||
|
.get("raw_final_tx")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -274,11 +330,16 @@ impl RpcHandler {
|
|||||||
.context("Failed to broadcast transaction")?;
|
.context("Failed to broadcast transaction")?;
|
||||||
|
|
||||||
let pub_status = pub_resp.status();
|
let pub_status = pub_resp.status();
|
||||||
let pub_body: serde_json::Value = pub_resp.json().await
|
let pub_body: serde_json::Value = pub_resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
.context("Failed to parse broadcast response")?;
|
.context("Failed to parse broadcast response")?;
|
||||||
|
|
||||||
if !pub_status.is_success() {
|
if !pub_status.is_success() {
|
||||||
let msg = pub_body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
let msg = pub_body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
return Err(anyhow::anyhow!("Transaction broadcast failed: {}", msg));
|
return Err(anyhow::anyhow!("Transaction broadcast failed: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,13 +352,18 @@ impl RpcHandler {
|
|||||||
/// Create a signed raw transaction WITHOUT broadcasting.
|
/// Create a signed raw transaction WITHOUT broadcasting.
|
||||||
/// Used for mesh relay: create the TX locally, then relay the hex to an
|
/// Used for mesh relay: create the TX locally, then relay the hex to an
|
||||||
/// internet-connected peer who broadcasts it.
|
/// internet-connected peer who broadcasts it.
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_create_raw_tx(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_create_raw_tx(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
|
||||||
let addr = params.get("addr")
|
let addr = params
|
||||||
|
.get("addr")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'addr'"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'addr'"))?;
|
||||||
let amount_sats = params.get("amount_sats")
|
let amount_sats = params
|
||||||
|
.get("amount_sats")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats'"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats'"))?;
|
||||||
|
|
||||||
@@ -329,15 +395,18 @@ impl RpcHandler {
|
|||||||
.context("Failed to fund PSBT via LND")?;
|
.context("Failed to fund PSBT via LND")?;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp.json().await
|
let body: serde_json::Value = resp.json().await.context("Failed to parse fund response")?;
|
||||||
.context("Failed to parse fund response")?;
|
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
return Err(anyhow::anyhow!("Failed to create TX: {}", msg));
|
return Err(anyhow::anyhow!("Failed to create TX: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
let funded_psbt = body.get("funded_psbt")
|
let funded_psbt = body
|
||||||
|
.get("funded_psbt")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("No funded_psbt in response"))?;
|
.ok_or_else(|| anyhow::anyhow!("No funded_psbt in response"))?;
|
||||||
|
|
||||||
@@ -355,16 +424,22 @@ impl RpcHandler {
|
|||||||
.context("Failed to finalize PSBT")?;
|
.context("Failed to finalize PSBT")?;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp.json().await
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
.context("Failed to parse finalize response")?;
|
.context("Failed to parse finalize response")?;
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
return Err(anyhow::anyhow!("Failed to sign TX: {}", msg));
|
return Err(anyhow::anyhow!("Failed to sign TX: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// raw_final_tx from LND is base64-encoded -- decode to hex for Bitcoin RPC
|
// raw_final_tx from LND is base64-encoded -- decode to hex for Bitcoin RPC
|
||||||
let raw_final_tx_b64 = body.get("raw_final_tx")
|
let raw_final_tx_b64 = body
|
||||||
|
.get("raw_final_tx")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("No raw_final_tx in response"))?;
|
.ok_or_else(|| anyhow::anyhow!("No raw_final_tx in response"))?;
|
||||||
|
|
||||||
@@ -373,7 +448,12 @@ impl RpcHandler {
|
|||||||
.context("Failed to decode raw_final_tx base64")?;
|
.context("Failed to decode raw_final_tx base64")?;
|
||||||
let raw_tx_hex = hex::encode(&tx_bytes);
|
let raw_tx_hex = hex::encode(&tx_bytes);
|
||||||
|
|
||||||
info!(addr, amount_sats, tx_len = raw_tx_hex.len(), "Created raw TX for mesh relay (NOT broadcast)");
|
info!(
|
||||||
|
addr,
|
||||||
|
amount_sats,
|
||||||
|
tx_len = raw_tx_hex.len(),
|
||||||
|
"Created raw TX for mesh relay (NOT broadcast)"
|
||||||
|
);
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"raw_tx_hex": raw_tx_hex,
|
"raw_tx_hex": raw_tx_hex,
|
||||||
@@ -391,28 +471,34 @@ impl RpcHandler {
|
|||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let password = params.get("password")
|
let password = params
|
||||||
|
.get("password")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'password' for seed access"))?;
|
||||||
let wallet_password = params.get("wallet_password")
|
let wallet_password = params
|
||||||
|
.get("wallet_password")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'wallet_password' for LND"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'wallet_password' for LND"))?;
|
||||||
|
|
||||||
// Verify user password before granting seed access.
|
// Verify user password before granting seed access.
|
||||||
self.auth_manager.verify_password(password).await
|
self.auth_manager
|
||||||
|
.verify_password(password)
|
||||||
|
.await
|
||||||
.context("Password verification failed")?;
|
.context("Password verification failed")?;
|
||||||
|
|
||||||
// Load encrypted seed from disk.
|
// Load encrypted seed from disk.
|
||||||
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password).await
|
let mnemonic = crate::seed::load_seed_encrypted(&self.config.data_dir, password)
|
||||||
|
.await
|
||||||
.context("Failed to load encrypted seed. Was a seed phrase saved during onboarding?")?;
|
.context("Failed to load encrypted seed. Was a seed phrase saved during onboarding?")?;
|
||||||
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
let seed = crate::seed::MasterSeed::from_mnemonic(&mnemonic);
|
||||||
|
|
||||||
// Derive 16 bytes of LND entropy.
|
// Derive 16 bytes of LND entropy.
|
||||||
let mut entropy = crate::seed::derive_lnd_entropy(&seed)?;
|
let mut entropy = crate::seed::derive_lnd_entropy(&seed)?;
|
||||||
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(&entropy);
|
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(entropy);
|
||||||
entropy.zeroize();
|
entropy.zeroize();
|
||||||
|
|
||||||
let wallet_password_b64 = base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
|
let wallet_password_b64 =
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
|
||||||
|
|
||||||
// Call LND REST API to initialize wallet with derived entropy.
|
// Call LND REST API to initialize wallet with derived entropy.
|
||||||
// LND must be running but NOT yet initialized (no existing wallet).
|
// LND must be running but NOT yet initialized (no existing wallet).
|
||||||
@@ -435,11 +521,16 @@ impl RpcHandler {
|
|||||||
.context("LND initwallet request failed — is LND running and uninitialized?")?;
|
.context("LND initwallet request failed — is LND running and uninitialized?")?;
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body: serde_json::Value = resp.json().await
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
.context("Failed to parse initwallet response")?;
|
.context("Failed to parse initwallet response")?;
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
let msg = body
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
return Err(anyhow::anyhow!("LND wallet init failed: {}", msg));
|
return Err(anyhow::anyhow!("LND wallet init failed: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ impl RpcHandler {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Load federated DIDs for trust scoring
|
// Load federated DIDs for trust scoring
|
||||||
let fed_nodes = federation::load_nodes(&self.config.data_dir).await.unwrap_or_default();
|
let fed_nodes = federation::load_nodes(&self.config.data_dir)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
let federated_dids: Vec<String> = fed_nodes.iter().map(|n| n.did.clone()).collect();
|
let federated_dids: Vec<String> = fed_nodes.iter().map(|n| n.did.clone()).collect();
|
||||||
|
|
||||||
let tor_proxy = std::env::var("ARCHIPELAGO_NOSTR_TOR_PROXY").ok();
|
let tor_proxy = std::env::var("ARCHIPELAGO_NOSTR_TOR_PROXY").ok();
|
||||||
@@ -42,8 +44,8 @@ impl RpcHandler {
|
|||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let manifest: marketplace::AppManifest =
|
let manifest: marketplace::AppManifest = serde_json::from_value(params)
|
||||||
serde_json::from_value(params).map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?;
|
||||||
|
|
||||||
// Validate before publishing
|
// Validate before publishing
|
||||||
let issues = marketplace::validate_manifest(&manifest);
|
let issues = marketplace::validate_manifest(&manifest);
|
||||||
@@ -112,8 +114,8 @@ impl RpcHandler {
|
|||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let manifest: marketplace::AppManifest =
|
let manifest: marketplace::AppManifest = serde_json::from_value(params)
|
||||||
serde_json::from_value(params).map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Invalid manifest: {}", e))?;
|
||||||
|
|
||||||
let issues = marketplace::validate_manifest(&manifest);
|
let issues = marketplace::validate_manifest(&manifest);
|
||||||
let (trust_score, trust_tier) = marketplace::calculate_trust_score(&manifest, 0, &[]);
|
let (trust_score, trust_tier) = marketplace::calculate_trust_score(&manifest, 0, &[]);
|
||||||
@@ -147,9 +149,7 @@ impl RpcHandler {
|
|||||||
"amount": amount_sats,
|
"amount": amount_sats,
|
||||||
"memo": format!("Archipelago app: {}", app_id),
|
"memo": format!("Archipelago app: {}", app_id),
|
||||||
});
|
});
|
||||||
let invoice_result = self
|
let invoice_result = self.handle_lnd_createinvoice(Some(invoice_params)).await?;
|
||||||
.handle_lnd_createinvoice(Some(invoice_params))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let payment_request = invoice_result
|
let payment_request = invoice_result
|
||||||
.get("payment_request")
|
.get("payment_request")
|
||||||
@@ -181,15 +181,14 @@ impl RpcHandler {
|
|||||||
|
|
||||||
// Validate r_hash is hex-encoded (LND payment hashes are 32 bytes = 64 hex chars)
|
// Validate r_hash is hex-encoded (LND payment hashes are 32 bytes = 64 hex chars)
|
||||||
if r_hash.len() != 64 || !r_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
if r_hash.len() != 64 || !r_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
return Err(anyhow::anyhow!("Invalid r_hash: must be 64-character hex string"));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid r_hash: must be 64-character hex string"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
|
|
||||||
let url = format!(
|
let url = format!("https://127.0.0.1:8080/v1/invoice/{}", r_hash);
|
||||||
"https://127.0.0.1:8080/v1/invoice/{}",
|
|
||||||
r_hash
|
|
||||||
);
|
|
||||||
let paid = match client
|
let paid = match client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
@@ -198,7 +197,9 @@ impl RpcHandler {
|
|||||||
{
|
{
|
||||||
Ok(r) if r.status().is_success() => {
|
Ok(r) if r.status().is_success() => {
|
||||||
let body: serde_json::Value = r.json().await.unwrap_or_default();
|
let body: serde_json::Value = r.json().await.unwrap_or_default();
|
||||||
body.get("settled").and_then(|v| v.as_bool()).unwrap_or(false)
|
body.get("settled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ impl RpcHandler {
|
|||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
|
||||||
|
|
||||||
let relay_mode = params["relay_mode"]
|
let relay_mode = params["relay_mode"].as_str().unwrap_or("archy");
|
||||||
.as_str()
|
|
||||||
.unwrap_or("archy");
|
|
||||||
|
|
||||||
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
|
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
|
||||||
anyhow::bail!("Invalid tx_hex length");
|
anyhow::bail!("Invalid tx_hex length");
|
||||||
@@ -26,11 +24,14 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
let svc = service.as_ref()
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||||
svc.relay_tracker.track_tx_relay(request_id, svc.our_did()).await;
|
svc.relay_tracker
|
||||||
|
.track_tx_relay(request_id, svc.our_did())
|
||||||
|
.await;
|
||||||
|
|
||||||
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
|
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
|
||||||
|
|
||||||
@@ -44,7 +45,9 @@ impl RpcHandler {
|
|||||||
|
|
||||||
// Encrypt with first available Archy peer's shared secret
|
// Encrypt with first available Archy peer's shared secret
|
||||||
// (any Archy node that receives it can try decrypting)
|
// (any Archy node that receives it can try decrypting)
|
||||||
let payload = shared_secrets.values().next()
|
let payload = shared_secrets
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
.and_then(|secret| {
|
.and_then(|secret| {
|
||||||
crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| {
|
crate::mesh::crypto::encrypt(secret, &wire).ok().map(|ct| {
|
||||||
let mut encrypted = Vec::with_capacity(1 + ct.len());
|
let mut encrypted = Vec::with_capacity(1 + ct.len());
|
||||||
@@ -60,32 +63,41 @@ impl RpcHandler {
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
|
||||||
let _ = shared_state
|
let _ = shared_state
|
||||||
.cmd_tx
|
.send_cmd(crate::mesh::listener::MeshCommand::BroadcastChannel {
|
||||||
.send(crate::mesh::listener::MeshCommand::BroadcastChannel {
|
|
||||||
channel: 0,
|
channel: 0,
|
||||||
payload: b64.into_bytes(),
|
payload: b64.into_bytes(),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
info!(request_id, tx_len = tx_hex.len(), "TX relay broadcast on mesh channel 0 (encrypted)");
|
info!(
|
||||||
|
request_id,
|
||||||
|
tx_len = tx_hex.len(),
|
||||||
|
"TX relay broadcast on mesh channel 0 (encrypted)"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Archy mode: E2E encrypted per-peer, direct to known Archy nodes
|
// Archy mode: E2E encrypted per-peer, direct to known Archy nodes
|
||||||
let peers = svc.peers().await;
|
let peers = svc.peers().await;
|
||||||
let shared_state = svc.shared_state();
|
let shared_state = svc.shared_state();
|
||||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||||
for peer in &peers {
|
for peer in &peers {
|
||||||
if !peer.advert_name.starts_with("Archy-") { continue; }
|
if !peer.advert_name.starts_with("Archy-") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(ref pk) = peer.pubkey_hex {
|
if let Some(ref pk) = peer.pubkey_hex {
|
||||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||||
if pk_bytes.len() >= 6 {
|
if pk_bytes.len() >= 6 {
|
||||||
let mut prefix = [0u8; 6];
|
let mut prefix = [0u8; 6];
|
||||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||||
|
|
||||||
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id) {
|
let payload = if let Some(secret) = shared_secrets.get(&peer.contact_id)
|
||||||
|
{
|
||||||
match crate::mesh::crypto::encrypt(secret, &wire) {
|
match crate::mesh::crypto::encrypt(secret, &wire) {
|
||||||
Ok(ciphertext) => {
|
Ok(ciphertext) => {
|
||||||
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
|
let mut encrypted =
|
||||||
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
Vec::with_capacity(1 + ciphertext.len());
|
||||||
|
encrypted.push(
|
||||||
|
crate::mesh::message_types::ENCRYPTED_TYPED_MARKER,
|
||||||
|
);
|
||||||
encrypted.extend_from_slice(&ciphertext);
|
encrypted.extend_from_slice(&ciphertext);
|
||||||
encrypted
|
encrypted
|
||||||
}
|
}
|
||||||
@@ -95,9 +107,9 @@ impl RpcHandler {
|
|||||||
wire.clone()
|
wire.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = svc.shared_state()
|
let _ = svc
|
||||||
.cmd_tx
|
.shared_state()
|
||||||
.send(crate::mesh::listener::MeshCommand::SendRaw {
|
.send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
|
||||||
dest_pubkey_prefix: prefix,
|
dest_pubkey_prefix: prefix,
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
@@ -108,7 +120,12 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(shared_secrets);
|
drop(shared_secrets);
|
||||||
info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers (E2E encrypted)");
|
info!(
|
||||||
|
request_id,
|
||||||
|
tx_len = tx_hex.len(),
|
||||||
|
archy_peers = sent_count,
|
||||||
|
"TX relay sent to Archy peers (E2E encrypted)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
@@ -128,7 +145,8 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing request_id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing request_id"))?;
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
let svc = service.as_ref()
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
// Check completed results first
|
// Check completed results first
|
||||||
@@ -169,7 +187,8 @@ impl RpcHandler {
|
|||||||
.unwrap_or(10) as usize;
|
.unwrap_or(10) as usize;
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
let svc = service.as_ref()
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
let headers = svc.block_header_cache.recent_headers(count).await;
|
let headers = svc.block_header_cache.recent_headers(count).await;
|
||||||
@@ -206,14 +225,19 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
let svc = service.as_ref()
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
let request_id = chrono::Utc::now().timestamp() as u64;
|
let request_id = chrono::Utc::now().timestamp() as u64;
|
||||||
svc.relay_tracker.track_lightning_relay(request_id, svc.our_did()).await;
|
svc.relay_tracker
|
||||||
|
.track_lightning_relay(request_id, svc.our_did())
|
||||||
|
.await;
|
||||||
|
|
||||||
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
|
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
|
||||||
bolt11, amount_sats, request_id,
|
bolt11,
|
||||||
|
amount_sats,
|
||||||
|
request_id,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Send to Archipelago peers — E2E encrypted per-peer
|
// Send to Archipelago peers — E2E encrypted per-peer
|
||||||
@@ -222,7 +246,9 @@ impl RpcHandler {
|
|||||||
let shared_secrets = shared_state.shared_secrets.read().await;
|
let shared_secrets = shared_state.shared_secrets.read().await;
|
||||||
let mut sent_count = 0u32;
|
let mut sent_count = 0u32;
|
||||||
for peer in &peers {
|
for peer in &peers {
|
||||||
if !peer.advert_name.starts_with("Archy-") { continue; }
|
if !peer.advert_name.starts_with("Archy-") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(ref pk) = peer.pubkey_hex {
|
if let Some(ref pk) = peer.pubkey_hex {
|
||||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
if let Ok(pk_bytes) = hex::decode(pk) {
|
||||||
if pk_bytes.len() >= 6 {
|
if pk_bytes.len() >= 6 {
|
||||||
@@ -233,7 +259,8 @@ impl RpcHandler {
|
|||||||
match crate::mesh::crypto::encrypt(secret, &wire) {
|
match crate::mesh::crypto::encrypt(secret, &wire) {
|
||||||
Ok(ciphertext) => {
|
Ok(ciphertext) => {
|
||||||
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
|
let mut encrypted = Vec::with_capacity(1 + ciphertext.len());
|
||||||
encrypted.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
encrypted
|
||||||
|
.push(crate::mesh::message_types::ENCRYPTED_TYPED_MARKER);
|
||||||
encrypted.extend_from_slice(&ciphertext);
|
encrypted.extend_from_slice(&ciphertext);
|
||||||
encrypted
|
encrypted
|
||||||
}
|
}
|
||||||
@@ -243,9 +270,9 @@ impl RpcHandler {
|
|||||||
wire.clone()
|
wire.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = svc.shared_state()
|
let _ = svc
|
||||||
.cmd_tx
|
.shared_state()
|
||||||
.send(crate::mesh::listener::MeshCommand::SendRaw {
|
.send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
|
||||||
dest_pubkey_prefix: prefix,
|
dest_pubkey_prefix: prefix,
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
@@ -257,7 +284,12 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
drop(shared_secrets);
|
drop(shared_secrets);
|
||||||
|
|
||||||
info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent (E2E encrypted)");
|
info!(
|
||||||
|
request_id,
|
||||||
|
amount_sats,
|
||||||
|
archy_peers = sent_count,
|
||||||
|
"Lightning relay sent (E2E encrypted)"
|
||||||
|
);
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"queued": true,
|
"queued": true,
|
||||||
|
|||||||
@@ -40,6 +40,39 @@ impl RpcHandler {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// mesh.send-channel — Send a text message to a mesh channel (broadcast).
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_send_channel(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
|
||||||
|
let channel = params.get("channel").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
|
||||||
|
|
||||||
|
let message = params
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing message"))?;
|
||||||
|
|
||||||
|
if message.is_empty() {
|
||||||
|
anyhow::bail!("Message cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running. Enable mesh first."))?;
|
||||||
|
|
||||||
|
let msg = svc.send_channel_message(channel, message).await?;
|
||||||
|
info!(channel, "Sent mesh channel message");
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"sent": true,
|
||||||
|
"message_id": msg.id,
|
||||||
|
"channel": channel,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// mesh.broadcast — Broadcast our node identity over mesh.
|
/// mesh.broadcast — Broadcast our node identity over mesh.
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_mesh_broadcast(&self) -> Result<serde_json::Value> {
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// mesh.deadman-status — Get dead man's switch status.
|
/// mesh.deadman-status — Get dead man's switch status.
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_deadman_status(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_mesh_deadman_status(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
let svc = service.as_ref()
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
let status = svc.dead_man_switch.status().await;
|
let status = svc.dead_man_switch.status().await;
|
||||||
@@ -53,7 +56,8 @@ impl RpcHandler {
|
|||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
let svc = service.as_ref()
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
let mut config = svc.dead_man_switch.get_config().await;
|
let mut config = svc.dead_man_switch.get_config().await;
|
||||||
@@ -71,7 +75,10 @@ impl RpcHandler {
|
|||||||
params.get("lat").and_then(|v| v.as_f64()),
|
params.get("lat").and_then(|v| v.as_f64()),
|
||||||
params.get("lng").and_then(|v| v.as_f64()),
|
params.get("lng").and_then(|v| v.as_f64()),
|
||||||
) {
|
) {
|
||||||
let label = params.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
|
let label = params
|
||||||
|
.get("label")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
|
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
|
||||||
}
|
}
|
||||||
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
|
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
|
||||||
@@ -97,9 +104,12 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
|
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_deadman_checkin(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_mesh_deadman_checkin(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
let svc = service.as_ref()
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
svc.dead_man_check_in().await;
|
svc.dead_man_check_in().await;
|
||||||
@@ -112,7 +122,9 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
|
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_rotate_prekeys(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_mesh_rotate_prekeys(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
// Load identity signing key
|
// Load identity signing key
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
let node_key_path = identity_dir.join("node_key");
|
let node_key_path = identity_dir.join("node_key");
|
||||||
@@ -162,7 +174,8 @@ impl RpcHandler {
|
|||||||
let count = params["count"].as_u64().unwrap_or(3) as usize;
|
let count = params["count"].as_u64().unwrap_or(3) as usize;
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
let svc = service.as_ref()
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
let mut sent = 0usize;
|
let mut sent = 0usize;
|
||||||
@@ -176,7 +189,10 @@ impl RpcHandler {
|
|||||||
"chunked" => {
|
"chunked" => {
|
||||||
// Send a TypedEnvelope that requires chunking (>140 base64 chars)
|
// Send a TypedEnvelope that requires chunking (>140 base64 chars)
|
||||||
let fake_tx = "0".repeat(400); // simulates TX hex
|
let fake_tx = "0".repeat(400); // simulates TX hex
|
||||||
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(&fake_tx, test_id as u64 + i as u64)?;
|
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(
|
||||||
|
&fake_tx,
|
||||||
|
test_id as u64 + i as u64,
|
||||||
|
)?;
|
||||||
// Send via SendRaw which handles base64 + chunking
|
// Send via SendRaw which handles base64 + chunking
|
||||||
let peers = svc.peers().await;
|
let peers = svc.peers().await;
|
||||||
if let Some(peer) = peers.iter().find(|p| p.contact_id == contact_id) {
|
if let Some(peer) = peers.iter().find(|p| p.contact_id == contact_id) {
|
||||||
@@ -185,12 +201,13 @@ impl RpcHandler {
|
|||||||
if pk_bytes.len() >= 6 {
|
if pk_bytes.len() >= 6 {
|
||||||
let mut prefix = [0u8; 6];
|
let mut prefix = [0u8; 6];
|
||||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
prefix.copy_from_slice(&pk_bytes[..6]);
|
||||||
let _ = svc.shared_state().cmd_tx.send(
|
let _ = svc
|
||||||
crate::mesh::listener::MeshCommand::SendRaw {
|
.shared_state()
|
||||||
|
.send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
|
||||||
dest_pubkey_prefix: prefix,
|
dest_pubkey_prefix: prefix,
|
||||||
payload: wire,
|
payload: wire,
|
||||||
},
|
})
|
||||||
).await;
|
.await;
|
||||||
sent += 1;
|
sent += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +223,13 @@ impl RpcHandler {
|
|||||||
// Send as plain text for ping/medium/large
|
// Send as plain text for ping/medium/large
|
||||||
let _msg = svc.send_message(contact_id, &payload).await?;
|
let _msg = svc.send_message(contact_id, &payload).await?;
|
||||||
sent += 1;
|
sent += 1;
|
||||||
info!(test_id, seq = i, mode, len = payload.len(), "Test message sent");
|
info!(
|
||||||
|
test_id,
|
||||||
|
seq = i,
|
||||||
|
mode,
|
||||||
|
len = payload.len(),
|
||||||
|
"Test message sent"
|
||||||
|
);
|
||||||
|
|
||||||
// Small delay between sends
|
// Small delay between sends
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
||||||
|
|||||||
@@ -70,6 +70,111 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// conversations.list — Unified inbox across mesh peers, mesh channels,
|
||||||
|
/// and federation nodes. Each conversation returns its latest message
|
||||||
|
/// timestamp + snippet + transport tag so the UI can render one sorted list.
|
||||||
|
pub(in crate::api::rpc) async fn handle_conversations_list(
|
||||||
|
&self,
|
||||||
|
_params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let mut conversations: Vec<serde_json::Value> = Vec::new();
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
if let Some(svc) = service.as_ref() {
|
||||||
|
let peers = svc.peers().await;
|
||||||
|
let messages = svc.messages(None).await;
|
||||||
|
// Per-peer last message.
|
||||||
|
for peer in &peers {
|
||||||
|
let last = messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|m| m.peer_contact_id == peer.contact_id);
|
||||||
|
let is_federation = peer.contact_id & 0x8000_0000 != 0;
|
||||||
|
conversations.push(serde_json::json!({
|
||||||
|
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
|
||||||
|
"transport": if is_federation { "federation" } else { "mesh" },
|
||||||
|
"contact_id": peer.contact_id,
|
||||||
|
"name": peer.advert_name,
|
||||||
|
"pubkey": peer.pubkey_hex,
|
||||||
|
"last_text": last.map(|m| m.plaintext.clone()),
|
||||||
|
"last_timestamp": last.map(|m| m.timestamp.clone()),
|
||||||
|
"last_direction": last.map(|m| format!("{:?}", m.direction).to_lowercase()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Channel 0 ("Archipelago") as a synthetic conversation.
|
||||||
|
let channel_last = messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|m| m.message_type == "text" && m.peer_contact_id == 0);
|
||||||
|
conversations.push(serde_json::json!({
|
||||||
|
"id": "channel:0",
|
||||||
|
"transport": "channel",
|
||||||
|
"channel": 0,
|
||||||
|
"name": "Archipelago",
|
||||||
|
"last_text": channel_last.map(|m| m.plaintext.clone()),
|
||||||
|
"last_timestamp": channel_last.map(|m| m.timestamp.clone()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Sort by last_timestamp desc (missing timestamps sink).
|
||||||
|
conversations.sort_by(|a, b| {
|
||||||
|
let at = a
|
||||||
|
.get("last_timestamp")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let bt = b
|
||||||
|
.get("last_timestamp")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
bt.cmp(at)
|
||||||
|
});
|
||||||
|
Ok(serde_json::json!({ "conversations": conversations }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// conversations.messages — Return messages for a ConversationId string
|
||||||
|
/// (format: `mesh:<contact_id>` | `federation:<contact_id>` | `channel:<u8>`).
|
||||||
|
pub(in crate::api::rpc) async fn handle_conversations_messages(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let id = params["id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
|
||||||
|
let (kind, rest) = id
|
||||||
|
.split_once(':')
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Invalid conversation id"))?;
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
let svc = service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
let all = svc.messages(None).await;
|
||||||
|
let filtered: Vec<_> = match kind {
|
||||||
|
"mesh" | "federation" => {
|
||||||
|
let contact_id: u32 = rest.parse().unwrap_or(0);
|
||||||
|
all.into_iter()
|
||||||
|
.filter(|m| m.peer_contact_id == contact_id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
"channel" => {
|
||||||
|
// For now the channel bucket keeps contact_id = 0.
|
||||||
|
all.into_iter().filter(|m| m.peer_contact_id == 0).collect()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
Ok(serde_json::json!({ "messages": filtered }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// mesh.debug-dump — Full in-memory state snapshot for debugging.
|
||||||
|
/// Returns peers, all messages, status, shared-secret peer ids, encrypt_relay
|
||||||
|
/// flag, and stego mode. Intended for smoke tests and bug investigation.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_debug_dump(&self) -> Result<serde_json::Value> {
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
if let Some(svc) = service.as_ref() {
|
||||||
|
Ok(svc.debug_dump().await)
|
||||||
|
} else {
|
||||||
|
Ok(serde_json::json!({ "running": false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// mesh.session-status — Get ratchet session info for a peer.
|
/// mesh.session-status — Get ratchet session info for a peer.
|
||||||
pub(in crate::api::rpc) async fn handle_mesh_session_status(
|
pub(in crate::api::rpc) async fn handle_mesh_session_status(
|
||||||
&self,
|
&self,
|
||||||
@@ -84,7 +189,10 @@ impl RpcHandler {
|
|||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
let peer_did = if let Some(svc) = service.as_ref() {
|
let peer_did = if let Some(svc) = service.as_ref() {
|
||||||
let peers = svc.peers().await;
|
let peers = svc.peers().await;
|
||||||
peers.iter().find(|p| p.contact_id == contact_id).and_then(|p| p.did.clone())
|
peers
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.contact_id == contact_id)
|
||||||
|
.and_then(|p| p.did.clone())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -118,4 +226,76 @@ impl RpcHandler {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// mesh.clear-all — Nuclear reset: wipe all mesh state files and restart
|
||||||
|
/// the service for a completely clean slate.
|
||||||
|
pub(in crate::api::rpc) async fn handle_mesh_clear_all(&self) -> Result<serde_json::Value> {
|
||||||
|
let data_dir = self.config.data_dir.clone();
|
||||||
|
// Delete all mesh state files
|
||||||
|
for filename in &[
|
||||||
|
"messages.json",
|
||||||
|
"mesh-contacts.json",
|
||||||
|
"sessions.json",
|
||||||
|
"mesh-outbox.json",
|
||||||
|
] {
|
||||||
|
let _ = tokio::fs::remove_file(data_dir.join(filename)).await;
|
||||||
|
}
|
||||||
|
// Clear in-memory state
|
||||||
|
let service = self.mesh_service.read().await;
|
||||||
|
if let Some(svc) = service.as_ref() {
|
||||||
|
let state = svc.state();
|
||||||
|
|
||||||
|
// Snapshot the firmware pubkeys we currently know about, then
|
||||||
|
// add them to the radio-contact blocklist. MeshCore's on-device
|
||||||
|
// contact table is persistent and reads back stale rows on the
|
||||||
|
// next refresh_contacts, so without this step `clear-all` only
|
||||||
|
// wipes the app view for a few seconds before the old entries
|
||||||
|
// reappear. The blocklist is also saved to disk so the filter
|
||||||
|
// survives a restart.
|
||||||
|
let firmware_pubkeys: Vec<String> = state
|
||||||
|
.peers
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.filter_map(|p| {
|
||||||
|
// Federation-synthetic peers have their contact_id in the
|
||||||
|
// high half of u32 and carry the archipelago key — those
|
||||||
|
// aren't firmware contacts and must not go on the list.
|
||||||
|
if p.contact_id & 0x8000_0000 != 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
p.pubkey_hex.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
{
|
||||||
|
let mut set = state.radio_contact_blocklist.write().await;
|
||||||
|
for pk in &firmware_pubkeys {
|
||||||
|
set.insert(pk.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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.messages.write().await.clear();
|
||||||
|
state.contacts.write().await.clear();
|
||||||
|
state.presence.write().await.clear();
|
||||||
|
state.chunk_buffer.write().await.clear();
|
||||||
|
state.shared_secrets.write().await.clear();
|
||||||
|
// Re-seed federation peers
|
||||||
|
crate::mesh::seed_federation_peers_into_mesh(state, &data_dir).await;
|
||||||
|
// Trigger a contact refresh from the radio device
|
||||||
|
let _ = state
|
||||||
|
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(serde_json::json!({ "status": "cleared" }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -38,10 +38,7 @@ pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Methods whose responses can be cached for a few seconds.
|
/// Methods whose responses can be cached for a few seconds.
|
||||||
pub(super) const CACHEABLE_METHODS: &[&str] = &[
|
pub(super) const CACHEABLE_METHODS: &[&str] = &["system.stats", "federation.list-nodes"];
|
||||||
"system.stats",
|
|
||||||
"federation.list-nodes",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Sanitize error messages before returning to clients.
|
/// Sanitize error messages before returning to clients.
|
||||||
/// Keeps user-facing validation errors but strips internal system details.
|
/// Keeps user-facing validation errors but strips internal system details.
|
||||||
@@ -69,7 +66,8 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
|||||||
for prefix in &user_facing_prefixes {
|
for prefix in &user_facing_prefixes {
|
||||||
if msg.starts_with(prefix) {
|
if msg.starts_with(prefix) {
|
||||||
// Truncate long messages and strip file paths
|
// Truncate long messages and strip file paths
|
||||||
let sanitized = msg.replace("/var/lib/archipelago/", "[data]/")
|
let sanitized = msg
|
||||||
|
.replace("/var/lib/archipelago/", "[data]/")
|
||||||
.replace("/usr/local/bin/", "[bin]/")
|
.replace("/usr/local/bin/", "[bin]/")
|
||||||
.replace("/etc/", "[config]/");
|
.replace("/etc/", "[config]/");
|
||||||
return if sanitized.len() > 200 {
|
return if sanitized.len() > 200 {
|
||||||
|
|||||||
@@ -8,15 +8,16 @@ mod credentials;
|
|||||||
mod dispatcher;
|
mod dispatcher;
|
||||||
mod dwn;
|
mod dwn;
|
||||||
mod federation;
|
mod federation;
|
||||||
|
mod fips;
|
||||||
mod handshake;
|
mod handshake;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod interfaces;
|
mod interfaces;
|
||||||
|
mod lnd;
|
||||||
mod marketplace;
|
mod marketplace;
|
||||||
|
mod mesh;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod monitoring;
|
mod monitoring;
|
||||||
mod names;
|
mod names;
|
||||||
mod lnd;
|
|
||||||
mod mesh;
|
|
||||||
mod network;
|
mod network;
|
||||||
mod node;
|
mod node;
|
||||||
mod nostr;
|
mod nostr;
|
||||||
@@ -24,12 +25,13 @@ mod package;
|
|||||||
mod peers;
|
mod peers;
|
||||||
mod response;
|
mod response;
|
||||||
mod router;
|
mod router;
|
||||||
mod seed_rpc;
|
|
||||||
mod security;
|
mod security;
|
||||||
mod tor;
|
mod seed_rpc;
|
||||||
mod transport;
|
mod streaming;
|
||||||
mod totp;
|
|
||||||
mod system;
|
mod system;
|
||||||
|
mod tor;
|
||||||
|
mod totp;
|
||||||
|
mod transport;
|
||||||
mod update;
|
mod update;
|
||||||
mod vpn;
|
mod vpn;
|
||||||
mod wallet;
|
mod wallet;
|
||||||
@@ -49,10 +51,10 @@ use std::sync::Arc;
|
|||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
use middleware::{
|
use middleware::{
|
||||||
UNAUTHENTICATED_METHODS, CACHEABLE_METHODS,
|
|
||||||
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
|
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
|
||||||
|
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
|
||||||
};
|
};
|
||||||
use response::{RpcRequest, RpcResponse, RpcError, ResponseCache, json_response, cookie_header};
|
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
|
||||||
|
|
||||||
/// Default dev password when no user is set up (matches mock-backend).
|
/// Default dev password when no user is set up (matches mock-backend).
|
||||||
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
|
pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
|
||||||
@@ -70,6 +72,13 @@ pub struct RpcHandler {
|
|||||||
response_cache: ResponseCache,
|
response_cache: ResponseCache,
|
||||||
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
|
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
|
||||||
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
|
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
|
||||||
|
/// Shared content-addressed blob store. Set by ApiHandler after construction
|
||||||
|
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
|
||||||
|
/// second instance and duplicated cap_key.
|
||||||
|
pub(crate) blob_store: Arc<tokio::sync::RwLock<Option<Arc<crate::blobs::BlobStore>>>>,
|
||||||
|
/// Our own Ed25519 pubkey hex — needed by ContentRef senders for cap scoping
|
||||||
|
/// and by ContentRef receivers to request caps scoped to themselves.
|
||||||
|
pub(crate) self_pubkey_hex: Arc<tokio::sync::RwLock<Option<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
@@ -87,7 +96,9 @@ impl RpcHandler {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let port_allocator = Arc::new(tokio::sync::Mutex::new(PortAllocator::new(&config.data_dir).await?));
|
let port_allocator = Arc::new(tokio::sync::Mutex::new(
|
||||||
|
PortAllocator::new(&config.data_dir).await?,
|
||||||
|
));
|
||||||
|
|
||||||
let login_rate_limiter = LoginRateLimiter::new();
|
let login_rate_limiter = LoginRateLimiter::new();
|
||||||
let endpoint_rate_limiter = EndpointRateLimiter::new();
|
let endpoint_rate_limiter = EndpointRateLimiter::new();
|
||||||
@@ -127,11 +138,19 @@ impl RpcHandler {
|
|||||||
response_cache: ResponseCache::new(5),
|
response_cache: ResponseCache::new(5),
|
||||||
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
|
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
|
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
|
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the mesh service (called after identity is loaded).
|
/// Set the mesh service (called after identity is loaded).
|
||||||
pub async fn set_mesh_service(&self, service: crate::mesh::MeshService) {
|
pub async fn set_mesh_service(&self, service: crate::mesh::MeshService) {
|
||||||
|
// If the blob store is already initialised, propagate it into the
|
||||||
|
// freshly-started mesh state so the listener can persist inline
|
||||||
|
// attachments. Mirrors `set_blob_store`'s forward-propagation.
|
||||||
|
if let Some(store) = self.blob_store.read().await.as_ref().cloned() {
|
||||||
|
*service.shared_state().blob_store.write().await = Some(store);
|
||||||
|
}
|
||||||
*self.mesh_service.write().await = Some(service);
|
*self.mesh_service.write().await = Some(service);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +159,22 @@ impl RpcHandler {
|
|||||||
*self.transport_router.write().await = Some(router);
|
*self.transport_router.write().await = Some(router);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Share the blob store + our pubkey so mesh.send-content / fetch-content
|
||||||
|
/// can reach them. Called once from ApiHandler::new.
|
||||||
|
pub async fn set_blob_store(
|
||||||
|
&self,
|
||||||
|
store: Arc<crate::blobs::BlobStore>,
|
||||||
|
self_pubkey_hex: String,
|
||||||
|
) {
|
||||||
|
*self.blob_store.write().await = Some(store.clone());
|
||||||
|
*self.self_pubkey_hex.write().await = Some(self_pubkey_hex);
|
||||||
|
// Propagate into a running mesh service if one is already up — keeps
|
||||||
|
// `set_blob_store` and `set_mesh_service` order-independent.
|
||||||
|
if let Some(svc) = self.mesh_service.read().await.as_ref() {
|
||||||
|
*svc.shared_state().blob_store.write().await = Some(store);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get reference to the mesh service Arc (for MeshTransport wrapper).
|
/// Get reference to the mesh service Arc (for MeshTransport wrapper).
|
||||||
pub fn mesh_service_arc(&self) -> Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>> {
|
pub fn mesh_service_arc(&self) -> Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>> {
|
||||||
Arc::clone(&self.mesh_service)
|
Arc::clone(&self.mesh_service)
|
||||||
@@ -162,20 +197,18 @@ impl RpcHandler {
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle(
|
pub async fn handle(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
|
||||||
&self,
|
|
||||||
req: Request<hyper::Body>,
|
|
||||||
) -> Result<Response<hyper::Body>> {
|
|
||||||
// Extract session cookie before consuming the request
|
// Extract session cookie before consuming the request
|
||||||
let (parts, body) = req.into_parts();
|
let (parts, body) = req.into_parts();
|
||||||
let session_token = session::extract_session_cookie(&parts.headers);
|
let session_token = session::extract_session_cookie(&parts.headers);
|
||||||
let secure_suffix = self.cookie_suffix_for_request(&parts.headers);
|
let secure_suffix = self.cookie_suffix_for_request(&parts.headers);
|
||||||
|
|
||||||
let body_bytes = hyper::body::to_bytes(body).await
|
let body_bytes = hyper::body::to_bytes(body)
|
||||||
|
.await
|
||||||
.context("Failed to read body")?;
|
.context("Failed to read body")?;
|
||||||
|
|
||||||
let rpc_req: RpcRequest = serde_json::from_slice(&body_bytes)
|
let rpc_req: RpcRequest =
|
||||||
.context("Invalid RPC request")?;
|
serde_json::from_slice(&body_bytes).context("Invalid RPC request")?;
|
||||||
|
|
||||||
debug!("RPC method: {}", rpc_req.method);
|
debug!("RPC method: {}", rpc_req.method);
|
||||||
|
|
||||||
@@ -202,7 +235,11 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !authenticated {
|
if !authenticated {
|
||||||
let reason = if session_token.is_none() { "no session cookie" } else { "invalid/expired token" };
|
let reason = if session_token.is_none() {
|
||||||
|
"no session cookie"
|
||||||
|
} else {
|
||||||
|
"invalid/expired token"
|
||||||
|
};
|
||||||
tracing::warn!(method = %rpc_req.method, reason, "401 Unauthorized — rejecting RPC call");
|
tracing::warn!(method = %rpc_req.method, reason, "401 Unauthorized — rejecting RPC call");
|
||||||
return Ok(self.error_response(401, "Unauthorized", StatusCode::UNAUTHORIZED));
|
return Ok(self.error_response(401, "Unauthorized", StatusCode::UNAUTHORIZED));
|
||||||
}
|
}
|
||||||
@@ -212,7 +249,11 @@ impl RpcHandler {
|
|||||||
if !is_unauthenticated {
|
if !is_unauthenticated {
|
||||||
if let Ok(Some(user)) = self.auth_manager.get_user().await {
|
if let Ok(Some(user)) = self.auth_manager.get_user().await {
|
||||||
if !user.role.can_access(&rpc_req.method) {
|
if !user.role.can_access(&rpc_req.method) {
|
||||||
return Ok(self.error_response(403, "Forbidden: insufficient permissions", StatusCode::FORBIDDEN));
|
return Ok(self.error_response(
|
||||||
|
403,
|
||||||
|
"Forbidden: insufficient permissions",
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,11 +261,19 @@ impl RpcHandler {
|
|||||||
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
|
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
|
||||||
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
|
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
|
||||||
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
|
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
|
||||||
let csrf_exempt = matches!(rpc_req.method.as_str(),
|
let csrf_exempt = matches!(
|
||||||
"node-messages-received" | "server.echo" | "server.get-state"
|
rpc_req.method.as_str(),
|
||||||
| "system.stats" | "tor.status"
|
"node-messages-received"
|
||||||
| "tor.onion-addresses" | "federation.list-nodes" | "system.get-settings"
|
| "server.echo"
|
||||||
| "system.get-node-key" | "system.get-metrics" | "system.get-version"
|
| "server.get-state"
|
||||||
|
| "system.stats"
|
||||||
|
| "tor.status"
|
||||||
|
| "tor.onion-addresses"
|
||||||
|
| "federation.list-nodes"
|
||||||
|
| "system.get-settings"
|
||||||
|
| "system.get-node-key"
|
||||||
|
| "system.get-metrics"
|
||||||
|
| "system.get-version"
|
||||||
);
|
);
|
||||||
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
|
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
|
||||||
let csrf_header = parts
|
let csrf_header = parts
|
||||||
@@ -241,7 +290,9 @@ impl RpcHandler {
|
|||||||
let secret = SessionStore::load_or_create_remember_secret().await;
|
let secret = SessionStore::load_or_create_remember_secret().await;
|
||||||
let mut mac = match HmacSha256::new_from_slice(&secret) {
|
let mut mac = match HmacSha256::new_from_slice(&secret) {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(_) => { return Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, b"{}")); }
|
Err(_) => {
|
||||||
|
return Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, b"{}"));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
mac.update(format!("csrf:{}", token).as_bytes());
|
mac.update(format!("csrf:{}", token).as_bytes());
|
||||||
match hex::decode(header) {
|
match hex::decode(header) {
|
||||||
@@ -271,7 +322,11 @@ impl RpcHandler {
|
|||||||
"403 CSRF validation failed — rejecting RPC call"
|
"403 CSRF validation failed — rejecting RPC call"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Ok(self.error_response(403, "CSRF token missing or invalid", StatusCode::FORBIDDEN));
|
return Ok(self.error_response(
|
||||||
|
403,
|
||||||
|
"CSRF token missing or invalid",
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,10 +341,16 @@ impl RpcHandler {
|
|||||||
// Rate limit sensitive endpoints
|
// Rate limit sensitive endpoints
|
||||||
{
|
{
|
||||||
let client_ip = extract_client_ip(&parts.headers);
|
let client_ip = extract_client_ip(&parts.headers);
|
||||||
if !self.endpoint_rate_limiter.check(&rpc_req.method, client_ip).await {
|
if !self
|
||||||
|
.endpoint_rate_limiter
|
||||||
|
.check(&rpc_req.method, client_ip)
|
||||||
|
.await
|
||||||
|
{
|
||||||
return Ok(self.rate_limit_response());
|
return Ok(self.rate_limit_response());
|
||||||
}
|
}
|
||||||
self.endpoint_rate_limiter.record(&rpc_req.method, client_ip).await;
|
self.endpoint_rate_limiter
|
||||||
|
.record(&rpc_req.method, client_ip)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract params; clone for post-routing use (login 2FA check needs password)
|
// Extract params; clone for post-routing use (login 2FA check needs password)
|
||||||
@@ -325,7 +386,9 @@ impl RpcHandler {
|
|||||||
let mut rpc_resp = match result {
|
let mut rpc_resp = match result {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
if is_cacheable {
|
if is_cacheable {
|
||||||
self.response_cache.set(rpc_req.method.clone(), data.clone()).await;
|
self.response_cache
|
||||||
|
.set(rpc_req.method.clone(), data.clone())
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
RpcResponse {
|
RpcResponse {
|
||||||
result: Some(data),
|
result: Some(data),
|
||||||
@@ -346,8 +409,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let resp_body = serde_json::to_vec(&rpc_resp)
|
let resp_body = serde_json::to_vec(&rpc_resp).context("Failed to serialize response")?;
|
||||||
.context("Failed to serialize response")?;
|
|
||||||
|
|
||||||
let mut response = json_response(StatusCode::OK, &resp_body);
|
let mut response = json_response(StatusCode::OK, &resp_body);
|
||||||
|
|
||||||
@@ -362,13 +424,19 @@ impl RpcHandler {
|
|||||||
&new_session_cookies,
|
&new_session_cookies,
|
||||||
client_ip,
|
client_ip,
|
||||||
secure_suffix,
|
secure_suffix,
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a JSON error response with the given RPC error code and HTTP status.
|
/// Build a JSON error response with the given RPC error code and HTTP status.
|
||||||
fn error_response(&self, code: i32, message: &str, status: StatusCode) -> Response<hyper::Body> {
|
fn error_response(
|
||||||
|
&self,
|
||||||
|
code: i32,
|
||||||
|
message: &str,
|
||||||
|
status: StatusCode,
|
||||||
|
) -> Response<hyper::Body> {
|
||||||
let rpc_resp = RpcResponse {
|
let rpc_resp = RpcResponse {
|
||||||
result: None,
|
result: None,
|
||||||
error: Some(RpcError {
|
error: Some(RpcError {
|
||||||
@@ -393,7 +461,8 @@ impl RpcHandler {
|
|||||||
};
|
};
|
||||||
let resp_body = serde_json::to_vec(&rpc_resp).unwrap_or_default();
|
let resp_body = serde_json::to_vec(&rpc_resp).unwrap_or_default();
|
||||||
let mut resp = json_response(StatusCode::TOO_MANY_REQUESTS, &resp_body);
|
let mut resp = json_response(StatusCode::TOO_MANY_REQUESTS, &resp_body);
|
||||||
resp.headers_mut().insert("Retry-After", cookie_header("60"));
|
resp.headers_mut()
|
||||||
|
.insert("Retry-After", cookie_header("60"));
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,9 +502,8 @@ impl RpcHandler {
|
|||||||
"result": { "requires_totp": true },
|
"result": { "requires_totp": true },
|
||||||
"error": null
|
"error": null
|
||||||
});
|
});
|
||||||
*response.body_mut() = hyper::Body::from(
|
*response.body_mut() =
|
||||||
serde_json::to_vec(&totp_body).unwrap_or_default(),
|
hyper::Body::from(serde_json::to_vec(&totp_body).unwrap_or_default());
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -493,11 +561,17 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
cookie_header(&format!("session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)),
|
cookie_header(&format!(
|
||||||
|
"session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0{}",
|
||||||
|
secure_suffix
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
cookie_header(&format!("csrf_token=; SameSite=Lax; Path=/; Max-Age=0{}", secure_suffix)),
|
cookie_header(&format!(
|
||||||
|
"csrf_token=; SameSite=Lax; Path=/; Max-Age=0{}",
|
||||||
|
secure_suffix
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,24 +582,48 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_session_cookie(&self, response: &mut Response<hyper::Body>, token: &str, secure_suffix: &str) {
|
fn set_session_cookie(
|
||||||
|
&self,
|
||||||
|
response: &mut Response<hyper::Body>,
|
||||||
|
token: &str,
|
||||||
|
secure_suffix: &str,
|
||||||
|
) {
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
cookie_header(&format!("session={}; HttpOnly; SameSite=Lax; Path=/{}", token, secure_suffix)),
|
cookie_header(&format!(
|
||||||
|
"session={}; HttpOnly; SameSite=Lax; Path=/{}",
|
||||||
|
token, secure_suffix
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_csrf_cookie(&self, response: &mut Response<hyper::Body>, csrf_token: &str, secure_suffix: &str) {
|
fn set_csrf_cookie(
|
||||||
|
&self,
|
||||||
|
response: &mut Response<hyper::Body>,
|
||||||
|
csrf_token: &str,
|
||||||
|
secure_suffix: &str,
|
||||||
|
) {
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
cookie_header(&format!("csrf_token={}; SameSite=Lax; Path=/{}", csrf_token, secure_suffix)),
|
cookie_header(&format!(
|
||||||
|
"csrf_token={}; SameSite=Lax; Path=/{}",
|
||||||
|
csrf_token, secure_suffix
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_remember_cookie(&self, response: &mut Response<hyper::Body>, remember_token: &str, secure_suffix: &str) {
|
fn set_remember_cookie(
|
||||||
|
&self,
|
||||||
|
response: &mut Response<hyper::Body>,
|
||||||
|
remember_token: &str,
|
||||||
|
secure_suffix: &str,
|
||||||
|
) {
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
cookie_header(&format!("remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}", remember_token, REMEMBER_TTL, secure_suffix)),
|
cookie_header(&format!(
|
||||||
|
"remember={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}",
|
||||||
|
remember_token, REMEMBER_TTL, secure_suffix
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ impl RpcHandler {
|
|||||||
|
|
||||||
match self.metrics_store.latest().await {
|
match self.metrics_store.latest().await {
|
||||||
Some(snapshot) => Ok(serde_json::to_value(snapshot)?),
|
Some(snapshot) => Ok(serde_json::to_value(snapshot)?),
|
||||||
None => Ok(serde_json::json!({ "status": "collecting", "message": "No metrics collected yet" })),
|
None => Ok(
|
||||||
|
serde_json::json!({ "status": "collecting", "message": "No metrics collected yet" }),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,14 +151,12 @@ impl RpcHandler {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match format {
|
match format {
|
||||||
"json" => {
|
"json" => Ok(serde_json::json!({
|
||||||
Ok(serde_json::json!({
|
"format": "json",
|
||||||
"format": "json",
|
"resolution": resolution,
|
||||||
"resolution": resolution,
|
"count": data.len(),
|
||||||
"count": data.len(),
|
"data": data,
|
||||||
"data": data,
|
})),
|
||||||
}))
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
// CSV format
|
// CSV format
|
||||||
let mut csv = String::from(
|
let mut csv = String::from(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! RPC handlers for node network visibility and overlay controls.
|
//! RPC handlers for node network visibility and overlay controls.
|
||||||
|
|
||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use crate::{identity, peers};
|
|
||||||
use crate::container::docker_packages;
|
use crate::container::docker_packages;
|
||||||
|
use crate::{identity, peers};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
@@ -94,19 +94,29 @@ impl RpcHandler {
|
|||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let to_did = params.get("did").and_then(|v| v.as_str())
|
let to_did = params
|
||||||
|
.get("did")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: did"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: did"))?;
|
||||||
let to_onion = params.get("onion").and_then(|v| v.as_str())
|
let to_onion = params
|
||||||
|
.get("onion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: onion"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: onion"))?;
|
||||||
let to_pubkey = params.get("pubkey").and_then(|v| v.as_str())
|
let to_pubkey = params
|
||||||
|
.get("pubkey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: pubkey"))?;
|
||||||
let message = params.get("message").and_then(|v| v.as_str()).map(String::from);
|
let message = params
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
// Send a message to the peer over Tor with connection request
|
// Send a message to the peer over Tor with connection request
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let my_pubkey = &data.server_info.pubkey;
|
let my_pubkey = &data.server_info.pubkey;
|
||||||
let my_did = identity::did_key_from_pubkey_hex(my_pubkey)?;
|
let my_did = identity::did_key_from_pubkey_hex(my_pubkey)?;
|
||||||
let my_onion = docker_packages::read_tor_address("archipelago").await
|
let my_onion = docker_packages::read_tor_address("archipelago")
|
||||||
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let req_msg = serde_json::json!({
|
let req_msg = serde_json::json!({
|
||||||
@@ -117,13 +127,18 @@ impl RpcHandler {
|
|||||||
"message": message,
|
"message": message,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let to_fips_npub =
|
||||||
|
crate::federation::fips_npub_for_onion(&self.config.data_dir, to_onion).await;
|
||||||
crate::node_message::send_to_peer(
|
crate::node_message::send_to_peer(
|
||||||
to_onion,
|
to_onion,
|
||||||
|
to_fips_npub.as_deref(),
|
||||||
my_pubkey,
|
my_pubkey,
|
||||||
&req_msg.to_string(),
|
&req_msg.to_string(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
).await?;
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Also add them as a pending peer locally
|
// Also add them as a pending peer locally
|
||||||
let req = ConnectionRequest {
|
let req = ConnectionRequest {
|
||||||
@@ -145,18 +160,25 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "requests": requests }))
|
Ok(serde_json::json!({ "requests": requests }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept a connection request — add peer to trusted list.
|
/// Accept a connection request — add peer to trusted list AND send
|
||||||
|
/// a `connection_accepted` notification back to the requester so
|
||||||
|
/// their side auto-adds us without a second manual round-trip.
|
||||||
pub(super) async fn handle_network_accept_request(
|
pub(super) async fn handle_network_accept_request(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let request_id = params.get("id").and_then(|v| v.as_str())
|
let request_id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||||
|
|
||||||
let requests = self.load_requests().await?;
|
let requests = self.load_requests().await?;
|
||||||
let req = requests.iter().find(|r| r.id == request_id)
|
let req = requests
|
||||||
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?;
|
.iter()
|
||||||
|
.find(|r| r.id == request_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
// Add to known peers
|
// Add to known peers
|
||||||
let peer = peers::KnownPeer {
|
let peer = peers::KnownPeer {
|
||||||
@@ -170,6 +192,47 @@ impl RpcHandler {
|
|||||||
// Remove the request
|
// Remove the request
|
||||||
self.delete_request(request_id).await?;
|
self.delete_request(request_id).await?;
|
||||||
|
|
||||||
|
// Notify the requester we've accepted so their UI auto-adds us and
|
||||||
|
// clears its outbound pending row. Best-effort — if the peer is
|
||||||
|
// offline we don't fail the accept; the next connection_request
|
||||||
|
// retry on their side will resolve eventually.
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let my_pubkey = data.server_info.pubkey.clone();
|
||||||
|
let my_did = crate::identity::did_key_from_pubkey_hex(&my_pubkey).ok();
|
||||||
|
let my_onion = crate::container::docker_packages::read_tor_address("archipelago")
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let accept_msg = serde_json::json!({
|
||||||
|
"type": "connection_accepted",
|
||||||
|
"request_id": request_id,
|
||||||
|
"from_did": my_did,
|
||||||
|
"from_onion": my_onion,
|
||||||
|
"from_pubkey": my_pubkey,
|
||||||
|
});
|
||||||
|
let to_fips_npub =
|
||||||
|
crate::federation::fips_npub_for_onion(&self.config.data_dir, &req.from_onion).await;
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let signing_key = crate::identity::NodeIdentity::load_or_create(&identity_dir)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
if let Err(e) = crate::node_message::send_to_peer(
|
||||||
|
&req.from_onion,
|
||||||
|
to_fips_npub.as_deref(),
|
||||||
|
&my_pubkey,
|
||||||
|
&accept_msg.to_string(),
|
||||||
|
signing_key.as_ref().map(|i| i.signing_key()),
|
||||||
|
Some(&req.from_pubkey),
|
||||||
|
data.server_info.name.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
to = %req.from_did,
|
||||||
|
error = %e,
|
||||||
|
"connection_accepted notify failed (requester will still be able to see us on their next retry)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!("Accepted connection from {}", req.from_did);
|
tracing::info!("Accepted connection from {}", req.from_did);
|
||||||
Ok(serde_json::json!({ "ok": true }))
|
Ok(serde_json::json!({ "ok": true }))
|
||||||
}
|
}
|
||||||
@@ -180,7 +243,9 @@ impl RpcHandler {
|
|||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.unwrap_or_default();
|
let params = params.unwrap_or_default();
|
||||||
let request_id = params.get("id").and_then(|v| v.as_str())
|
let request_id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||||
|
|
||||||
self.delete_request(request_id).await?;
|
self.delete_request(request_id).await?;
|
||||||
@@ -200,7 +265,9 @@ impl RpcHandler {
|
|||||||
|
|
||||||
async fn requests_dir(&self) -> Result<std::path::PathBuf> {
|
async fn requests_dir(&self) -> Result<std::path::PathBuf> {
|
||||||
let dir = self.config.data_dir.join(REQUESTS_DIR);
|
let dir = self.config.data_dir.join(REQUESTS_DIR);
|
||||||
fs::create_dir_all(&dir).await.context("Failed to create requests dir")?;
|
fs::create_dir_all(&dir)
|
||||||
|
.await
|
||||||
|
.context("Failed to create requests dir")?;
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +275,9 @@ impl RpcHandler {
|
|||||||
let dir = self.requests_dir().await?;
|
let dir = self.requests_dir().await?;
|
||||||
let path = dir.join(format!("{}.json", req.id));
|
let path = dir.join(format!("{}.json", req.id));
|
||||||
let json = serde_json::to_string_pretty(req).context("Failed to serialize request")?;
|
let json = serde_json::to_string_pretty(req).context("Failed to serialize request")?;
|
||||||
fs::write(&path, json).await.context("Failed to write request")?;
|
fs::write(&path, json)
|
||||||
|
.await
|
||||||
|
.context("Failed to write request")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,13 +302,21 @@ impl RpcHandler {
|
|||||||
|
|
||||||
async fn delete_request(&self, id: &str) -> Result<()> {
|
async fn delete_request(&self, id: &str) -> Result<()> {
|
||||||
// Validate ID to prevent path traversal
|
// Validate ID to prevent path traversal
|
||||||
if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
|
if id.is_empty()
|
||||||
|
|| id.len() > 128
|
||||||
|
|| id.contains('/')
|
||||||
|
|| id.contains('\\')
|
||||||
|
|| id.contains("..")
|
||||||
|
|| id.contains('\0')
|
||||||
|
{
|
||||||
anyhow::bail!("Invalid request ID");
|
anyhow::bail!("Invalid request ID");
|
||||||
}
|
}
|
||||||
let dir = self.requests_dir().await?;
|
let dir = self.requests_dir().await?;
|
||||||
let path = dir.join(format!("{}.json", id));
|
let path = dir.join(format!("{}.json", id));
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
fs::remove_file(&path).await.context("Failed to delete request")?;
|
fs::remove_file(&path)
|
||||||
|
.await
|
||||||
|
.context("Failed to delete request")?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use crate::{backup, identity, nostr_discovery};
|
|
||||||
use crate::container::docker_packages;
|
use crate::container::docker_packages;
|
||||||
|
use crate::{backup, identity, nostr_discovery};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
use nostr_sdk::ToBech32;
|
use nostr_sdk::ToBech32;
|
||||||
@@ -103,21 +103,31 @@ impl RpcHandler {
|
|||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
let pubkey_hex = nostr_discovery::get_nostr_pubkey(&identity_dir).await?;
|
let pubkey_hex = nostr_discovery::get_nostr_pubkey(&identity_dir).await?;
|
||||||
|
|
||||||
let event = params.get("event")
|
let event = params
|
||||||
|
.get("event")
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'event' parameter"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'event' parameter"))?;
|
||||||
|
|
||||||
let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(1);
|
let kind = event.get("kind").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||||
let content = event.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
let content = event.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let created_at = event.get("created_at").and_then(|v| v.as_u64())
|
let created_at = event
|
||||||
.unwrap_or_else(|| std::time::SystemTime::now()
|
.get("created_at")
|
||||||
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs());
|
.and_then(|v| v.as_u64())
|
||||||
let tags = event.get("tags").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.unwrap_or_else(|| {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
});
|
||||||
|
let tags = event
|
||||||
|
.get("tags")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!([]));
|
||||||
|
|
||||||
// NIP-01 serialization: [0, pubkey, created_at, kind, tags, content]
|
// NIP-01 serialization: [0, pubkey, created_at, kind, tags, content]
|
||||||
let serialized = serde_json::json!([0, pubkey_hex, created_at, kind, tags, content]);
|
let serialized = serde_json::json!([0, pubkey_hex, created_at, kind, tags, content]);
|
||||||
let serialized_str = serde_json::to_string(&serialized)?;
|
let serialized_str = serde_json::to_string(&serialized)?;
|
||||||
|
|
||||||
use sha2::{Sha256, Digest};
|
use sha2::{Digest, Sha256};
|
||||||
let hash = Sha256::digest(serialized_str.as_bytes());
|
let hash = Sha256::digest(serialized_str.as_bytes());
|
||||||
let event_hash_hex = hex::encode(hash);
|
let event_hash_hex = hex::encode(hash);
|
||||||
|
|
||||||
|
|||||||
@@ -4,26 +4,13 @@ use anyhow::{Context, Result};
|
|||||||
|
|
||||||
/// Trusted Docker registries. Only images from these sources are allowed.
|
/// Trusted Docker registries. Only images from these sources are allowed.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(super) const TRUSTED_REGISTRIES: &[&str] = &["docker.io/", "ghcr.io/", "localhost/", "80.71.235.15:3000/"];
|
pub(super) const TRUSTED_REGISTRIES: &[&str] = &[
|
||||||
|
"docker.io/",
|
||||||
/// Detect which Bitcoin container is running on archy-net for DNS resolution.
|
"ghcr.io/",
|
||||||
/// Returns the container name to use as the RPC host (e.g., "bitcoin-knots").
|
"localhost/",
|
||||||
pub(super) fn detect_bitcoin_container_name() -> String {
|
"git.tx1138.com/",
|
||||||
// Synchronous check — called from get_app_config which is sync
|
"23.182.128.160:3000/",
|
||||||
let output = std::process::Command::new("podman")
|
];
|
||||||
.args(["ps", "--format", "{{.Names}}"])
|
|
||||||
.output();
|
|
||||||
if let Ok(out) = output {
|
|
||||||
let names = String::from_utf8_lossy(&out.stdout);
|
|
||||||
for candidate in &["bitcoin-knots", "bitcoin-core", "bitcoin"] {
|
|
||||||
if names.lines().any(|l| l.trim() == *candidate) {
|
|
||||||
return candidate.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Default to bitcoin-knots (most common)
|
|
||||||
"bitcoin-knots".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate Docker image against trusted registry allowlist.
|
/// Validate Docker image against trusted registry allowlist.
|
||||||
pub(super) fn is_valid_docker_image(image: &str) -> bool {
|
pub(super) fn is_valid_docker_image(image: &str) -> bool {
|
||||||
@@ -40,7 +27,10 @@ pub(super) fn is_valid_docker_image(image: &str) -> bool {
|
|||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
matches!(registry, "docker.io" | "ghcr.io" | "localhost" | "80.71.235.15:3000")
|
matches!(
|
||||||
|
registry,
|
||||||
|
"docker.io" | "ghcr.io" | "localhost" | "git.tx1138.com" | "23.182.128.160:3000"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-app Linux capabilities needed beyond the default cap-drop=ALL.
|
/// Per-app Linux capabilities needed beyond the default cap-drop=ALL.
|
||||||
@@ -58,8 +48,7 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
|||||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||||
"--cap-add=NET_RAW".to_string(),
|
"--cap-add=NET_RAW".to_string(),
|
||||||
],
|
],
|
||||||
"nextcloud" | "btcpay-server" | "btcpayserver"
|
"nextcloud" | "btcpay-server" | "btcpayserver" | "portainer" => vec![
|
||||||
| "portainer" => vec![
|
|
||||||
"--cap-add=CHOWN".to_string(),
|
"--cap-add=CHOWN".to_string(),
|
||||||
"--cap-add=SETUID".to_string(),
|
"--cap-add=SETUID".to_string(),
|
||||||
"--cap-add=SETGID".to_string(),
|
"--cap-add=SETGID".to_string(),
|
||||||
@@ -83,16 +72,17 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
|
|||||||
],
|
],
|
||||||
// Bitcoin and Lightning need file ownership ops + NET_BIND_SERVICE for port binding
|
// Bitcoin and Lightning need file ownership ops + NET_BIND_SERVICE for port binding
|
||||||
// LND additionally needs NET_RAW for TLS certificate generation (netlinkrib interface enumeration)
|
// LND additionally needs NET_RAW for TLS certificate generation (netlinkrib interface enumeration)
|
||||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint"
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint" | "fedimint-gateway" => {
|
||||||
| "fedimint-gateway" => vec![
|
vec![
|
||||||
"--cap-add=CHOWN".to_string(),
|
"--cap-add=CHOWN".to_string(),
|
||||||
"--cap-add=FOWNER".to_string(),
|
"--cap-add=FOWNER".to_string(),
|
||||||
"--cap-add=SETUID".to_string(),
|
"--cap-add=SETUID".to_string(),
|
||||||
"--cap-add=SETGID".to_string(),
|
"--cap-add=SETGID".to_string(),
|
||||||
"--cap-add=DAC_OVERRIDE".to_string(),
|
"--cap-add=DAC_OVERRIDE".to_string(),
|
||||||
"--cap-add=NET_BIND_SERVICE".to_string(),
|
"--cap-add=NET_BIND_SERVICE".to_string(),
|
||||||
"--cap-add=NET_RAW".to_string(),
|
"--cap-add=NET_RAW".to_string(),
|
||||||
],
|
]
|
||||||
|
}
|
||||||
// Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
|
// Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
|
||||||
"vaultwarden" => vec![
|
"vaultwarden" => vec![
|
||||||
"--cap-add=CHOWN".to_string(),
|
"--cap-add=CHOWN".to_string(),
|
||||||
@@ -164,8 +154,8 @@ pub(super) fn is_readonly_compatible(app_id: &str) -> bool {
|
|||||||
/// Returns (health-cmd, interval, retries) args to append to run_args.
|
/// Returns (health-cmd, interval, retries) args to append to run_args.
|
||||||
pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String> {
|
pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String> {
|
||||||
// bitcoin-cli reads the .cookie file from -datadir automatically (no plaintext creds needed)
|
// bitcoin-cli reads the .cookie file from -datadir automatically (no plaintext creds needed)
|
||||||
let btc_health = "bitcoin-cli -datadir=/home/bitcoin/.bitcoin getblockchaininfo || exit 1"
|
let btc_health =
|
||||||
.to_string();
|
"bitcoin-cli -datadir=/home/bitcoin/.bitcoin getblockchaininfo || exit 1".to_string();
|
||||||
let (cmd, interval, retries) = match app_id {
|
let (cmd, interval, retries) = match app_id {
|
||||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (btc_health.as_str(), "30s", "3"),
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => (btc_health.as_str(), "30s", "3"),
|
||||||
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
|
"lnd" => ("lncli getinfo || exit 1", "30s", "3"),
|
||||||
@@ -188,11 +178,9 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
|||||||
"30s",
|
"30s",
|
||||||
"3",
|
"3",
|
||||||
),
|
),
|
||||||
"homeassistant" | "home-assistant" => (
|
"homeassistant" | "home-assistant" => {
|
||||||
"curl -sf http://localhost:8123/api/ || exit 1",
|
("curl -sf http://localhost:8123/api/ || exit 1", "30s", "3")
|
||||||
"30s",
|
}
|
||||||
"3",
|
|
||||||
),
|
|
||||||
"grafana" => (
|
"grafana" => (
|
||||||
"curl -sf http://localhost:3000/api/health || exit 1",
|
"curl -sf http://localhost:3000/api/health || exit 1",
|
||||||
"30s",
|
"30s",
|
||||||
@@ -205,11 +193,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
|||||||
),
|
),
|
||||||
"vaultwarden" => ("curl -sf http://localhost:80/alive || exit 1", "30s", "3"),
|
"vaultwarden" => ("curl -sf http://localhost:80/alive || exit 1", "30s", "3"),
|
||||||
"uptime-kuma" => ("curl -sf http://localhost:3001/ || exit 1", "30s", "3"),
|
"uptime-kuma" => ("curl -sf http://localhost:3001/ || exit 1", "30s", "3"),
|
||||||
"filebrowser" => (
|
"filebrowser" => ("curl -sf http://localhost:80/health || exit 1", "30s", "3"),
|
||||||
"curl -sf http://localhost:80/health || exit 1",
|
|
||||||
"30s",
|
|
||||||
"3",
|
|
||||||
),
|
|
||||||
"searxng" => ("curl -sf http://localhost:8080/ || exit 1", "30s", "3"),
|
"searxng" => ("curl -sf http://localhost:8080/ || exit 1", "30s", "3"),
|
||||||
"photoprism" => (
|
"photoprism" => (
|
||||||
"curl -sf http://localhost:2342/api/v1/status || exit 1",
|
"curl -sf http://localhost:2342/api/v1/status || exit 1",
|
||||||
@@ -232,19 +216,12 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
|
|||||||
"3",
|
"3",
|
||||||
),
|
),
|
||||||
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
|
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
|
||||||
"fedimint" => (
|
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
|
||||||
"curl -sf http://localhost:8174/health || exit 1",
|
"fedimint-gateway" => ("curl -sf http://localhost:8176/ || exit 1", "60s", "3"),
|
||||||
"60s",
|
|
||||||
"3",
|
|
||||||
),
|
|
||||||
"nostr-rs-relay" | "nostr-relay" => {
|
"nostr-rs-relay" | "nostr-relay" => {
|
||||||
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
|
("curl -sf http://localhost:8080/ || exit 1", "30s", "3")
|
||||||
}
|
}
|
||||||
"nginx-proxy-manager" => (
|
"nginx-proxy-manager" => ("curl -sf http://localhost:81/api/ || exit 1", "30s", "3"),
|
||||||
"curl -sf http://localhost:81/api/ || exit 1",
|
|
||||||
"30s",
|
|
||||||
"3",
|
|
||||||
),
|
|
||||||
"routstr" => (
|
"routstr" => (
|
||||||
"curl -sf http://localhost:8000/v1/models || exit 1",
|
"curl -sf http://localhost:8000/v1/models || exit 1",
|
||||||
"30s",
|
"30s",
|
||||||
@@ -316,51 +293,78 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
|||||||
match package_id {
|
match package_id {
|
||||||
// Bitcoin: multiple historical names
|
// Bitcoin: multiple historical names
|
||||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
|
||||||
"bitcoin-knots".into(), "bitcoin".into(), "bitcoin-core".into(),
|
"bitcoin-knots".into(),
|
||||||
"archy-bitcoin-knots".into(), "archy-bitcoin".into(),
|
"bitcoin".into(),
|
||||||
|
"bitcoin-core".into(),
|
||||||
|
"archy-bitcoin-knots".into(),
|
||||||
|
"archy-bitcoin".into(),
|
||||||
"bitcoin-ui".into(),
|
"bitcoin-ui".into(),
|
||||||
|
"archy-bitcoin-ui".into(),
|
||||||
],
|
],
|
||||||
// LND + UI
|
// LND + UI
|
||||||
"lnd" => vec!["lnd".into(), "archy-lnd".into(), "archy-lnd-ui".into()],
|
"lnd" => vec!["lnd".into(), "archy-lnd".into(), "archy-lnd-ui".into()],
|
||||||
// Electrumx: multiple aliases
|
// Electrumx: multiple aliases
|
||||||
"electrumx" | "electrs" | "mempool-electrs" => vec![
|
"electrumx" | "electrs" | "mempool-electrs" => vec![
|
||||||
"electrumx".into(), "electrs".into(), "mempool-electrs".into(),
|
"electrumx".into(),
|
||||||
"archy-electrumx".into(), "archy-electrs-ui".into(),
|
"electrs".into(),
|
||||||
|
"mempool-electrs".into(),
|
||||||
|
"archy-electrumx".into(),
|
||||||
|
"archy-electrs-ui".into(),
|
||||||
],
|
],
|
||||||
// Mempool: multi-container stack
|
// Mempool: multi-container stack
|
||||||
"mempool" | "mempool-web" => vec![
|
"mempool" | "mempool-web" => vec![
|
||||||
"mempool".into(), "mempool-web".into(), "mempool-api".into(),
|
"mempool".into(),
|
||||||
"archy-mempool-web".into(), "archy-mempool-api".into(),
|
"mempool-web".into(),
|
||||||
"archy-mempool-db".into(), "mysql-mempool".into(),
|
"mempool-api".into(),
|
||||||
|
"archy-mempool-web".into(),
|
||||||
|
"archy-mempool-api".into(),
|
||||||
|
"archy-mempool-db".into(),
|
||||||
|
"mysql-mempool".into(),
|
||||||
],
|
],
|
||||||
// BTCPay: multi-container + multiple aliases
|
// BTCPay: multi-container + multiple aliases
|
||||||
"btcpay-server" | "btcpayserver" | "btcpay" => vec![
|
"btcpay-server" | "btcpayserver" | "btcpay" => vec![
|
||||||
"btcpay-server".into(), "btcpay".into(), "btcpayserver".into(),
|
"btcpay-server".into(),
|
||||||
"archy-btcpay".into(), "archy-btcpay-db".into(), "archy-nbxplorer".into(),
|
"btcpay".into(),
|
||||||
|
"btcpayserver".into(),
|
||||||
|
"archy-btcpay".into(),
|
||||||
|
"archy-btcpay-db".into(),
|
||||||
|
"archy-nbxplorer".into(),
|
||||||
],
|
],
|
||||||
// Home Assistant: two naming conventions
|
// Home Assistant: two naming conventions
|
||||||
"homeassistant" | "home-assistant" => vec![
|
"homeassistant" | "home-assistant" => vec![
|
||||||
"homeassistant".into(), "home-assistant".into(),
|
"homeassistant".into(),
|
||||||
|
"home-assistant".into(),
|
||||||
"archy-homeassistant".into(),
|
"archy-homeassistant".into(),
|
||||||
],
|
],
|
||||||
// Fedimint: multiple related containers
|
// Fedimint: multiple related containers
|
||||||
"fedimint" => vec![
|
"fedimint" => vec![
|
||||||
"fedimint".into(), "fedimintd".into(),
|
"fedimint".into(),
|
||||||
"fedimint-ui".into(), "archy-fedimint".into(),
|
"fedimintd".into(),
|
||||||
|
"fedimint-ui".into(),
|
||||||
|
"archy-fedimint".into(),
|
||||||
"fedimint-gateway".into(),
|
"fedimint-gateway".into(),
|
||||||
],
|
],
|
||||||
"fedimint-gateway" => vec!["fedimint-gateway".into()],
|
"fedimint-gateway" => vec!["fedimint-gateway".into()],
|
||||||
// Immich: multi-container
|
// Immich: multi-container
|
||||||
"immich" => vec![
|
"immich" => vec![
|
||||||
"immich_postgres".into(), "immich_redis".into(), "immich_server".into(),
|
"immich_postgres".into(),
|
||||||
|
"immich_redis".into(),
|
||||||
|
"immich_server".into(),
|
||||||
],
|
],
|
||||||
// Penpot: multi-container
|
// Penpot: multi-container
|
||||||
"penpot" | "penpot-frontend" => vec![
|
"penpot" | "penpot-frontend" => vec![
|
||||||
"penpot-postgres".into(), "penpot-valkey".into(),
|
"penpot-postgres".into(),
|
||||||
"penpot-backend".into(), "penpot-exporter".into(), "penpot-frontend".into(),
|
"penpot-valkey".into(),
|
||||||
|
"penpot-backend".into(),
|
||||||
|
"penpot-exporter".into(),
|
||||||
|
"penpot-frontend".into(),
|
||||||
],
|
],
|
||||||
"nostr-vpn" => vec!["nostr-vpn".into(), "archy-nostr-vpn".into()],
|
"nostr-vpn" => vec![
|
||||||
"fips" => vec!["fips".into(), "archy-fips".into()],
|
"nostr-vpn".into(),
|
||||||
|
"archy-nostr-vpn".into(),
|
||||||
|
"archy-nostr-vpn-ui".into(),
|
||||||
|
],
|
||||||
|
"fips" => vec!["fips".into(), "archy-fips".into(), "archy-fips-ui".into()],
|
||||||
"routstr" => vec!["routstr".into(), "archy-routstr".into()],
|
"routstr" => vec!["routstr".into(), "archy-routstr".into()],
|
||||||
// Default: exact name + archy- prefix
|
// Default: exact name + archy- prefix
|
||||||
_ => vec![base, archy],
|
_ => vec![base, archy],
|
||||||
@@ -405,10 +409,7 @@ pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
|||||||
format!("{}/fedimint-gateway", base),
|
format!("{}/fedimint-gateway", base),
|
||||||
],
|
],
|
||||||
"fedimint-gateway" => vec![format!("{}/fedimint-gateway", base)],
|
"fedimint-gateway" => vec![format!("{}/fedimint-gateway", base)],
|
||||||
"immich" => vec![
|
"immich" => vec![format!("{}/immich", base), format!("{}/immich-db", base)],
|
||||||
format!("{}/immich", base),
|
|
||||||
format!("{}/immich-db", base),
|
|
||||||
],
|
|
||||||
"penpot" | "penpot-frontend" => vec![
|
"penpot" | "penpot-frontend" => vec![
|
||||||
format!("{}/penpot-assets", base),
|
format!("{}/penpot-assets", base),
|
||||||
format!("{}/penpot-postgres", base),
|
format!("{}/penpot-postgres", base),
|
||||||
@@ -426,6 +427,24 @@ fn read_secret(name: &str, default: &str) -> String {
|
|||||||
.unwrap_or_else(|_| default.to_string())
|
.unwrap_or_else(|_| default.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a secret or generate and persist a random one if it doesn't exist.
|
||||||
|
pub(super) async fn read_or_generate_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Generate a 24-byte random password (hex-encoded = 48 chars)
|
||||||
|
let mut buf = [0u8; 24];
|
||||||
|
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf);
|
||||||
|
let secret = hex::encode(buf);
|
||||||
|
let _ = tokio::fs::create_dir_all("/var/lib/archipelago/secrets").await;
|
||||||
|
let _ = tokio::fs::write(&path, &secret).await;
|
||||||
|
secret
|
||||||
|
}
|
||||||
|
|
||||||
/// Read the node-level Nostr secret key (hex) for identity-aware apps.
|
/// Read the node-level Nostr secret key (hex) for identity-aware apps.
|
||||||
/// Returns empty string if not yet generated.
|
/// Returns empty string if not yet generated.
|
||||||
fn read_nostr_secret_hex() -> String {
|
fn read_nostr_secret_hex() -> String {
|
||||||
@@ -491,9 +510,9 @@ pub(super) async fn get_app_config(
|
|||||||
"--bitcoin.node=bitcoind".to_string(),
|
"--bitcoin.node=bitcoind".to_string(),
|
||||||
format!("--bitcoind.rpcuser={}", rpc_user),
|
format!("--bitcoind.rpcuser={}", rpc_user),
|
||||||
format!("--bitcoind.rpcpass={}", rpc_pass),
|
format!("--bitcoind.rpcpass={}", rpc_pass),
|
||||||
"--bitcoind.rpchost=bitcoin-knots:8332".to_string(),
|
"--bitcoind.rpchost=host.containers.internal:8332".to_string(),
|
||||||
"--bitcoind.zmqpubrawblock=tcp://bitcoin-knots:28332".to_string(),
|
"--bitcoind.zmqpubrawblock=tcp://host.containers.internal:28332".to_string(),
|
||||||
"--bitcoind.zmqpubrawtx=tcp://bitcoin-knots:28333".to_string(),
|
"--bitcoind.zmqpubrawtx=tcp://host.containers.internal:28333".to_string(),
|
||||||
"--rpclisten=0.0.0.0:10009".to_string(),
|
"--rpclisten=0.0.0.0:10009".to_string(),
|
||||||
"--restlisten=0.0.0.0:8080".to_string(),
|
"--restlisten=0.0.0.0:8080".to_string(),
|
||||||
"--listen=0.0.0.0:9735".to_string(),
|
"--listen=0.0.0.0:9735".to_string(),
|
||||||
@@ -528,7 +547,7 @@ pub(super) async fn get_app_config(
|
|||||||
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
vec!["/var/lib/archipelago/mempool:/data".to_string()],
|
||||||
vec![
|
vec![
|
||||||
"MEMPOOL_BACKEND=electrum".to_string(),
|
"MEMPOOL_BACKEND=electrum".to_string(),
|
||||||
"ELECTRUM_HOST=electrumx".to_string(),
|
"ELECTRUM_HOST=host.containers.internal".to_string(),
|
||||||
"ELECTRUM_PORT=50001".to_string(),
|
"ELECTRUM_PORT=50001".to_string(),
|
||||||
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
"ELECTRUM_TLS_ENABLED=false".to_string(),
|
||||||
format!("CORE_RPC_HOST={}", host_ip),
|
format!("CORE_RPC_HOST={}", host_ip),
|
||||||
@@ -545,15 +564,13 @@ pub(super) async fn get_app_config(
|
|||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
"electrumx" | "mempool-electrs" | "electrs" => {
|
"electrumx" | "mempool-electrs" | "electrs" => {
|
||||||
// Detect which bitcoin container is running for archy-net DNS resolution
|
|
||||||
let bitcoin_host = detect_bitcoin_container_name();
|
|
||||||
(
|
(
|
||||||
vec!["50001:50001".to_string()],
|
vec!["50001:50001".to_string()],
|
||||||
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
|
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
|
||||||
vec![
|
vec![
|
||||||
format!(
|
format!(
|
||||||
"DAEMON_URL=http://{}:{}@{}:8332/",
|
"DAEMON_URL=http://{}:{}@host.containers.internal:8332/",
|
||||||
rpc_user, rpc_pass, bitcoin_host
|
rpc_user, rpc_pass
|
||||||
),
|
),
|
||||||
"COIN=Bitcoin".to_string(),
|
"COIN=Bitcoin".to_string(),
|
||||||
"DB_DIRECTORY=/data".to_string(),
|
"DB_DIRECTORY=/data".to_string(),
|
||||||
@@ -757,39 +774,45 @@ pub(super) async fn get_app_config(
|
|||||||
Some(vec![
|
Some(vec![
|
||||||
"--data-dir".to_string(),
|
"--data-dir".to_string(),
|
||||||
"/data".to_string(),
|
"/data".to_string(),
|
||||||
format!("--bitcoind-url=http://{}:{}@bitcoin-knots:8332", rpc_user, rpc_pass),
|
format!("--bitcoind-url=http://{}:{}@{}:8332", rpc_user, rpc_pass, host_ip),
|
||||||
]),
|
|
||||||
),
|
|
||||||
"fedimint-gateway" => (
|
|
||||||
vec!["8176:8176".to_string(), "9737:9737".to_string()],
|
|
||||||
vec!["/var/lib/archipelago/fedimint-gateway:/data".to_string()],
|
|
||||||
vec![],
|
|
||||||
None,
|
|
||||||
Some(vec![
|
|
||||||
"gatewayd".to_string(),
|
|
||||||
"--data-dir".to_string(),
|
|
||||||
"/data".to_string(),
|
|
||||||
"--listen".to_string(),
|
|
||||||
"0.0.0.0:8176".to_string(),
|
|
||||||
"--bcrypt-password-hash".to_string(),
|
|
||||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(),
|
|
||||||
"--network".to_string(),
|
|
||||||
"bitcoin".to_string(),
|
|
||||||
"--bitcoind-url".to_string(),
|
|
||||||
format!("http://{}:8332", host_ip),
|
|
||||||
"--bitcoind-username".to_string(),
|
|
||||||
rpc_user.to_string(),
|
|
||||||
"--bitcoind-password".to_string(),
|
|
||||||
rpc_pass.to_string(),
|
|
||||||
"ldk".to_string(),
|
|
||||||
"--ldk-lightning-port".to_string(),
|
|
||||||
"9737".to_string(),
|
|
||||||
"--ldk-alias".to_string(),
|
|
||||||
"archipelago-gateway".to_string(),
|
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
"fedimint-gateway" => {
|
||||||
|
let fedi_hash = read_secret(
|
||||||
|
"fedimint-gateway-hash",
|
||||||
|
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC",
|
||||||
|
);
|
||||||
|
(
|
||||||
|
vec!["8176:8176".to_string(), "9737:9737".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/fedimint-gateway:/data".to_string()],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
Some(vec![
|
||||||
|
"gatewayd".to_string(),
|
||||||
|
"--data-dir".to_string(),
|
||||||
|
"/data".to_string(),
|
||||||
|
"--listen".to_string(),
|
||||||
|
"0.0.0.0:8176".to_string(),
|
||||||
|
"--bcrypt-password-hash".to_string(),
|
||||||
|
fedi_hash,
|
||||||
|
"--network".to_string(),
|
||||||
|
"bitcoin".to_string(),
|
||||||
|
"--bitcoind-url".to_string(),
|
||||||
|
format!("http://{}:8332", host_ip),
|
||||||
|
"--bitcoind-username".to_string(),
|
||||||
|
rpc_user.to_string(),
|
||||||
|
"--bitcoind-password".to_string(),
|
||||||
|
rpc_pass.to_string(),
|
||||||
|
"ldk".to_string(),
|
||||||
|
"--ldk-lightning-port".to_string(),
|
||||||
|
"9737".to_string(),
|
||||||
|
"--ldk-alias".to_string(),
|
||||||
|
"archipelago-gateway".to_string(),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
"indeedhub" => (
|
"indeedhub" => (
|
||||||
vec!["8190:3000".to_string()],
|
vec!["7778:7777".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
vec![
|
vec![
|
||||||
"NODE_ENV=production".to_string(),
|
"NODE_ENV=production".to_string(),
|
||||||
@@ -834,18 +857,15 @@ pub(super) async fn get_app_config(
|
|||||||
vec!["/var/lib/archipelago/nostr-vpn:/root/.config/nvpn".to_string()],
|
vec!["/var/lib/archipelago/nostr-vpn:/root/.config/nvpn".to_string()],
|
||||||
env,
|
env,
|
||||||
None,
|
None,
|
||||||
Some(vec![
|
None,
|
||||||
"start".to_string(),
|
|
||||||
"--daemon".to_string(),
|
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"fips" => {
|
"fips" => {
|
||||||
let nsec = read_nostr_secret_hex();
|
let nsec = read_nostr_secret_hex();
|
||||||
let mut env = vec![];
|
let mut env = vec![];
|
||||||
if !nsec.is_empty() {
|
if !nsec.is_empty() {
|
||||||
env.push(format!("FIPS_NOSTR_SECRET={}", nsec));
|
env.push(format!("FIPS_NSEC={}", nsec));
|
||||||
env.push(format!("FIPS_NOSTR_PUBKEY={}", read_nostr_pubkey_hex()));
|
env.push(format!("FIPS_NPUB={}", read_nostr_pubkey_hex()));
|
||||||
}
|
}
|
||||||
(
|
(
|
||||||
vec![
|
vec![
|
||||||
@@ -873,6 +893,69 @@ pub(super) async fn get_app_config(
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
_ => (vec![], vec![], vec![], None, None),
|
"botfights" => {
|
||||||
|
let jwt_secret = read_or_generate_secret("botfights-jwt").await;
|
||||||
|
(
|
||||||
|
vec!["9100:9100".to_string()],
|
||||||
|
vec!["/var/lib/archipelago/botfights:/app/server/data".to_string()],
|
||||||
|
vec![
|
||||||
|
"NODE_ENV=production".to_string(),
|
||||||
|
"PORT=9100".to_string(),
|
||||||
|
format!("JWT_SECRET={}", jwt_secret),
|
||||||
|
"FIGHT_LOOP_ENABLED=true".to_string(),
|
||||||
|
"ARCHY_EMBEDDED=1".to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Gitea binds to 3001 internally. Nginx on port 3000 strips X-Frame-Options
|
||||||
|
// so Gitea works in Archipelago's iframe. See nginx-gitea-iframe.conf.
|
||||||
|
"gitea" => (
|
||||||
|
vec!["3001:3000".to_string(), "2222:22".to_string()],
|
||||||
|
vec![
|
||||||
|
"/var/lib/archipelago/gitea/data:/data".to_string(),
|
||||||
|
"/var/lib/archipelago/gitea/config:/etc/gitea".to_string(),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
"GITEA__database__DB_TYPE=sqlite3".to_string(),
|
||||||
|
"GITEA__server__SSH_PORT=2222".to_string(),
|
||||||
|
"GITEA__server__SSH_LISTEN_PORT=22".to_string(),
|
||||||
|
"GITEA__server__LFS_START_SERVER=true".to_string(),
|
||||||
|
"GITEA__packages__ENABLED=true".to_string(),
|
||||||
|
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true".to_string(),
|
||||||
|
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true".to_string(),
|
||||||
|
"GITEA__security__X_FRAME_OPTIONS=".to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
_ => {
|
||||||
|
// Unknown app: try to load config from /var/lib/archipelago/app-configs/{id}.json
|
||||||
|
// This allows dynamic apps from the remote catalog to be installed
|
||||||
|
// without hardcoding their config here.
|
||||||
|
let config_path = format!("/var/lib/archipelago/app-configs/{}.json", app_id);
|
||||||
|
if let Ok(data) = tokio::fs::read_to_string(&config_path).await {
|
||||||
|
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&data) {
|
||||||
|
let ports = cfg.get("ports")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let volumes = cfg.get("volumes")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let env_vars = cfg.get("env")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
tracing::info!("Loaded dynamic config for app: {}", app_id);
|
||||||
|
return (ports, volumes, env_vars, None, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No config found — use minimal defaults (container's own EXPOSE/VOLUME)
|
||||||
|
tracing::warn!("No config found for app: {} — using minimal defaults", app_id);
|
||||||
|
(vec![], vec![], vec![], None, None)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
|||||||
let is_running = |names: &[&str]| {
|
let is_running = |names: &[&str]| {
|
||||||
running.lines().any(|l| {
|
running.lines().any(|l| {
|
||||||
let name = l.trim();
|
let name = l.trim();
|
||||||
names.iter().any(|n| name == *n)
|
names.contains(&name)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,12 +42,10 @@ pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
|||||||
/// Returns an error with a user-friendly message if dependencies are missing.
|
/// Returns an error with a user-friendly message if dependencies are missing.
|
||||||
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
|
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
|
||||||
match package_id {
|
match package_id {
|
||||||
"electrumx" | "mempool-electrs" | "electrs" if !deps.has_bitcoin => {
|
"electrumx" | "mempool-electrs" | "electrs" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||||
Err(anyhow::anyhow!(
|
"ElectrumX requires a running Bitcoin node (Bitcoin Knots). \
|
||||||
"ElectrumX requires a running Bitcoin node (Bitcoin Knots). \
|
|
||||||
Please install and start Bitcoin Knots first."
|
Please install and start Bitcoin Knots first."
|
||||||
))
|
)),
|
||||||
}
|
|
||||||
"lnd" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
"lnd" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||||
"LND requires a running Bitcoin node (Bitcoin Knots). \
|
"LND requires a running Bitcoin node (Bitcoin Knots). \
|
||||||
Please install and start Bitcoin Knots first."
|
Please install and start Bitcoin Knots first."
|
||||||
@@ -70,10 +68,10 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
|
|||||||
missing.join(" and ")
|
missing.join(" and ")
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
"fedimint" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
"fedimint" if !deps.has_bitcoin => {
|
||||||
"Fedimint requires a running Bitcoin node (Bitcoin Knots). \
|
info!("Fedimint installing without local Bitcoin node — configure remote Bitcoin RPC in Fedimint guardian setup");
|
||||||
Please install and start Bitcoin Knots first."
|
Ok(())
|
||||||
)),
|
}
|
||||||
_ => Ok(()),
|
_ => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,9 +142,7 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
|||||||
|
|
||||||
/// Sort a list of container names according to the dependency-aware startup
|
/// Sort a list of container names according to the dependency-aware startup
|
||||||
/// 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(
|
pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result<Vec<String>> {
|
||||||
package_id: &str,
|
|
||||||
) -> Result<Vec<String>> {
|
|
||||||
let containers = get_containers_for_app(package_id).await?;
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
if containers.is_empty() {
|
if containers.is_empty() {
|
||||||
return Ok(vec![format!("archy-{}", package_id)]);
|
return Ok(vec![format!("archy-{}", package_id)]);
|
||||||
@@ -159,12 +155,7 @@ pub(super) async fn ordered_containers_for_start(
|
|||||||
order
|
order
|
||||||
};
|
};
|
||||||
let mut sorted = containers;
|
let mut sorted = containers;
|
||||||
sorted.sort_by_key(|c| {
|
sorted.sort_by_key(|c| effective_order.iter().position(|o| *o == c).unwrap_or(99));
|
||||||
effective_order
|
|
||||||
.iter()
|
|
||||||
.position(|o| *o == c)
|
|
||||||
.unwrap_or(99)
|
|
||||||
});
|
|
||||||
Ok(sorted)
|
Ok(sorted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,12 +170,18 @@ pub(super) fn configure_fedimint_lnd(
|
|||||||
rpc_pass: &str,
|
rpc_pass: &str,
|
||||||
) {
|
) {
|
||||||
let lnd_cert = "/var/lib/archipelago/lnd/tls.cert";
|
let lnd_cert = "/var/lib/archipelago/lnd/tls.cert";
|
||||||
let lnd_macaroon =
|
let lnd_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
if std::path::Path::new(lnd_cert).exists() && std::path::Path::new(lnd_macaroon).exists() {
|
||||||
if std::path::Path::new(lnd_cert).exists()
|
|
||||||
&& std::path::Path::new(lnd_macaroon).exists()
|
|
||||||
{
|
|
||||||
info!("LND detected with credentials — configuring gateway in lnd mode");
|
info!("LND detected with credentials — configuring gateway in lnd mode");
|
||||||
|
|
||||||
|
// Read bcrypt hash from secrets file, fall back to default
|
||||||
|
let fedi_hash =
|
||||||
|
std::fs::read_to_string("/var/lib/archipelago/secrets/fedimint-gateway-hash")
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
ports.retain(|p| p != "9737:9737");
|
ports.retain(|p| p != "9737:9737");
|
||||||
volumes.push(format!("{}:/lnd/tls.cert:ro", lnd_cert));
|
volumes.push(format!("{}:/lnd/tls.cert:ro", lnd_cert));
|
||||||
volumes.push(format!("{}:/lnd/admin.macaroon:ro", lnd_macaroon));
|
volumes.push(format!("{}:/lnd/admin.macaroon:ro", lnd_macaroon));
|
||||||
@@ -195,7 +192,7 @@ pub(super) fn configure_fedimint_lnd(
|
|||||||
"--listen".to_string(),
|
"--listen".to_string(),
|
||||||
"0.0.0.0:8176".to_string(),
|
"0.0.0.0:8176".to_string(),
|
||||||
"--bcrypt-password-hash".to_string(),
|
"--bcrypt-password-hash".to_string(),
|
||||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(),
|
fedi_hash,
|
||||||
"--network".to_string(),
|
"--network".to_string(),
|
||||||
"bitcoin".to_string(),
|
"bitcoin".to_string(),
|
||||||
"--bitcoind-url".to_string(),
|
"--bitcoind-url".to_string(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ mod lifecycle;
|
|||||||
mod progress;
|
mod progress;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
mod stacks;
|
mod stacks;
|
||||||
|
mod update;
|
||||||
mod validation;
|
mod validation;
|
||||||
|
|
||||||
// Re-export items needed by sibling modules (container.rs, security.rs)
|
// Re-export items needed by sibling modules (container.rs, security.rs)
|
||||||
|
|||||||
@@ -8,12 +8,7 @@ use crate::data_model::{
|
|||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// Set install progress for a package and broadcast the update.
|
/// Set install progress for a package and broadcast the update.
|
||||||
/// Creates a minimal package entry if one doesn't exist yet.
|
/// Creates a minimal package entry if one doesn't exist yet.
|
||||||
pub(super) async fn set_install_progress(
|
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
|
||||||
&self,
|
|
||||||
package_id: &str,
|
|
||||||
downloaded: u64,
|
|
||||||
size: u64,
|
|
||||||
) {
|
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||||
let entry = data
|
let entry = data
|
||||||
.package_data
|
.package_data
|
||||||
@@ -86,6 +81,7 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
|||||||
},
|
},
|
||||||
installed: None,
|
installed: None,
|
||||||
install_progress: None,
|
install_progress: None,
|
||||||
|
available_update: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,25 +110,19 @@ fn parse_size_value(s: &str) -> Option<u64> {
|
|||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
|
|
||||||
let (num_str, multiplier) = if let Some(pos) = s.rfind("GiB") {
|
let (num_str, multiplier) = if let Some(pos) = s.rfind("GiB") {
|
||||||
(
|
(s[..pos].split_whitespace().last()?, 1024 * 1024 * 1024)
|
||||||
s[..pos].trim().split_whitespace().last()?,
|
|
||||||
1024 * 1024 * 1024,
|
|
||||||
)
|
|
||||||
} else if let Some(pos) = s.rfind("MiB") {
|
} else if let Some(pos) = s.rfind("MiB") {
|
||||||
(s[..pos].trim().split_whitespace().last()?, 1024 * 1024)
|
(s[..pos].split_whitespace().last()?, 1024 * 1024)
|
||||||
} else if let Some(pos) = s.rfind("KiB") {
|
} else if let Some(pos) = s.rfind("KiB") {
|
||||||
(s[..pos].trim().split_whitespace().last()?, 1024)
|
(s[..pos].split_whitespace().last()?, 1024)
|
||||||
} else if let Some(pos) = s.rfind("GB") {
|
} else if let Some(pos) = s.rfind("GB") {
|
||||||
(
|
(s[..pos].split_whitespace().last()?, 1_000_000_000)
|
||||||
s[..pos].trim().split_whitespace().last()?,
|
|
||||||
1_000_000_000,
|
|
||||||
)
|
|
||||||
} else if let Some(pos) = s.rfind("MB") {
|
} else if let Some(pos) = s.rfind("MB") {
|
||||||
(s[..pos].trim().split_whitespace().last()?, 1_000_000)
|
(s[..pos].split_whitespace().last()?, 1_000_000)
|
||||||
} else if let Some(pos) = s.rfind("KB") {
|
} else if let Some(pos) = s.rfind("KB") {
|
||||||
(s[..pos].trim().split_whitespace().last()?, 1_000)
|
(s[..pos].split_whitespace().last()?, 1_000)
|
||||||
} else if let Some(pos) = s.rfind('B') {
|
} else if let Some(pos) = s.rfind('B') {
|
||||||
(s[..pos].trim().split_whitespace().last()?, 1)
|
(s[..pos].split_whitespace().last()?, 1)
|
||||||
} else {
|
} else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ use anyhow::{Context, Result};
|
|||||||
/// 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.
|
||||||
pub fn stop_timeout_secs(container_name: &str) -> &'static str {
|
pub fn stop_timeout_secs(container_name: &str) -> &'static str {
|
||||||
let id = container_name.strip_prefix("archy-").unwrap_or(container_name);
|
let id = container_name
|
||||||
|
.strip_prefix("archy-")
|
||||||
|
.unwrap_or(container_name);
|
||||||
match id {
|
match id {
|
||||||
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => "600",
|
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => "600",
|
||||||
"lnd" => "330",
|
"lnd" => "330",
|
||||||
"electrumx" | "electrs" | "mempool-electrs" => "300",
|
"electrumx" | "electrs" | "mempool-electrs" => "300",
|
||||||
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres"
|
"btcpay-db" | "mempool-db" | "penpot-postgres" | "immich_postgres" | "nextcloud-db"
|
||||||
| "nextcloud-db" | "endurain-db" => "120",
|
| "endurain-db" => "120",
|
||||||
"btcpay-server" | "nbxplorer" | "fedimint" | "fedimint-gateway" => "60",
|
"btcpay-server" | "nbxplorer" | "fedimint" | "fedimint-gateway" => "60",
|
||||||
_ => "30",
|
_ => "30",
|
||||||
}
|
}
|
||||||
@@ -46,7 +48,11 @@ impl RpcHandler {
|
|||||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
|
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
install_log(&format!("START: {} (containers: {:?})", package_id, to_start)).await;
|
install_log(&format!(
|
||||||
|
"START: {} (containers: {:?})",
|
||||||
|
package_id, to_start
|
||||||
|
))
|
||||||
|
.await;
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
for (i, name) in to_start.iter().enumerate() {
|
for (i, name) in to_start.iter().enumerate() {
|
||||||
// Brief delay between dependent containers to allow initialization
|
// Brief delay between dependent containers to allow initialization
|
||||||
@@ -130,7 +136,11 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
install_log(&format!("STOP: {} (containers: {:?})", package_id, containers)).await;
|
install_log(&format!(
|
||||||
|
"STOP: {} (containers: {:?})",
|
||||||
|
package_id, containers
|
||||||
|
))
|
||||||
|
.await;
|
||||||
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
|
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
|
||||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
|
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
|
||||||
for name in &containers {
|
for name in &containers {
|
||||||
@@ -139,7 +149,11 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
for name in &containers {
|
for name in &containers {
|
||||||
tracing::info!("Stopping container: {} (timeout: {}s)", name, stop_timeout_secs(name));
|
tracing::info!(
|
||||||
|
"Stopping container: {} (timeout: {}s)",
|
||||||
|
name,
|
||||||
|
stop_timeout_secs(name)
|
||||||
|
);
|
||||||
let out = tokio::process::Command::new("podman")
|
let out = tokio::process::Command::new("podman")
|
||||||
.args(["stop", "-t", stop_timeout_secs(name), name])
|
.args(["stop", "-t", stop_timeout_secs(name), name])
|
||||||
.output()
|
.output()
|
||||||
@@ -176,7 +190,11 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
install_log(&format!("RESTART: {} (containers: {:?})", package_id, containers)).await;
|
install_log(&format!(
|
||||||
|
"RESTART: {} (containers: {:?})",
|
||||||
|
package_id, containers
|
||||||
|
))
|
||||||
|
.await;
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
for name in &containers {
|
for name in &containers {
|
||||||
tracing::info!("Restarting container: {}", name);
|
tracing::info!("Restarting container: {}", name);
|
||||||
@@ -188,7 +206,11 @@ impl RpcHandler {
|
|||||||
|
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
tracing::warn!("podman restart {} failed: {}, trying stop+start", name, stderr);
|
tracing::warn!(
|
||||||
|
"podman restart {} failed: {}, trying stop+start",
|
||||||
|
name,
|
||||||
|
stderr
|
||||||
|
);
|
||||||
|
|
||||||
// Fallback: stop then start (handles rootless podman loopback issues)
|
// Fallback: stop then start (handles rootless podman loopback issues)
|
||||||
let _ = tokio::process::Command::new("podman")
|
let _ = tokio::process::Command::new("podman")
|
||||||
@@ -202,7 +224,9 @@ impl RpcHandler {
|
|||||||
.context(format!("Failed to exec podman start {}", name))?;
|
.context(format!("Failed to exec podman start {}", name))?;
|
||||||
|
|
||||||
if !start_out.status.success() {
|
if !start_out.status.success() {
|
||||||
let start_err = String::from_utf8_lossy(&start_out.stderr).trim().to_string();
|
let start_err = String::from_utf8_lossy(&start_out.stderr)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
tracing::error!("stop+start {} also failed: {}", name, start_err);
|
tracing::error!("stop+start {} also failed: {}", name, start_err);
|
||||||
errors.push(format!("{}: {}", name, start_err));
|
errors.push(format!("{}: {}", name, start_err));
|
||||||
} else {
|
} else {
|
||||||
@@ -260,12 +284,7 @@ impl RpcHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!("Uninstall {}: stop {} error: {}", package_id, name, e);
|
||||||
"Uninstall {}: stop {} error: {}",
|
|
||||||
package_id,
|
|
||||||
name,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +299,12 @@ impl RpcHandler {
|
|||||||
Ok(o) => {
|
Ok(o) => {
|
||||||
// If normal rm fails (e.g., still running), force as fallback
|
// If normal rm fails (e.g., still running), force as fallback
|
||||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||||
tracing::warn!("Uninstall {}: rm {} failed ({}), trying force", package_id, name, stderr.trim());
|
tracing::warn!(
|
||||||
|
"Uninstall {}: rm {} failed ({}), trying force",
|
||||||
|
package_id,
|
||||||
|
name,
|
||||||
|
stderr.trim()
|
||||||
|
);
|
||||||
let force_rm = tokio::process::Command::new("podman")
|
let force_rm = tokio::process::Command::new("podman")
|
||||||
.args(["rm", "-f", name])
|
.args(["rm", "-f", name])
|
||||||
.output()
|
.output()
|
||||||
@@ -374,6 +398,31 @@ impl RpcHandler {
|
|||||||
removed
|
removed
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
|||||||
|
//! Per-app manual update handler.
|
||||||
|
//!
|
||||||
|
//! Flow: validate → set Updating state → graceful stop → pull new image(s) →
|
||||||
|
//! remove old container(s) → recreate via reconcile script → verify running.
|
||||||
|
//! Data volumes are preserved (bind mounts, not stored in container).
|
||||||
|
|
||||||
|
use super::config::get_containers_for_app;
|
||||||
|
use super::install::install_log;
|
||||||
|
use super::progress::parse_pull_progress;
|
||||||
|
use super::runtime::stop_timeout_secs;
|
||||||
|
use super::validation::validate_app_id;
|
||||||
|
use crate::api::rpc::RpcHandler;
|
||||||
|
use crate::container::image_versions;
|
||||||
|
use crate::data_model::PackageState;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
/// Update a package to the version pinned in image-versions.sh.
|
||||||
|
/// This is a manual operation — the user clicks "Update" in the UI.
|
||||||
|
pub(in crate::api::rpc) async fn handle_package_update(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let package_id = params
|
||||||
|
.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
|
validate_app_id(package_id)?;
|
||||||
|
|
||||||
|
// Verify an update is actually available
|
||||||
|
let pinned = image_versions::pinned_image_for_app(package_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
|
||||||
|
|
||||||
|
// Reject if already updating
|
||||||
|
{
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
if let Some(entry) = data.package_data.get(package_id) {
|
||||||
|
if entry.state == PackageState::Updating {
|
||||||
|
return Err(anyhow::anyhow!("{} is already updating", package_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
install_log(&format!("UPDATE: {} → {}", package_id, pinned)).await;
|
||||||
|
|
||||||
|
// Set state to Updating
|
||||||
|
{
|
||||||
|
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
|
entry.state = PackageState::Updating;
|
||||||
|
entry.available_update = None;
|
||||||
|
}
|
||||||
|
self.state_manager.update_data(data).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve images to pull — either a stack or single container
|
||||||
|
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
|
||||||
|
|
||||||
|
// Get all containers for this app
|
||||||
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
|
if containers.is_empty() {
|
||||||
|
self.clear_update_state(package_id).await;
|
||||||
|
return Err(anyhow::anyhow!("No containers found for {}", package_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute update — on failure, attempt rollback by restarting old containers
|
||||||
|
match self
|
||||||
|
.execute_update(package_id, &containers, &images_to_pull)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
install_log(&format!("UPDATE OK: {}", package_id)).await;
|
||||||
|
self.clear_install_progress(package_id).await;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"status": "updated",
|
||||||
|
"package_id": package_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Update {} failed: {}. Attempting rollback.", package_id, e);
|
||||||
|
install_log(&format!(
|
||||||
|
"UPDATE FAIL: {} — {}. Rolling back.",
|
||||||
|
package_id, e
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
self.rollback_update(package_id, &containers).await;
|
||||||
|
self.clear_install_progress(package_id).await;
|
||||||
|
self.clear_update_state(package_id).await;
|
||||||
|
Err(e.context(format!("Update {} failed, rolled back", package_id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core update execution: stop → pull → remove → recreate → verify.
|
||||||
|
async fn execute_update(
|
||||||
|
&self,
|
||||||
|
package_id: &str,
|
||||||
|
containers: &[String],
|
||||||
|
images_to_pull: &[(String, String)],
|
||||||
|
) -> Result<()> {
|
||||||
|
// 1. Graceful stop all containers (reverse order for dependencies)
|
||||||
|
info!(
|
||||||
|
"Update {}: stopping {} containers",
|
||||||
|
package_id,
|
||||||
|
containers.len()
|
||||||
|
);
|
||||||
|
for name in containers.iter().rev() {
|
||||||
|
let timeout = stop_timeout_secs(name);
|
||||||
|
info!(
|
||||||
|
"Update {}: stopping {} (timeout: {}s)",
|
||||||
|
package_id, name, timeout
|
||||||
|
);
|
||||||
|
let out = tokio::process::Command::new("podman")
|
||||||
|
.args(["stop", "-t", timeout, name])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context(format!("Failed to stop {}", name))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
warn!(
|
||||||
|
"Update {}: stop {} failed: {}",
|
||||||
|
package_id,
|
||||||
|
name,
|
||||||
|
stderr.trim()
|
||||||
|
);
|
||||||
|
// Continue — container might already be stopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Pull new images with progress
|
||||||
|
info!(
|
||||||
|
"Update {}: pulling {} images",
|
||||||
|
package_id,
|
||||||
|
images_to_pull.len()
|
||||||
|
);
|
||||||
|
for (i, (name, image)) in images_to_pull.iter().enumerate() {
|
||||||
|
info!(
|
||||||
|
"Update {}: pulling image {}/{} ({})",
|
||||||
|
package_id,
|
||||||
|
i + 1,
|
||||||
|
images_to_pull.len(),
|
||||||
|
image
|
||||||
|
);
|
||||||
|
self.pull_update_image(package_id, image)
|
||||||
|
.await
|
||||||
|
.context(format!("Failed to pull {} for {}", image, name))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove old containers
|
||||||
|
info!("Update {}: removing old containers", package_id);
|
||||||
|
for name in containers {
|
||||||
|
let out = tokio::process::Command::new("podman")
|
||||||
|
.args(["rm", name])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context(format!("Failed to remove {}", name))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
// Force remove as fallback
|
||||||
|
warn!(
|
||||||
|
"Update {}: rm {} failed ({}), forcing",
|
||||||
|
package_id,
|
||||||
|
name,
|
||||||
|
stderr.trim()
|
||||||
|
);
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["rm", "-f", name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Recreate via reconcile script (single source of truth for container specs)
|
||||||
|
info!("Update {}: recreating containers via reconcile", package_id);
|
||||||
|
for name in containers {
|
||||||
|
let out = tokio::process::Command::new("bash")
|
||||||
|
.args([
|
||||||
|
"/opt/archipelago/scripts/reconcile-containers.sh",
|
||||||
|
&format!("--container={}", name),
|
||||||
|
"--force",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context(format!("Failed to reconcile {}", name))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
error!(
|
||||||
|
"Update {}: reconcile {} failed:\nstdout: {}\nstderr: {}",
|
||||||
|
package_id,
|
||||||
|
name,
|
||||||
|
stdout.trim(),
|
||||||
|
stderr.trim()
|
||||||
|
);
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Reconcile failed for {}: {}",
|
||||||
|
name,
|
||||||
|
stderr.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Brief delay between containers for dependency initialization
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Verify containers reached running state
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
for name in containers {
|
||||||
|
let status = tokio::process::Command::new("podman")
|
||||||
|
.args(["inspect", name, "--format", "{{.State.Status}}"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
if let Ok(o) = status {
|
||||||
|
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||||
|
if state == "exited" {
|
||||||
|
warn!(
|
||||||
|
"Update {}: container {} exited after recreate",
|
||||||
|
package_id, name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull a single image with progress broadcasting (reuses install progress pattern).
|
||||||
|
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
|
||||||
|
self.set_install_progress(package_id, 0, 0).await;
|
||||||
|
|
||||||
|
let mut child = tokio::process::Command::new("podman")
|
||||||
|
.args(["pull", image])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.context("Failed to start image pull")?;
|
||||||
|
|
||||||
|
if let Some(stderr) = child.stderr.take() {
|
||||||
|
let reader = BufReader::new(stderr);
|
||||||
|
let mut lines = reader.lines();
|
||||||
|
let pkg_id = package_id.to_string();
|
||||||
|
let state_mgr = self.state_manager.clone();
|
||||||
|
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
if let Some((downloaded, total)) = parse_pull_progress(&line) {
|
||||||
|
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = child
|
||||||
|
.wait()
|
||||||
|
.await
|
||||||
|
.context("Failed to wait for image pull")?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(anyhow::anyhow!("podman pull {} failed", image));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_install_progress(package_id, 100, 100).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine which images need to be pulled for this update.
|
||||||
|
/// For multi-container stacks, pulls all component images.
|
||||||
|
/// For single-container apps, pulls just the pinned image.
|
||||||
|
fn resolve_images_to_pull(
|
||||||
|
&self,
|
||||||
|
package_id: &str,
|
||||||
|
pinned_primary: &str,
|
||||||
|
) -> Vec<(String, String)> {
|
||||||
|
let stack_images = image_versions::pinned_images_for_stack(package_id);
|
||||||
|
if stack_images.is_empty() {
|
||||||
|
// Single container app
|
||||||
|
vec![(package_id.to_string(), pinned_primary.to_string())]
|
||||||
|
} else {
|
||||||
|
stack_images
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rollback: restart old containers if they still exist.
|
||||||
|
/// Called when update fails partway through.
|
||||||
|
async fn rollback_update(&self, package_id: &str, containers: &[String]) {
|
||||||
|
warn!("Rolling back update for {}", package_id);
|
||||||
|
for name in containers {
|
||||||
|
// Try to start — works if container still exists (wasn't removed yet)
|
||||||
|
let out = tokio::process::Command::new("podman")
|
||||||
|
.args(["start", name])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
match out {
|
||||||
|
Ok(o) if o.status.success() => {
|
||||||
|
info!("Rollback: restarted {}", name);
|
||||||
|
}
|
||||||
|
Ok(o) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||||
|
warn!("Rollback: could not restart {}: {}", name, stderr.trim());
|
||||||
|
// Container was already removed — try reconcile to recreate with old image
|
||||||
|
let _ = tokio::process::Command::new("bash")
|
||||||
|
.args([
|
||||||
|
"/opt/archipelago/scripts/reconcile-containers.sh",
|
||||||
|
&format!("--container={}", name),
|
||||||
|
"--force",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Rollback: failed to restart {}: {}", name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the Updating state (used on failure/rollback).
|
||||||
|
async fn clear_update_state(&self, package_id: &str) {
|
||||||
|
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
|
// Don't overwrite state from scanner — just clear if still Updating
|
||||||
|
if entry.state == PackageState::Updating {
|
||||||
|
entry.state = PackageState::Stopped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.state_manager.update_data(data).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use crate::{federation, node_message, nostr_discovery, peers};
|
|
||||||
use crate::peers::KnownPeer;
|
use crate::peers::KnownPeer;
|
||||||
|
use crate::{federation, node_message, nostr_discovery, peers};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
@@ -17,7 +17,10 @@ impl RpcHandler {
|
|||||||
.get("pubkey")
|
.get("pubkey")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?;
|
||||||
let name = params.get("name").and_then(|v| v.as_str()).map(String::from);
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from);
|
||||||
|
|
||||||
let peer = KnownPeer {
|
let peer = KnownPeer {
|
||||||
onion: onion.to_string(),
|
onion: onion.to_string(),
|
||||||
@@ -69,13 +72,17 @@ impl RpcHandler {
|
|||||||
// Validate onion is a known peer or federated node to prevent SSRF
|
// Validate onion is a known peer or federated node to prevent SSRF
|
||||||
let known_peers = peers::load_peers(&self.config.data_dir).await?;
|
let known_peers = peers::load_peers(&self.config.data_dir).await?;
|
||||||
let is_known_peer = known_peers.iter().any(|p| {
|
let is_known_peer = known_peers.iter().any(|p| {
|
||||||
p.onion == onion || p.onion == format!("{}.onion", onion)
|
p.onion == onion
|
||||||
|
|| p.onion == format!("{}.onion", onion)
|
||||||
|| format!("{}.onion", p.onion) == onion
|
|| format!("{}.onion", p.onion) == onion
|
||||||
});
|
});
|
||||||
let is_known_fed = if !is_known_peer {
|
let is_known_fed = if !is_known_peer {
|
||||||
let fed_nodes = federation::load_nodes(&self.config.data_dir).await.unwrap_or_default();
|
let fed_nodes = federation::load_nodes(&self.config.data_dir)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
fed_nodes.iter().any(|n| {
|
fed_nodes.iter().any(|n| {
|
||||||
n.onion == onion || n.onion == format!("{}.onion", onion)
|
n.onion == onion
|
||||||
|
|| n.onion == format!("{}.onion", onion)
|
||||||
|| format!("{}.onion", n.onion) == onion
|
|| format!("{}.onion", n.onion) == onion
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -90,24 +97,44 @@ impl RpcHandler {
|
|||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let pubkey = data.server_info.pubkey.clone();
|
let pubkey = data.server_info.pubkey.clone();
|
||||||
|
|
||||||
|
// Skip sending to ourselves (prevents duplicate messages in group chat)
|
||||||
|
if let Some(ref our_onion) = data.server_info.tor_address {
|
||||||
|
let our = our_onion.trim_end_matches(".onion");
|
||||||
|
let their = onion.trim_end_matches(".onion");
|
||||||
|
if our == their {
|
||||||
|
return Ok(serde_json::json!({ "ok": true, "sent_to": onion, "skipped": "self" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load signing key for E2E encryption
|
// Load signing key for E2E encryption
|
||||||
let identity_dir = self.config.data_dir.join("identity");
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
let node_id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
let node_id = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||||
|
|
||||||
// Look up recipient's pubkey from federation nodes
|
// Look up recipient's pubkey from federation nodes
|
||||||
let fed_nodes = federation::load_nodes(&self.config.data_dir).await.unwrap_or_default();
|
let fed_nodes = federation::load_nodes(&self.config.data_dir)
|
||||||
let recipient_pubkey = fed_nodes.iter()
|
.await
|
||||||
.find(|n| n.onion == onion || n.onion == format!("{}.onion", onion)
|
.unwrap_or_default();
|
||||||
|| format!("{}.onion", n.onion) == onion)
|
let recipient = fed_nodes.iter().find(|n| {
|
||||||
.map(|n| n.pubkey.clone());
|
n.onion == onion
|
||||||
|
|| n.onion == format!("{}.onion", onion)
|
||||||
|
|| format!("{}.onion", n.onion) == onion
|
||||||
|
});
|
||||||
|
let recipient_pubkey = recipient.map(|n| n.pubkey.clone());
|
||||||
|
let recipient_fips_npub = recipient.and_then(|n| n.fips_npub.clone());
|
||||||
|
|
||||||
|
// Include our node name so the recipient can display it
|
||||||
|
let node_name = data.server_info.name.clone();
|
||||||
|
|
||||||
node_message::send_to_peer(
|
node_message::send_to_peer(
|
||||||
onion,
|
onion,
|
||||||
|
recipient_fips_npub.as_deref(),
|
||||||
&pubkey,
|
&pubkey,
|
||||||
message,
|
message,
|
||||||
Some(node_id.signing_key()),
|
Some(node_id.signing_key()),
|
||||||
recipient_pubkey.as_deref(),
|
recipient_pubkey.as_deref(),
|
||||||
).await?;
|
node_name.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
Ok(serde_json::json!({ "ok": true, "sent_to": onion }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +147,11 @@ impl RpcHandler {
|
|||||||
.get("onion")
|
.get("onion")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing onion"))?;
|
||||||
let reachable = node_message::check_peer_reachable(onion).await.unwrap_or(false);
|
let fips_npub =
|
||||||
|
crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
|
let reachable = node_message::check_peer_reachable(onion, fips_npub.as_deref())
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
Ok(serde_json::json!({ "onion": onion, "reachable": reachable }))
|
Ok(serde_json::json!({ "onion": onion, "reachable": reachable }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ pub(super) struct RpcError {
|
|||||||
|
|
||||||
/// Simple TTL cache for read-only RPC responses.
|
/// Simple TTL cache for read-only RPC responses.
|
||||||
pub(super) struct ResponseCache {
|
pub(super) struct ResponseCache {
|
||||||
entries: tokio::sync::RwLock<std::collections::HashMap<String, (std::time::Instant, serde_json::Value)>>,
|
entries: tokio::sync::RwLock<
|
||||||
|
std::collections::HashMap<String, (std::time::Instant, serde_json::Value)>,
|
||||||
|
>,
|
||||||
ttl: std::time::Duration,
|
ttl: std::time::Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +59,15 @@ pub(super) fn json_response(status: StatusCode, body: &[u8]) -> Response<hyper::
|
|||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(hyper::Body::from(body.to_vec()))
|
.body(hyper::Body::from(body.to_vec()))
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
Response::new(hyper::Body::from(r#"{"error":{"code":500,"message":"Internal error"}}"#))
|
Response::new(hyper::Body::from(
|
||||||
|
r#"{"error":{"code":500,"message":"Internal error"}}"#,
|
||||||
|
))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a Set-Cookie header value, returning a default if parsing fails.
|
/// Parse a Set-Cookie header value, returning a default if parsing fails.
|
||||||
pub(super) fn cookie_header(value: &str) -> hyper::header::HeaderValue {
|
pub(super) fn cookie_header(value: &str) -> hyper::header::HeaderValue {
|
||||||
value.parse().unwrap_or_else(|_| hyper::header::HeaderValue::from_static(""))
|
value
|
||||||
|
.parse()
|
||||||
|
.unwrap_or_else(|_| hyper::header::HeaderValue::from_static(""))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,13 @@ impl RpcHandler {
|
|||||||
let internal_port = params
|
let internal_port = params
|
||||||
.get("internal_port")
|
.get("internal_port")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing internal_port"))? as u16;
|
.ok_or_else(|| anyhow::anyhow!("Missing internal_port"))?
|
||||||
|
as u16;
|
||||||
let external_port = params
|
let external_port = params
|
||||||
.get("external_port")
|
.get("external_port")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing external_port"))? as u16;
|
.ok_or_else(|| anyhow::anyhow!("Missing external_port"))?
|
||||||
|
as u16;
|
||||||
let protocol = params
|
let protocol = params
|
||||||
.get("protocol")
|
.get("protocol")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -98,6 +100,7 @@ impl RpcHandler {
|
|||||||
"tor_connected": diag.tor_connected,
|
"tor_connected": diag.tor_connected,
|
||||||
"dns_working": diag.dns_working,
|
"dns_working": diag.dns_working,
|
||||||
"recommendations": diag.recommendations,
|
"recommendations": diag.recommendations,
|
||||||
|
"wifi_ssid": diag.wifi_ssid,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::RpcHandler;
|
|
||||||
use super::package::validate_app_id;
|
use super::package::validate_app_id;
|
||||||
|
use super::RpcHandler;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
@@ -15,8 +15,7 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let secrets_dir = self.config.data_dir.join("secrets");
|
let secrets_dir = self.config.data_dir.join("secrets");
|
||||||
let encryption_key = self.get_secrets_key();
|
let encryption_key = self.get_secrets_key();
|
||||||
let mgr =
|
let mgr = archipelago_security::SecretsManager::new(secrets_dir, encryption_key)?;
|
||||||
archipelago_security::SecretsManager::new(secrets_dir, encryption_key)?;
|
|
||||||
|
|
||||||
let secret_ids = mgr.list_secrets(app_id).await?;
|
let secret_ids = mgr.list_secrets(app_id).await?;
|
||||||
let mut rotated = Vec::new();
|
let mut rotated = Vec::new();
|
||||||
@@ -44,8 +43,7 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let secrets_dir = self.config.data_dir.join("secrets");
|
let secrets_dir = self.config.data_dir.join("secrets");
|
||||||
let encryption_key = self.get_secrets_key();
|
let encryption_key = self.get_secrets_key();
|
||||||
let mgr =
|
let mgr = archipelago_security::SecretsManager::new(secrets_dir, encryption_key)?;
|
||||||
archipelago_security::SecretsManager::new(secrets_dir, encryption_key)?;
|
|
||||||
|
|
||||||
let expiring = mgr.list_expiring(max_age_days).await?;
|
let expiring = mgr.list_expiring(max_age_days).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,40 @@ impl Drop for OnboardingMnemonicState {
|
|||||||
|
|
||||||
const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes
|
const MNEMONIC_TTL: std::time::Duration = std::time::Duration::from_secs(600); // 10 minutes
|
||||||
|
|
||||||
|
/// Best-effort: install fips.yaml + start archipelago-fips.service after the
|
||||||
|
/// seed onboarding has written the fips_key to disk. Runs in a detached task
|
||||||
|
/// so the user-facing RPC returns immediately — the systemctl calls can take
|
||||||
|
/// a few seconds the first time on slow hardware. Any failure is logged but
|
||||||
|
/// does not break onboarding; the user can still hit fips.install manually
|
||||||
|
/// from the dashboard as an escape hatch.
|
||||||
|
fn spawn_post_onboarding_fips_activate(data_dir: std::path::PathBuf) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let identity_dir = data_dir.join("identity");
|
||||||
|
if !crate::identity::fips_key_exists(&identity_dir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Touch load_fips_keys first so any legacy raw-byte file is migrated
|
||||||
|
// to bech32 before we copy it into /etc/fips/.
|
||||||
|
if let Err(e) = crate::identity::load_fips_keys(&identity_dir).await {
|
||||||
|
tracing::warn!("post-onboarding fips key load/migrate failed: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::fips::config::install(&identity_dir).await {
|
||||||
|
tracing::warn!("post-onboarding fips config install failed: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::fips::service::activate(crate::fips::SERVICE_UNIT).await {
|
||||||
|
tracing::warn!("post-onboarding archipelago-fips activate failed: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracing::info!("archipelago-fips auto-activated post-onboarding");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
|
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
|
||||||
/// Returns the words for the user to write down.
|
/// Returns the words for the user to write down.
|
||||||
pub(in crate::api::rpc) async fn handle_seed_generate(
|
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
|
||||||
&self,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let (mnemonic, seed) = crate::seed::MasterSeed::generate()?;
|
let (mnemonic, seed) = crate::seed::MasterSeed::generate()?;
|
||||||
|
|
||||||
// Derive and write node Ed25519 key.
|
// Derive and write node Ed25519 key.
|
||||||
@@ -49,12 +77,18 @@ impl RpcHandler {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
tokio::fs::set_permissions(&nostr_secret_path, std::fs::Permissions::from_mode(0o600)).await?;
|
tokio::fs::set_permissions(&nostr_secret_path, std::fs::Permissions::from_mode(0o600))
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize identity index at 0.
|
// Initialize identity index at 0.
|
||||||
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
crate::seed::save_identity_index(&self.config.data_dir, 0).await?;
|
||||||
|
|
||||||
|
// fips_key is now on disk — auto-activate archipelago-fips so the
|
||||||
|
// user doesn't have to hit an "Activate" button. Detached task;
|
||||||
|
// the onboarding RPC returns immediately.
|
||||||
|
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
||||||
|
|
||||||
let words: Vec<&str> = mnemonic.words().collect();
|
let words: Vec<&str> = mnemonic.words().collect();
|
||||||
|
|
||||||
// Hold mnemonic in memory for the verify step.
|
// Hold mnemonic in memory for the verify step.
|
||||||
@@ -79,8 +113,12 @@ impl RpcHandler {
|
|||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let submitted_words: Vec<String> = serde_json::from_value(
|
let submitted_words: Vec<String> = serde_json::from_value(
|
||||||
params.get("words").cloned().ok_or_else(|| anyhow::anyhow!("Missing words"))?,
|
params
|
||||||
).context("Invalid words array")?;
|
.get("words")
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing words"))?,
|
||||||
|
)
|
||||||
|
.context("Invalid words array")?;
|
||||||
|
|
||||||
// Validate against the held mnemonic.
|
// Validate against the held mnemonic.
|
||||||
let mnemonic_str = {
|
let mnemonic_str = {
|
||||||
@@ -89,7 +127,9 @@ impl RpcHandler {
|
|||||||
Some(s) if s.created_at.elapsed() < MNEMONIC_TTL => s.words.clone(),
|
Some(s) if s.created_at.elapsed() < MNEMONIC_TTL => s.words.clone(),
|
||||||
_ => {
|
_ => {
|
||||||
*state = None;
|
*state = None;
|
||||||
anyhow::bail!("No pending seed generation or session expired. Please regenerate.");
|
anyhow::bail!(
|
||||||
|
"No pending seed generation or session expired. Please regenerate."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -134,8 +174,12 @@ impl RpcHandler {
|
|||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let words: Vec<String> = serde_json::from_value(
|
let words: Vec<String> = serde_json::from_value(
|
||||||
params.get("words").cloned().ok_or_else(|| anyhow::anyhow!("Missing words"))?,
|
params
|
||||||
).context("Invalid words array")?;
|
.get("words")
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing words"))?,
|
||||||
|
)
|
||||||
|
.context("Invalid words array")?;
|
||||||
|
|
||||||
let phrase = words.join(" ");
|
let phrase = words.join(" ");
|
||||||
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
|
let (_mnemonic, seed) = crate::seed::MasterSeed::from_mnemonic_words(&phrase)?;
|
||||||
@@ -149,14 +193,19 @@ impl RpcHandler {
|
|||||||
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
let secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||||
let pubkey_hex_nostr = nostr_keys.public_key().to_hex();
|
let pubkey_hex_nostr = nostr_keys.public_key().to_hex();
|
||||||
tokio::fs::write(identity_dir.join("nostr_secret"), secret_hex.as_bytes()).await?;
|
tokio::fs::write(identity_dir.join("nostr_secret"), secret_hex.as_bytes()).await?;
|
||||||
tokio::fs::write(identity_dir.join("nostr_pubkey"), pubkey_hex_nostr.as_bytes()).await?;
|
tokio::fs::write(
|
||||||
|
identity_dir.join("nostr_pubkey"),
|
||||||
|
pubkey_hex_nostr.as_bytes(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
tokio::fs::set_permissions(
|
tokio::fs::set_permissions(
|
||||||
identity_dir.join("nostr_secret"),
|
identity_dir.join("nostr_secret"),
|
||||||
std::fs::Permissions::from_mode(0o600),
|
std::fs::Permissions::from_mode(0o600),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize identity index.
|
// Initialize identity index.
|
||||||
@@ -164,12 +213,14 @@ impl RpcHandler {
|
|||||||
|
|
||||||
// Create default identity from seed.
|
// Create default identity from seed.
|
||||||
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
|
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
|
||||||
manager.create_from_seed(
|
manager
|
||||||
"Personal".to_string(),
|
.create_from_seed(
|
||||||
crate::identity_manager::IdentityPurpose::Personal,
|
"Personal".to_string(),
|
||||||
&seed,
|
crate::identity_manager::IdentityPurpose::Personal,
|
||||||
&self.config.data_dir,
|
&seed,
|
||||||
).await?;
|
&self.config.data_dir,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Get DID and npub for the response.
|
// Get DID and npub for the response.
|
||||||
let node_key = crate::seed::derive_node_ed25519(&seed)?;
|
let node_key = crate::seed::derive_node_ed25519(&seed)?;
|
||||||
@@ -177,6 +228,10 @@ impl RpcHandler {
|
|||||||
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
let did = crate::identity::did_key_from_pubkey_hex(&pubkey_hex)?;
|
||||||
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
|
||||||
|
|
||||||
|
// Same as seed.generate: the key is materialised, kick the FIPS
|
||||||
|
// service up without user interaction.
|
||||||
|
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"did": did,
|
"did": did,
|
||||||
"nostr_npub": nostr_npub,
|
"nostr_npub": nostr_npub,
|
||||||
@@ -190,14 +245,16 @@ impl RpcHandler {
|
|||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let passphrase = params.get("passphrase")
|
let passphrase = params
|
||||||
|
.get("passphrase")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing passphrase"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing passphrase"))?;
|
||||||
|
|
||||||
// Try to get mnemonic from in-memory state first.
|
// Try to get mnemonic from in-memory state first.
|
||||||
let mnemonic_str = {
|
let mnemonic_str = {
|
||||||
let state = ONBOARDING_MNEMONIC.lock().await;
|
let state = ONBOARDING_MNEMONIC.lock().await;
|
||||||
state.as_ref()
|
state
|
||||||
|
.as_ref()
|
||||||
.filter(|s| s.created_at.elapsed() < MNEMONIC_TTL)
|
.filter(|s| s.created_at.elapsed() < MNEMONIC_TTL)
|
||||||
.map(|s| s.words.clone())
|
.map(|s| s.words.clone())
|
||||||
};
|
};
|
||||||
@@ -214,15 +271,14 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return seed status information.
|
/// Return seed status information.
|
||||||
pub(in crate::api::rpc) async fn handle_seed_status(
|
pub(in crate::api::rpc) async fn handle_seed_status(&self) -> Result<serde_json::Value> {
|
||||||
&self,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let has_seed = crate::seed::seed_exists(&self.config.data_dir);
|
let has_seed = crate::seed::seed_exists(&self.config.data_dir);
|
||||||
let has_node_key = crate::identity::NodeIdentity::key_exists(
|
let has_node_key =
|
||||||
&self.config.data_dir.join("identity"),
|
crate::identity::NodeIdentity::key_exists(&self.config.data_dir.join("identity"));
|
||||||
);
|
|
||||||
let is_legacy = has_node_key && !has_seed;
|
let is_legacy = has_node_key && !has_seed;
|
||||||
let next_index = crate::seed::load_identity_index(&self.config.data_dir).await.unwrap_or(0);
|
let next_index = crate::seed::load_identity_index(&self.config.data_dir)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
|
let manager = crate::identity_manager::IdentityManager::new(&self.config.data_dir).await?;
|
||||||
let (identities, _) = manager.list().await?;
|
let (identities, _) = manager.list().await?;
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
//! RPC handlers for streaming ecash payments.
|
||||||
|
//!
|
||||||
|
//! Endpoints for managing priced services, processing payments,
|
||||||
|
//! checking sessions/usage, and publishing service advertisements.
|
||||||
|
|
||||||
|
use super::RpcHandler;
|
||||||
|
use crate::streaming::{advertisement, gate, meter, pricing, session};
|
||||||
|
use crate::wallet::ecash;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
// ── Service pricing management ──
|
||||||
|
|
||||||
|
/// List all configured streaming services and their pricing.
|
||||||
|
pub(super) async fn handle_streaming_list_services(&self) -> Result<serde_json::Value> {
|
||||||
|
let config = pricing::load_pricing(&self.config.data_dir).await?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"services": config.services,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure pricing for a streaming service.
|
||||||
|
pub(super) async fn handle_streaming_configure_service(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
|
||||||
|
let service_id = params
|
||||||
|
.get("service_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(service_id);
|
||||||
|
let metric_str = params
|
||||||
|
.get("metric")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("requests");
|
||||||
|
let step_size = params
|
||||||
|
.get("step_size")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(1);
|
||||||
|
let price_per_step = params
|
||||||
|
.get("price_per_step")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(1);
|
||||||
|
let min_steps = params
|
||||||
|
.get("min_steps")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let enabled = params
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
let description = params
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let metric = match metric_str {
|
||||||
|
"bytes" => pricing::Metric::Bytes,
|
||||||
|
"milliseconds" | "time" => pricing::Metric::Milliseconds,
|
||||||
|
"requests" => pricing::Metric::Requests,
|
||||||
|
_ => return Err(anyhow::anyhow!("Invalid metric: {}", metric_str)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let accepted_mints: Vec<String> = params
|
||||||
|
.get("accepted_mints")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let service = pricing::ServicePricing {
|
||||||
|
service_id: service_id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
metric,
|
||||||
|
step_size,
|
||||||
|
price_per_step,
|
||||||
|
min_steps,
|
||||||
|
enabled,
|
||||||
|
description: description.to_string(),
|
||||||
|
accepted_mints,
|
||||||
|
};
|
||||||
|
service.validate()?;
|
||||||
|
|
||||||
|
let mut config = pricing::load_pricing(&self.config.data_dir).await?;
|
||||||
|
|
||||||
|
// Update existing or add new
|
||||||
|
if let Some(existing) = config
|
||||||
|
.services
|
||||||
|
.iter_mut()
|
||||||
|
.find(|s| s.service_id == service_id)
|
||||||
|
{
|
||||||
|
*existing = service.clone();
|
||||||
|
} else {
|
||||||
|
config.services.push(service.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
pricing::save_pricing(&self.config.data_dir, &config).await?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"service": service,
|
||||||
|
"updated": true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable or disable a streaming service.
|
||||||
|
pub(super) async fn handle_streaming_toggle_service(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let service_id = params
|
||||||
|
.get("service_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
|
||||||
|
let enabled = params
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
|
||||||
|
|
||||||
|
let mut config = pricing::load_pricing(&self.config.data_dir).await?;
|
||||||
|
if let Some(service) = config
|
||||||
|
.services
|
||||||
|
.iter_mut()
|
||||||
|
.find(|s| s.service_id == service_id)
|
||||||
|
{
|
||||||
|
service.enabled = enabled;
|
||||||
|
pricing::save_pricing(&self.config.data_dir, &config).await?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"service_id": service_id,
|
||||||
|
"enabled": enabled,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!("Service '{}' not found", service_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment processing ──
|
||||||
|
|
||||||
|
/// Process a streaming payment — submit a Cashu token for a service.
|
||||||
|
/// Returns session details with allotment on success.
|
||||||
|
pub(super) async fn handle_streaming_pay(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let service_id = params
|
||||||
|
.get("service_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
|
||||||
|
let token = params
|
||||||
|
.get("token")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing token (cashuA token string)"))?;
|
||||||
|
let peer_id = params
|
||||||
|
.get("peer_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing peer_id"))?;
|
||||||
|
|
||||||
|
if token.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("Token cannot be empty"));
|
||||||
|
}
|
||||||
|
if peer_id.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("Peer ID cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result =
|
||||||
|
gate::check_gate(&self.config.data_dir, peer_id, service_id, Some(token), 0).await?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
gate::GateResult::PaidAndAllowed {
|
||||||
|
session_id,
|
||||||
|
allotment,
|
||||||
|
paid_sats,
|
||||||
|
} => Ok(serde_json::json!({
|
||||||
|
"status": "paid",
|
||||||
|
"session_id": session_id,
|
||||||
|
"allotment": allotment,
|
||||||
|
"paid_sats": paid_sats,
|
||||||
|
})),
|
||||||
|
gate::GateResult::InsufficientPayment {
|
||||||
|
provided_sats,
|
||||||
|
minimum_sats,
|
||||||
|
} => Ok(serde_json::json!({
|
||||||
|
"status": "insufficient",
|
||||||
|
"error": { "code": "insufficient_payment", "message": format!("Need {} sats, got {}", minimum_sats, provided_sats) },
|
||||||
|
"minimum_sats": minimum_sats,
|
||||||
|
"provided_sats": provided_sats,
|
||||||
|
})),
|
||||||
|
gate::GateResult::PaymentFailed { reason } => Ok(serde_json::json!({
|
||||||
|
"status": "failed",
|
||||||
|
"error": { "code": "payment_failed", "message": reason },
|
||||||
|
})),
|
||||||
|
gate::GateResult::ServiceUnavailable => {
|
||||||
|
Err(anyhow::anyhow!("Service '{}' not available", service_id))
|
||||||
|
}
|
||||||
|
_ => Err(anyhow::anyhow!("Unexpected gate result")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover available streaming services (pricing info).
|
||||||
|
/// This is the unauthenticated discovery endpoint.
|
||||||
|
pub(super) async fn handle_streaming_discover(&self) -> Result<serde_json::Value> {
|
||||||
|
let config = pricing::load_pricing(&self.config.data_dir).await?;
|
||||||
|
let accepted_mints = ecash::load_accepted_mints(&self.config.data_dir).await?;
|
||||||
|
|
||||||
|
let services: Vec<serde_json::Value> = config
|
||||||
|
.services
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.enabled)
|
||||||
|
.map(|s| {
|
||||||
|
let mints = if s.accepted_mints.is_empty() {
|
||||||
|
&accepted_mints.mints
|
||||||
|
} else {
|
||||||
|
&s.accepted_mints
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"service_id": s.service_id,
|
||||||
|
"name": s.name,
|
||||||
|
"description": s.description,
|
||||||
|
"metric": s.metric,
|
||||||
|
"step_size": s.step_size,
|
||||||
|
"price_per_step": s.price_per_step,
|
||||||
|
"min_steps": s.min_steps,
|
||||||
|
"minimum_sats": s.minimum_payment(),
|
||||||
|
"accepted_mints": mints,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"services": services,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Session management ──
|
||||||
|
|
||||||
|
/// Check usage for a peer's active session.
|
||||||
|
pub(super) async fn handle_streaming_usage(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let peer_id = params
|
||||||
|
.get("peer_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing peer_id"))?;
|
||||||
|
let service_id = params
|
||||||
|
.get("service_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing service_id"))?;
|
||||||
|
|
||||||
|
match meter::get_peer_usage(&self.config.data_dir, peer_id, service_id).await? {
|
||||||
|
Some(usage) => Ok(serde_json::json!({ "usage": usage })),
|
||||||
|
None => Ok(serde_json::json!({
|
||||||
|
"usage": null,
|
||||||
|
"message": "No active session",
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get details of a specific session by ID.
|
||||||
|
pub(super) async fn handle_streaming_session(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let session_id = params
|
||||||
|
.get("session_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing session_id"))?;
|
||||||
|
|
||||||
|
let store = session::load_sessions(&self.config.data_dir).await?;
|
||||||
|
match store.get(session_id) {
|
||||||
|
Some(s) => Ok(serde_json::json!({ "session": s })),
|
||||||
|
None => Err(anyhow::anyhow!("Session not found")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all active streaming sessions (admin view).
|
||||||
|
pub(super) async fn handle_streaming_list_sessions(&self) -> Result<serde_json::Value> {
|
||||||
|
let store = session::load_sessions(&self.config.data_dir).await?;
|
||||||
|
let active = store.active_sessions();
|
||||||
|
let revenue = store.total_revenue();
|
||||||
|
let by_service = store.revenue_by_service();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"sessions": active,
|
||||||
|
"total_active": active.len(),
|
||||||
|
"total_revenue_sats": revenue,
|
||||||
|
"revenue_by_service": by_service,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a specific session.
|
||||||
|
pub(super) async fn handle_streaming_close_session(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let session_id = params
|
||||||
|
.get("session_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing session_id"))?;
|
||||||
|
|
||||||
|
let mut store = session::load_sessions(&self.config.data_dir).await?;
|
||||||
|
if let Some(s) = store.get_mut(session_id) {
|
||||||
|
s.close();
|
||||||
|
session::save_sessions(&self.config.data_dir, &store).await?;
|
||||||
|
Ok(serde_json::json!({ "closed": true }))
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!("Session not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Advertisement ──
|
||||||
|
|
||||||
|
/// Publish a streaming service advertisement to Nostr relays.
|
||||||
|
pub(super) async fn handle_streaming_advertise(&self) -> Result<serde_json::Value> {
|
||||||
|
let config = pricing::load_pricing(&self.config.data_dir).await?;
|
||||||
|
let accepted_mints = ecash::load_accepted_mints(&self.config.data_dir).await?;
|
||||||
|
|
||||||
|
let enabled_count = config.services.iter().filter(|s| s.enabled).count();
|
||||||
|
if enabled_count == 0 {
|
||||||
|
return Err(anyhow::anyhow!("No enabled services to advertise"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get node's onion address for the endpoint tag
|
||||||
|
let onion = crate::container::docker_packages::read_tor_address("archipelago").await;
|
||||||
|
|
||||||
|
let tags = advertisement::build_advertisement_tags(
|
||||||
|
&config,
|
||||||
|
&accepted_mints.mints,
|
||||||
|
onion.as_deref(),
|
||||||
|
);
|
||||||
|
let content = advertisement::build_advertisement_content(&config);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"kind": advertisement::KIND_SERVICE_ADVERTISEMENT,
|
||||||
|
"content": content,
|
||||||
|
"tags": tags,
|
||||||
|
"services_count": enabled_count,
|
||||||
|
"ready_to_publish": true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accepted mints management ──
|
||||||
|
|
||||||
|
/// List accepted mints for streaming payments.
|
||||||
|
pub(super) async fn handle_streaming_list_mints(&self) -> Result<serde_json::Value> {
|
||||||
|
let mints = ecash::load_accepted_mints(&self.config.data_dir).await?;
|
||||||
|
Ok(serde_json::json!({ "mints": mints.mints }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or remove accepted mints.
|
||||||
|
pub(super) async fn handle_streaming_configure_mints(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let mints = params
|
||||||
|
.get("mints")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing mints array"))?;
|
||||||
|
|
||||||
|
let mint_urls: Vec<String> = mints
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if mint_urls.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("Must have at least one accepted mint"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
for url in &mint_urls {
|
||||||
|
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||||
|
return Err(anyhow::anyhow!("Invalid mint URL: {}", url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = ecash::AcceptedMints {
|
||||||
|
mints: mint_urls.clone(),
|
||||||
|
};
|
||||||
|
ecash::save_accepted_mints(&self.config.data_dir, &config).await?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"mints": mint_urls,
|
||||||
|
"updated": true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Maintenance ──
|
||||||
|
|
||||||
|
/// Run streaming maintenance (close expired sessions, prune old records).
|
||||||
|
pub(super) async fn handle_streaming_maintenance(&self) -> Result<serde_json::Value> {
|
||||||
|
let closed = meter::maintenance(&self.config.data_dir).await?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"expired_closed": closed,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,11 @@ impl RpcHandler {
|
|||||||
let (mem_used, mem_total) = read_meminfo().await.unwrap_or((0, 0));
|
let (mem_used, mem_total) = read_meminfo().await.unwrap_or((0, 0));
|
||||||
// Prefer encrypted data partition if it exists
|
// Prefer encrypted data partition if it exists
|
||||||
let data_path = std::path::Path::new("/var/lib/archipelago");
|
let data_path = std::path::Path::new("/var/lib/archipelago");
|
||||||
let df_target = if data_path.exists() { "/var/lib/archipelago" } else { "/" };
|
let df_target = if data_path.exists() {
|
||||||
|
"/var/lib/archipelago"
|
||||||
|
} else {
|
||||||
|
"/"
|
||||||
|
};
|
||||||
let (disk_used, disk_total) = read_disk_usage_path(df_target).await.unwrap_or((0, 0));
|
let (disk_used, disk_total) = read_disk_usage_path(df_target).await.unwrap_or((0, 0));
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
@@ -91,7 +95,9 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// system.detect-usb-devices — scan for known hardware wallet USB devices
|
/// system.detect-usb-devices — scan for known hardware wallet USB devices
|
||||||
pub(in crate::api::rpc) async fn handle_system_detect_usb_devices(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_system_detect_usb_devices(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
debug!("Scanning for USB hardware wallets");
|
debug!("Scanning for USB hardware wallets");
|
||||||
|
|
||||||
let devices = detect_usb_hardware_wallets().await.unwrap_or_default();
|
let devices = detect_usb_hardware_wallets().await.unwrap_or_default();
|
||||||
@@ -103,7 +109,11 @@ impl RpcHandler {
|
|||||||
pub(in crate::api::rpc) async fn handle_system_disk_status(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_system_disk_status(&self) -> Result<serde_json::Value> {
|
||||||
// Prefer the encrypted data partition if it exists
|
// Prefer the encrypted data partition if it exists
|
||||||
let data_path = std::path::Path::new("/var/lib/archipelago");
|
let data_path = std::path::Path::new("/var/lib/archipelago");
|
||||||
let df_target = if data_path.exists() { "/var/lib/archipelago" } else { "/" };
|
let df_target = if data_path.exists() {
|
||||||
|
"/var/lib/archipelago"
|
||||||
|
} else {
|
||||||
|
"/"
|
||||||
|
};
|
||||||
|
|
||||||
let (used, total) = read_disk_usage_path(df_target).await.unwrap_or((0, 0));
|
let (used, total) = read_disk_usage_path(df_target).await.unwrap_or((0, 0));
|
||||||
let percent = if total > 0 {
|
let percent = if total > 0 {
|
||||||
@@ -138,7 +148,9 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// system.disk-cleanup — Remove old container images, stale logs, and temp files.
|
/// system.disk-cleanup — Remove old container images, stale logs, and temp files.
|
||||||
pub(in crate::api::rpc) async fn handle_system_disk_cleanup(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_system_disk_cleanup(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
tracing::info!("Starting disk cleanup");
|
tracing::info!("Starting disk cleanup");
|
||||||
let mut freed_bytes: u64 = 0;
|
let mut freed_bytes: u64 = 0;
|
||||||
let mut actions: Vec<String> = Vec::new();
|
let mut actions: Vec<String> = Vec::new();
|
||||||
@@ -148,7 +160,10 @@ impl RpcHandler {
|
|||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
if bytes > 0 {
|
if bytes > 0 {
|
||||||
freed_bytes += bytes;
|
freed_bytes += bytes;
|
||||||
actions.push(format!("Pruned dangling images: {} freed", format_bytes(bytes)));
|
actions.push(format!(
|
||||||
|
"Pruned dangling images: {} freed",
|
||||||
|
format_bytes(bytes)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => actions.push(format!("Image prune failed: {}", e)),
|
Err(e) => actions.push(format!("Image prune failed: {}", e)),
|
||||||
@@ -187,7 +202,11 @@ impl RpcHandler {
|
|||||||
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
|
Err(e) => actions.push(format!("Build cache prune failed: {}", e)),
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("Disk cleanup complete: {} freed ({} actions)", format_bytes(freed_bytes), actions.len());
|
tracing::info!(
|
||||||
|
"Disk cleanup complete: {} freed ({} actions)",
|
||||||
|
format_bytes(freed_bytes),
|
||||||
|
actions.len()
|
||||||
|
);
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"freed_bytes": freed_bytes,
|
"freed_bytes": freed_bytes,
|
||||||
@@ -226,7 +245,8 @@ impl RpcHandler {
|
|||||||
let _ = tokio::fs::write(
|
let _ = tokio::fs::write(
|
||||||
"/var/lib/archipelago/tor-config/tor-action",
|
"/var/lib/archipelago/tor-config/tor-action",
|
||||||
serde_json::to_string(&action).unwrap_or_default(),
|
serde_json::to_string(&action).unwrap_or_default(),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(serde_json::json!({ "rebooting": true }))
|
Ok(serde_json::json!({ "rebooting": true }))
|
||||||
@@ -327,4 +347,86 @@ impl RpcHandler {
|
|||||||
|
|
||||||
Ok(serde_json::json!({ "status": "resetting" }))
|
Ok(serde_json::json!({ "status": "resetting" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// system.settings.get — Read a settings value
|
||||||
|
pub(in crate::api::rpc) async fn handle_system_settings_get(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let key = params
|
||||||
|
.get("key")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing key"))?;
|
||||||
|
|
||||||
|
match key {
|
||||||
|
"claude_api_key_set" => {
|
||||||
|
let key_file = self.config.data_dir.join("secrets/claude-api-key");
|
||||||
|
let has_key = tokio::fs::metadata(&key_file).await.is_ok();
|
||||||
|
Ok(serde_json::json!({ "value": has_key }))
|
||||||
|
}
|
||||||
|
_ => Ok(serde_json::json!({ "value": null })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// system.settings.set — Write a settings value
|
||||||
|
pub(in crate::api::rpc) async fn handle_system_settings_set(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let key = params
|
||||||
|
.get("key")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing key"))?;
|
||||||
|
let value = params.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
match key {
|
||||||
|
"claude_api_key" => {
|
||||||
|
let secrets_dir = self.config.data_dir.join("secrets");
|
||||||
|
tokio::fs::create_dir_all(&secrets_dir)
|
||||||
|
.await
|
||||||
|
.context("Failed to create secrets dir")?;
|
||||||
|
let key_file = secrets_dir.join("claude-api-key");
|
||||||
|
|
||||||
|
if value.is_empty() {
|
||||||
|
// Remove key
|
||||||
|
tokio::fs::remove_file(&key_file).await.ok();
|
||||||
|
info!("Claude API key removed");
|
||||||
|
} else {
|
||||||
|
// Save key
|
||||||
|
tokio::fs::write(&key_file, value)
|
||||||
|
.await
|
||||||
|
.context("Failed to write API key")?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
info!("Claude API key saved");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the claude-api-proxy environment and restart
|
||||||
|
let env_line = format!("ANTHROPIC_API_KEY={}", value);
|
||||||
|
let env_file = self.config.data_dir.join("secrets/claude-api-proxy.env");
|
||||||
|
tokio::fs::write(&env_file, &env_line).await.ok();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&env_file, std::fs::Permissions::from_mode(0o600))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart the proxy to pick up the new key
|
||||||
|
let _ = tokio::process::Command::new("sudo")
|
||||||
|
.args(["systemctl", "restart", "claude-api-proxy"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "saved": true }))
|
||||||
|
}
|
||||||
|
_ => anyhow::bail!("Unknown setting: {}", key),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,16 +25,17 @@ pub(super) async fn push_name_to_peers(
|
|||||||
if node.trust_level == federation::TrustLevel::Untrusted {
|
if node.trust_level == federation::TrustLevel::Untrusted {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match federation::sync_with_peer(
|
match federation::sync_with_peer(data_dir, node, &local_did, |bytes| {
|
||||||
data_dir,
|
node_identity.sign(bytes)
|
||||||
node,
|
})
|
||||||
&local_did,
|
|
||||||
|bytes| node_identity.sign(bytes),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => synced += 1,
|
Ok(_) => synced += 1,
|
||||||
Err(e) => debug!("Sync with {} after rename: {}", node.did.chars().take(20).collect::<String>(), e),
|
Err(e) => debug!(
|
||||||
|
"Sync with {} after rename: {}",
|
||||||
|
node.did.chars().take(20).collect::<String>(),
|
||||||
|
e
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
info!("Pushed server name to {}/{} peers", synced, nodes.len());
|
info!("Pushed server name to {}/{} peers", synced, nodes.len());
|
||||||
@@ -267,7 +268,10 @@ pub(super) async fn detect_usb_hardware_wallets() -> Result<Vec<serde_json::Valu
|
|||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some((_, name)) = KNOWN_HW_WALLETS.iter().find(|(known_vid, _)| *known_vid == vid) {
|
if let Some((_, name)) = KNOWN_HW_WALLETS
|
||||||
|
.iter()
|
||||||
|
.find(|(known_vid, _)| *known_vid == vid)
|
||||||
|
{
|
||||||
let pid_str = tokio::fs::read_to_string(&product_path)
|
let pid_str = tokio::fs::read_to_string(&product_path)
|
||||||
.await
|
.await
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
@@ -387,14 +391,7 @@ pub(super) async fn clean_temp_files() -> Result<u64> {
|
|||||||
for dir in &["/tmp", "/var/tmp"] {
|
for dir in &["/tmp", "/var/tmp"] {
|
||||||
let output = tokio::process::Command::new("sudo")
|
let output = tokio::process::Command::new("sudo")
|
||||||
.args([
|
.args([
|
||||||
"find",
|
"find", dir, "-type", "f", "-mtime", "+7", "-delete", "-print",
|
||||||
dir,
|
|
||||||
"-type",
|
|
||||||
"f",
|
|
||||||
"-mtime",
|
|
||||||
"+7",
|
|
||||||
"-delete",
|
|
||||||
"-print",
|
|
||||||
])
|
])
|
||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// List all configured hidden services with their .onion addresses.
|
/// List all configured hidden services with their .onion addresses.
|
||||||
pub(in crate::api::rpc) async fn handle_tor_list_services(
|
pub(in crate::api::rpc) async fn handle_tor_list_services(&self) -> Result<serde_json::Value> {
|
||||||
&self,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let config_dir = self.config.data_dir.join("tor-config");
|
let config_dir = self.config.data_dir.join("tor-config");
|
||||||
let services = list_services(&config_dir).await?;
|
let services = list_services(&config_dir).await?;
|
||||||
let tor_running = check_tor_running().await;
|
let tor_running = check_tor_running().await;
|
||||||
@@ -37,7 +35,10 @@ impl RpcHandler {
|
|||||||
let local_port = if raw_port == 0 {
|
let local_port = if raw_port == 0 {
|
||||||
let detected = known_service_port(name);
|
let detected = known_service_port(name);
|
||||||
if detected == 0 {
|
if detected == 0 {
|
||||||
return Err(anyhow::anyhow!("Unknown app '{}' — specify local_port manually", name));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Unknown app '{}' — specify local_port manually",
|
||||||
|
name
|
||||||
|
));
|
||||||
}
|
}
|
||||||
detected
|
detected
|
||||||
} else {
|
} else {
|
||||||
@@ -68,7 +69,11 @@ impl RpcHandler {
|
|||||||
sync_single_hostname(name, addr).await;
|
sync_single_hostname(name, addr).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(service = name, port = local_port, "Created Tor hidden service");
|
info!(
|
||||||
|
service = name,
|
||||||
|
port = local_port,
|
||||||
|
"Created Tor hidden service"
|
||||||
|
);
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"created": true,
|
"created": true,
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -143,7 +148,10 @@ impl RpcHandler {
|
|||||||
|
|
||||||
let old_onion = read_onion_address(name).await;
|
let old_onion = read_onion_address(name).await;
|
||||||
if old_onion.is_none() {
|
if old_onion.is_none() {
|
||||||
return Err(anyhow::anyhow!("Service '{}' has no .onion address to rotate", name));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Service '{}' has no .onion address to rotate",
|
||||||
|
name
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let timestamp = SystemTime::now()
|
let timestamp = SystemTime::now()
|
||||||
@@ -184,7 +192,8 @@ impl RpcHandler {
|
|||||||
&new_addr_clone,
|
&new_addr_clone,
|
||||||
old_onion_clone.as_deref(),
|
old_onion_clone.as_deref(),
|
||||||
tor_proxy.as_deref(),
|
tor_proxy.as_deref(),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +301,10 @@ impl RpcHandler {
|
|||||||
|
|
||||||
if !enabled {
|
if !enabled {
|
||||||
delete_hidden_service_dir(app_id).await;
|
delete_hidden_service_dir(app_id).await;
|
||||||
info!(app = app_id, "Disabled Tor access — removed hidden service dir");
|
info!(
|
||||||
|
app = app_id,
|
||||||
|
"Disabled Tor access — removed hidden service dir"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
regenerate_torrc(&config).await?;
|
regenerate_torrc(&config).await?;
|
||||||
@@ -319,9 +331,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Restart Tor daemon (system or container).
|
/// Restart Tor daemon (system or container).
|
||||||
pub(in crate::api::rpc) async fn handle_tor_restart(
|
pub(in crate::api::rpc) async fn handle_tor_restart(&self) -> Result<serde_json::Value> {
|
||||||
&self,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
info!("Manual Tor restart requested");
|
info!("Manual Tor restart requested");
|
||||||
|
|
||||||
let config_dir = self.config.data_dir.join("tor-config");
|
let config_dir = self.config.data_dir.join("tor-config");
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ pub(super) struct TorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
pub(super) struct ServicesConfig {
|
pub(in crate::api::rpc) struct ServicesConfig {
|
||||||
pub services: Vec<TorServiceEntry>,
|
pub services: Vec<TorServiceEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub(super) struct TorServiceEntry {
|
pub(in crate::api::rpc) struct TorServiceEntry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub local_port: u16,
|
pub local_port: u16,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -46,10 +46,15 @@ fn default_true() -> bool {
|
|||||||
// ─── Validation ───────────────────────────────────────────────────
|
// ─── Validation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
pub(super) fn validate_service_name(name: &str) -> Result<()> {
|
pub(super) fn validate_service_name(name: &str) -> Result<()> {
|
||||||
if name.is_empty() || name.len() > 64
|
if name.is_empty()
|
||||||
|| !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
|| name.len() > 64
|
||||||
|
|| !name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||||
{
|
{
|
||||||
return Err(anyhow::anyhow!("Invalid service name (alphanumeric, hyphens, underscores only)"));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Invalid service name (alphanumeric, hyphens, underscores only)"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -64,7 +69,9 @@ pub(super) async fn dispatch_tor_action(action: serde_json::Value) -> Result<()>
|
|||||||
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
|
let _ = tokio::fs::remove_file(TOR_RESULT_FILE).await;
|
||||||
|
|
||||||
let content = serde_json::to_string(&action).context("Failed to serialize tor action")?;
|
let content = serde_json::to_string(&action).context("Failed to serialize tor action")?;
|
||||||
let config_dir = Path::new(TOR_ACTION_FILE).parent().unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
let config_dir = Path::new(TOR_ACTION_FILE)
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
||||||
tokio::fs::create_dir_all(config_dir).await.ok();
|
tokio::fs::create_dir_all(config_dir).await.ok();
|
||||||
tokio::fs::write(TOR_ACTION_FILE, &content)
|
tokio::fs::write(TOR_ACTION_FILE, &content)
|
||||||
.await
|
.await
|
||||||
@@ -78,20 +85,27 @@ pub(super) async fn dispatch_tor_action(action: serde_json::Value) -> Result<()>
|
|||||||
if result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
|
if result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let err = result.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error");
|
let err = result
|
||||||
|
.get("error")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Unknown error");
|
||||||
return Err(anyhow::anyhow!("Tor helper: {}", err));
|
return Err(anyhow::anyhow!("Tor helper: {}", err));
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(anyhow::anyhow!("Tor helper timed out — is archipelago-tor-helper.path enabled?"))
|
Err(anyhow::anyhow!(
|
||||||
|
"Tor helper timed out — is archipelago-tor-helper.path enabled?"
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn delete_hidden_service_dir(name: &str) {
|
pub(super) async fn delete_hidden_service_dir(name: &str) {
|
||||||
if let Err(e) = dispatch_tor_action(serde_json::json!({
|
if let Err(e) = dispatch_tor_action(serde_json::json!({
|
||||||
"action": "delete-service",
|
"action": "delete-service",
|
||||||
"name": name,
|
"name": name,
|
||||||
})).await {
|
}))
|
||||||
|
.await
|
||||||
|
{
|
||||||
warn!("Failed to delete hidden service dir for {}: {}", name, e);
|
warn!("Failed to delete hidden service dir for {}: {}", name, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,15 +115,18 @@ pub(super) async fn rename_hidden_service_dir(name: &str, timestamp: u64) {
|
|||||||
"action": "rename-service",
|
"action": "rename-service",
|
||||||
"name": name,
|
"name": name,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
})).await {
|
}))
|
||||||
|
.await
|
||||||
|
{
|
||||||
warn!("Failed to rename hidden service dir for {}: {}", name, e);
|
warn!("Failed to rename hidden service dir for {}: {}", name, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn restart_tor() -> Result<()> {
|
pub(in crate::api::rpc) async fn restart_tor() -> Result<()> {
|
||||||
dispatch_tor_action(serde_json::json!({
|
dispatch_tor_action(serde_json::json!({
|
||||||
"action": "write-torrc-and-restart",
|
"action": "write-torrc-and-restart",
|
||||||
})).await
|
}))
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn check_tor_running() -> bool {
|
pub(super) async fn check_tor_running() -> bool {
|
||||||
@@ -125,20 +142,23 @@ pub(super) fn detect_hidden_service_base() -> String {
|
|||||||
return "/var/lib/tor".to_string();
|
return "/var/lib/tor".to_string();
|
||||||
}
|
}
|
||||||
let custom = tor_data_dir();
|
let custom = tor_data_dir();
|
||||||
if Path::new(&custom).join("hidden_service_archipelago").exists() {
|
if Path::new(&custom)
|
||||||
|
.join("hidden_service_archipelago")
|
||||||
|
.exists()
|
||||||
|
{
|
||||||
return custom;
|
return custom;
|
||||||
}
|
}
|
||||||
"/var/lib/tor".to_string()
|
"/var/lib/tor".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
|
pub(in crate::api::rpc) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
|
||||||
let base = detect_hidden_service_base();
|
let base = detect_hidden_service_base();
|
||||||
let mut lines = Vec::new();
|
let mut lines = vec![
|
||||||
|
"# Auto-generated by Archipelago — do not edit manually".to_string(),
|
||||||
lines.push("# Auto-generated by Archipelago — do not edit manually".to_string());
|
"SocksPort 9050".to_string(),
|
||||||
lines.push("SocksPort 9050".to_string());
|
"# ControlPort disabled for security".to_string(),
|
||||||
lines.push("# ControlPort disabled for security".to_string());
|
String::new(),
|
||||||
lines.push(String::new());
|
];
|
||||||
|
|
||||||
for svc in &config.services {
|
for svc in &config.services {
|
||||||
if !svc.enabled {
|
if !svc.enabled {
|
||||||
@@ -149,7 +169,10 @@ pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
|
|||||||
|
|
||||||
if is_protocol_service(&svc.name) {
|
if is_protocol_service(&svc.name) {
|
||||||
let remote_port = svc.remote_port.unwrap_or(svc.local_port);
|
let remote_port = svc.remote_port.unwrap_or(svc.local_port);
|
||||||
lines.push(format!("HiddenServicePort {} 127.0.0.1:{}", remote_port, svc.local_port));
|
lines.push(format!(
|
||||||
|
"HiddenServicePort {} 127.0.0.1:{}",
|
||||||
|
remote_port, svc.local_port
|
||||||
|
));
|
||||||
if svc.name == "lnd" {
|
if svc.name == "lnd" {
|
||||||
lines.push("HiddenServicePort 9735 127.0.0.1:9735".to_string());
|
lines.push("HiddenServicePort 9735 127.0.0.1:9735".to_string());
|
||||||
lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string());
|
lines.push("HiddenServicePort 10009 127.0.0.1:10009".to_string());
|
||||||
@@ -163,19 +186,25 @@ pub(super) async fn regenerate_torrc(config: &ServicesConfig) -> Result<()> {
|
|||||||
|
|
||||||
let content = lines.join("\n");
|
let content = lines.join("\n");
|
||||||
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
|
let staging = "/var/lib/archipelago/tor-config/torrc.staged";
|
||||||
let config_dir = Path::new(staging).parent().unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
let config_dir = Path::new(staging)
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("/var/lib/archipelago/tor-config"));
|
||||||
tokio::fs::create_dir_all(config_dir).await.ok();
|
tokio::fs::create_dir_all(config_dir).await.ok();
|
||||||
tokio::fs::write(staging, &content).await.context("Failed to write staged torrc")?;
|
tokio::fs::write(staging, &content)
|
||||||
|
.await
|
||||||
|
.context("Failed to write staged torrc")?;
|
||||||
|
|
||||||
debug!("Staged torrc with {} enabled services",
|
debug!(
|
||||||
config.services.iter().filter(|s| s.enabled).count());
|
"Staged torrc with {} enabled services",
|
||||||
|
config.services.iter().filter(|s| s.enabled).count()
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Hostname Sync ───────────────────────────────────────────────
|
// ─── Hostname Sync ───────────────────────────────────────────────
|
||||||
|
|
||||||
pub(super) async fn sync_single_hostname(name: &str, address: &str) {
|
pub(in crate::api::rpc) async fn sync_single_hostname(name: &str, address: &str) {
|
||||||
let hostnames_dir = Path::new("/var/lib/archipelago/tor-hostnames");
|
let hostnames_dir = Path::new("/var/lib/archipelago/tor-hostnames");
|
||||||
if let Err(e) = tokio::fs::create_dir_all(hostnames_dir).await {
|
if let Err(e) = tokio::fs::create_dir_all(hostnames_dir).await {
|
||||||
warn!("Failed to create tor-hostnames dir: {}", e);
|
warn!("Failed to create tor-hostnames dir: {}", e);
|
||||||
@@ -223,11 +252,11 @@ pub(super) async fn list_services(config_dir: &std::path::Path) -> Result<Vec<To
|
|||||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
|
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
|
||||||
if name.starts_with("hidden_service_")
|
if name.starts_with("hidden_service_") && !name.contains("_old_") && is_dir {
|
||||||
&& !name.contains("_old_")
|
let service_name = name
|
||||||
&& is_dir
|
.strip_prefix("hidden_service_")
|
||||||
{
|
.unwrap_or(&name)
|
||||||
let service_name = name.strip_prefix("hidden_service_").unwrap_or(&name).to_string();
|
.to_string();
|
||||||
if seen.contains(&service_name) {
|
if seen.contains(&service_name) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -306,7 +335,7 @@ fn is_valid_v3_onion(s: &str) -> bool {
|
|||||||
|
|
||||||
// ─── Known Ports ─────────────────────────────────────────────────
|
// ─── Known Ports ─────────────────────────────────────────────────
|
||||||
|
|
||||||
pub(super) fn known_service_port(name: &str) -> u16 {
|
pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
|
||||||
match name {
|
match name {
|
||||||
"archipelago" => 80,
|
"archipelago" => 80,
|
||||||
"bitcoin" | "bitcoin-knots" => 8333,
|
"bitcoin" | "bitcoin-knots" => 8333,
|
||||||
@@ -331,8 +360,11 @@ pub(super) fn known_service_port(name: &str) -> u16 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn is_protocol_service(name: &str) -> bool {
|
pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
|
||||||
matches!(name, "bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd")
|
matches!(
|
||||||
|
name,
|
||||||
|
"bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Config I/O ──────────────────────────────────────────────────
|
// ─── Config I/O ──────────────────────────────────────────────────
|
||||||
@@ -341,7 +373,9 @@ fn tor_data_dir() -> String {
|
|||||||
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string())
|
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| TOR_DATA_DIR.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn load_services_config(config_dir: &std::path::Path) -> ServicesConfig {
|
pub(in crate::api::rpc) async fn load_services_config(
|
||||||
|
config_dir: &std::path::Path,
|
||||||
|
) -> ServicesConfig {
|
||||||
let path = config_dir.join(SERVICES_CONFIG);
|
let path = config_dir.join(SERVICES_CONFIG);
|
||||||
match tokio::fs::read_to_string(&path).await {
|
match tokio::fs::read_to_string(&path).await {
|
||||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||||
@@ -349,11 +383,19 @@ pub(super) async fn load_services_config(config_dir: &std::path::Path) -> Servic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn save_services_config(config_dir: &std::path::Path, config: &ServicesConfig) -> Result<()> {
|
pub(in crate::api::rpc) async fn save_services_config(
|
||||||
tokio::fs::create_dir_all(config_dir).await.context("Failed to create tor config dir")?;
|
config_dir: &std::path::Path,
|
||||||
|
config: &ServicesConfig,
|
||||||
|
) -> Result<()> {
|
||||||
|
tokio::fs::create_dir_all(config_dir)
|
||||||
|
.await
|
||||||
|
.context("Failed to create tor config dir")?;
|
||||||
let path = config_dir.join(SERVICES_CONFIG);
|
let path = config_dir.join(SERVICES_CONFIG);
|
||||||
let content = serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
|
let content =
|
||||||
tokio::fs::write(&path, content).await.context("Failed to write services config")?;
|
serde_json::to_string_pretty(config).context("Failed to serialize services config")?;
|
||||||
|
tokio::fs::write(&path, content)
|
||||||
|
.await
|
||||||
|
.context("Failed to write services config")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,11 +417,13 @@ pub(super) async fn notify_federation_peers_address_change(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let proxy = tor_proxy.unwrap_or("127.0.0.1:9050");
|
// `tor_proxy` is retained for API compat but unused — the FIPS
|
||||||
|
// fallback dial uses constants::TOR_SOCKS_PROXY internally.
|
||||||
|
let _ = tor_proxy;
|
||||||
match federation::load_nodes(data_dir).await {
|
match federation::load_nodes(data_dir).await {
|
||||||
Ok(peers) => {
|
Ok(peers) => {
|
||||||
for peer in peers {
|
for peer in peers {
|
||||||
if peer.onion.is_empty() {
|
if peer.onion.is_empty() && peer.fips_npub.is_none() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
@@ -390,21 +434,20 @@ pub(super) async fn notify_federation_peers_address_change(
|
|||||||
"old_onion": old_onion,
|
"old_onion": old_onion,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let url = format!("http://{}/rpc/v1", &peer.onion);
|
// FIPS-preferred: peer's fips_npub is stable across
|
||||||
let client = match reqwest::Client::builder()
|
// onion rotation, so this notification reaches them
|
||||||
.proxy(match reqwest::Proxy::all(format!("socks5h://{}", proxy))
|
// even when their (or our) old onion is now stale.
|
||||||
.or_else(|_| reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)) {
|
let req = crate::fips::dial::PeerRequest::new(
|
||||||
Ok(p) => p,
|
peer.fips_npub.as_deref(),
|
||||||
Err(_) => continue,
|
&peer.onion,
|
||||||
})
|
"/rpc/v1",
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
)
|
||||||
.build()
|
.service(crate::settings::transport::PeerService::Peers)
|
||||||
{
|
.timeout(std::time::Duration::from_secs(30));
|
||||||
Ok(c) => c,
|
match req.send_json(&payload).await {
|
||||||
Err(_) => continue,
|
Ok((_, transport)) => {
|
||||||
};
|
info!(peer_did = %peer.did, transport = %transport, "Notified peer of address change")
|
||||||
match client.post(&url).json(&payload).send().await {
|
}
|
||||||
Ok(_) => info!(peer_did = %peer.did, "Notified peer of address change"),
|
|
||||||
Err(e) => warn!(peer_did = %peer.did, "Failed to notify peer: {}", e),
|
Err(e) => warn!(peer_did = %peer.did, "Failed to notify peer: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,13 +461,19 @@ pub(super) async fn notify_federation_peers_address_change(
|
|||||||
|
|
||||||
// ─── Hostname Waiting ────────────────────────────────────────────
|
// ─── Hostname Waiting ────────────────────────────────────────────
|
||||||
|
|
||||||
pub(super) async fn wait_for_hostname(service_name: &str, max_secs: u64) -> Option<String> {
|
pub(in crate::api::rpc) async fn wait_for_hostname(
|
||||||
|
service_name: &str,
|
||||||
|
max_secs: u64,
|
||||||
|
) -> Option<String> {
|
||||||
for _ in 0..max_secs {
|
for _ in 0..max_secs {
|
||||||
if let Some(addr) = read_onion_address(service_name).await {
|
if let Some(addr) = read_onion_address(service_name).await {
|
||||||
return Some(addr);
|
return Some(addr);
|
||||||
}
|
}
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
}
|
}
|
||||||
warn!(service = service_name, "Timed out waiting for new .onion hostname");
|
warn!(
|
||||||
|
service = service_name,
|
||||||
|
"Timed out waiting for new .onion hostname"
|
||||||
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,13 +72,14 @@ impl RpcHandler {
|
|||||||
.session_store
|
.session_store
|
||||||
.get_pending_secret(pending_token)
|
.get_pending_secret(pending_token)
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| anyhow::anyhow!("Setup session expired or invalid. Please start again."))?;
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Setup session expired or invalid. Please start again.")
|
||||||
|
})?;
|
||||||
|
|
||||||
let setup_json: serde_json::Value = serde_json::from_slice(&setup_bytes)?;
|
let setup_json: serde_json::Value = serde_json::from_slice(&setup_bytes)?;
|
||||||
let totp_data: crate::totp::TotpData =
|
let totp_data: crate::totp::TotpData =
|
||||||
serde_json::from_value(setup_json["totp_data"].clone())?;
|
serde_json::from_value(setup_json["totp_data"].clone())?;
|
||||||
let backup_codes: Vec<String> =
|
let backup_codes: Vec<String> = serde_json::from_value(setup_json["backup_codes"].clone())?;
|
||||||
serde_json::from_value(setup_json["backup_codes"].clone())?;
|
|
||||||
|
|
||||||
// Decrypt and verify the TOTP code
|
// Decrypt and verify the TOTP code
|
||||||
let secret = crate::totp::decrypt_secret(&totp_data, password)?;
|
let secret = crate::totp::decrypt_secret(&totp_data, password)?;
|
||||||
@@ -193,7 +194,10 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade pending session to full (rotates token)
|
// Upgrade pending session to full (rotates token)
|
||||||
let new_token = self.session_store.upgrade_to_full(token).await
|
let new_token = self
|
||||||
|
.session_store
|
||||||
|
.upgrade_to_full(token)
|
||||||
|
.await
|
||||||
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
|
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
|
||||||
|
|
||||||
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
|
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
|
||||||
@@ -243,11 +247,20 @@ impl RpcHandler {
|
|||||||
self.auth_manager.update_totp(totp_data).await?;
|
self.auth_manager.update_totp(totp_data).await?;
|
||||||
|
|
||||||
// Upgrade pending session to full (rotates token)
|
// Upgrade pending session to full (rotates token)
|
||||||
let new_token = self.session_store.upgrade_to_full(token).await
|
let new_token = self
|
||||||
|
.session_store
|
||||||
|
.upgrade_to_full(token)
|
||||||
|
.await
|
||||||
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
|
.ok_or_else(|| anyhow::anyhow!("Session expired. Please log in again."))?;
|
||||||
|
|
||||||
tracing::info!("Login via backup code (codes remaining: {})",
|
tracing::info!(
|
||||||
self.auth_manager.get_totp_data().await?.map(|d| d.backup_codes.len()).unwrap_or(0));
|
"Login via backup code (codes remaining: {})",
|
||||||
|
self.auth_manager
|
||||||
|
.get_totp_data()
|
||||||
|
.await?
|
||||||
|
.map(|d| d.backup_codes.len())
|
||||||
|
.unwrap_or(0)
|
||||||
|
);
|
||||||
|
|
||||||
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
|
Ok(serde_json::json!({ "success": true, "new_session_token": new_token }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ impl RpcHandler {
|
|||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.as_ref().ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let did = params["did"]
|
let did = params["did"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'did' param"))?
|
.ok_or_else(|| anyhow::anyhow!("Missing 'did' param"))?
|
||||||
@@ -87,8 +89,8 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Transport router not initialized"))?;
|
.ok_or_else(|| anyhow::anyhow!("Transport router not initialized"))?;
|
||||||
|
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let our_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
|
let our_did =
|
||||||
.unwrap_or_default();
|
crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
|
||||||
|
|
||||||
let message = TransportMessage {
|
let message = TransportMessage {
|
||||||
from_did: our_did,
|
from_did: our_did,
|
||||||
@@ -106,12 +108,54 @@ impl RpcHandler {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// transport.preferences — Return the user's per-service transport
|
||||||
|
/// preferences. The UI renders these as five FIPS/Auto/Tor rows.
|
||||||
|
pub(super) async fn handle_transport_preferences(&self) -> Result<serde_json::Value> {
|
||||||
|
let prefs = crate::settings::transport::snapshot().await;
|
||||||
|
Ok(serde_json::to_value(prefs)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// transport.set-preference — Change a single service preference.
|
||||||
|
/// Persists to disk and hot-swaps the in-memory handle so future
|
||||||
|
/// calls see the new value without restart.
|
||||||
|
pub(super) async fn handle_transport_set_preference(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
use crate::settings::transport::{set, PeerService, TransportPref};
|
||||||
|
let params = params
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let service: PeerService = serde_json::from_value(
|
||||||
|
params
|
||||||
|
.get("service")
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'service' param"))?,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid service: {}", e))?;
|
||||||
|
let pref: TransportPref = serde_json::from_value(
|
||||||
|
params
|
||||||
|
.get("pref")
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'pref' param"))?,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid pref: {}", e))?;
|
||||||
|
|
||||||
|
set(&self.config.data_dir, service, pref).await?;
|
||||||
|
info!(service = ?service, pref = ?pref, "Transport preference updated");
|
||||||
|
|
||||||
|
let current = crate::settings::transport::snapshot().await;
|
||||||
|
Ok(serde_json::to_value(current)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// transport.set-mode — Toggle mesh-only (off-grid) mode.
|
/// transport.set-mode — Toggle mesh-only (off-grid) mode.
|
||||||
pub(super) async fn handle_transport_set_mode(
|
pub(super) async fn handle_transport_set_mode(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.as_ref().ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let mesh_only = params["mesh_only"]
|
let mesh_only = params["mesh_only"]
|
||||||
.as_bool()
|
.as_bool()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'mesh_only' bool param"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'mesh_only' bool param"))?;
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ impl RpcHandler {
|
|||||||
pub(super) async fn handle_update_check(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_update_check(&self) -> Result<serde_json::Value> {
|
||||||
// Try git-based check first (preferred for beta nodes)
|
// Try git-based check first (preferred for beta nodes)
|
||||||
let repo_dir = std::path::PathBuf::from(
|
let repo_dir = std::path::PathBuf::from(
|
||||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||||
)
|
)
|
||||||
.join("archy");
|
.join("archy");
|
||||||
if repo_dir.join(".git").exists() {
|
if repo_dir.join(".git").exists() {
|
||||||
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
|
if let Ok(git_status) = self.git_check_update(&repo_dir).await {
|
||||||
return Ok(git_status);
|
return Ok(git_status);
|
||||||
@@ -50,7 +50,10 @@ impl RpcHandler {
|
|||||||
.context("git fetch failed")?;
|
.context("git fetch failed")?;
|
||||||
|
|
||||||
if !fetch.status.success() {
|
if !fetch.status.success() {
|
||||||
anyhow::bail!("git fetch failed: {}", String::from_utf8_lossy(&fetch.stderr));
|
anyhow::bail!(
|
||||||
|
"git fetch failed: {}",
|
||||||
|
String::from_utf8_lossy(&fetch.stderr)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get local and remote HEADs
|
// Get local and remote HEADs
|
||||||
@@ -85,7 +88,13 @@ impl RpcHandler {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let log = tokio::process::Command::new("git")
|
let log = tokio::process::Command::new("git")
|
||||||
.args(["log", "HEAD..origin/main", "--oneline", "--no-merges", "-20"])
|
.args([
|
||||||
|
"log",
|
||||||
|
"HEAD..origin/main",
|
||||||
|
"--oneline",
|
||||||
|
"--no-merges",
|
||||||
|
"-20",
|
||||||
|
])
|
||||||
.current_dir(&repo_str)
|
.current_dir(&repo_str)
|
||||||
.output()
|
.output()
|
||||||
.await?;
|
.await?;
|
||||||
@@ -115,9 +124,9 @@ impl RpcHandler {
|
|||||||
/// Apply git-based update: runs self-update.sh which pulls, builds, and restarts.
|
/// Apply git-based update: runs self-update.sh which pulls, builds, and restarts.
|
||||||
pub(super) async fn handle_update_git_apply(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_update_git_apply(&self) -> Result<serde_json::Value> {
|
||||||
let script = std::path::PathBuf::from(
|
let script = std::path::PathBuf::from(
|
||||||
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
|
||||||
)
|
)
|
||||||
.join("archy/scripts/self-update.sh");
|
.join("archy/scripts/self-update.sh");
|
||||||
|
|
||||||
if !script.exists() {
|
if !script.exists() {
|
||||||
anyhow::bail!("self-update.sh not found at {}", script.display());
|
anyhow::bail!("self-update.sh not found at {}", script.display());
|
||||||
@@ -199,7 +208,10 @@ impl RpcHandler {
|
|||||||
"manual" => update::UpdateSchedule::Manual,
|
"manual" => update::UpdateSchedule::Manual,
|
||||||
"daily_check" => update::UpdateSchedule::DailyCheck,
|
"daily_check" => update::UpdateSchedule::DailyCheck,
|
||||||
"auto_apply" => update::UpdateSchedule::AutoApply,
|
"auto_apply" => update::UpdateSchedule::AutoApply,
|
||||||
_ => anyhow::bail!("Invalid schedule: '{}'. Use manual, daily_check, or auto_apply", schedule_str),
|
_ => anyhow::bail!(
|
||||||
|
"Invalid schedule: '{}'. Use manual, daily_check, or auto_apply",
|
||||||
|
schedule_str
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
update::set_schedule(&self.config.data_dir, schedule).await?;
|
update::set_schedule(&self.config.data_dir, schedule).await?;
|
||||||
|
|||||||
@@ -9,17 +9,99 @@ impl RpcHandler {
|
|||||||
let status = vpn::get_status().await;
|
let status = vpn::get_status().await;
|
||||||
let config = vpn::load_config(&self.config.data_dir).await?;
|
let config = vpn::load_config(&self.config.data_dir).await?;
|
||||||
|
|
||||||
|
// Check WireGuard wg0 interface for its IP
|
||||||
|
let wg_ip = match tokio::process::Command::new("ip")
|
||||||
|
.args(["-4", "addr", "show", "wg0"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(o) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&o.stdout).to_string();
|
||||||
|
let parsed = stdout
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.contains("inet "))
|
||||||
|
.and_then(|l| l.split_whitespace().nth(1))
|
||||||
|
.map(|ip| ip.split('/').next().unwrap_or(ip).to_string());
|
||||||
|
if parsed.is_none() && !stdout.is_empty() {
|
||||||
|
tracing::debug!("wg0 exists but no inet address found");
|
||||||
|
}
|
||||||
|
// Fallback: if wg0 exists but has no server IP, read from config
|
||||||
|
parsed.or_else(|| {
|
||||||
|
// If wg0 link is up, report the static server IP
|
||||||
|
if stdout.contains("UP") || stdout.contains("POINTOPOINT") {
|
||||||
|
Some("10.44.0.1".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let node_npub = vpn::read_nvpn_config_value("nostr", "public_key")
|
||||||
|
.await
|
||||||
|
.map(|k| vpn::ensure_npub(&k));
|
||||||
|
let (relay_onion, relay_direct) = vpn::get_relay_urls().await;
|
||||||
|
// Prefer onion (always works), fall back to direct IP
|
||||||
|
let relay_url = relay_onion.clone().or(relay_direct.clone());
|
||||||
|
|
||||||
|
// Standalone WireGuard public key
|
||||||
|
let wg_pubkey = tokio::fs::read_to_string("/var/lib/archipelago/wireguard/public.key")
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string());
|
||||||
|
|
||||||
|
// Check if nvpn0 tunnel interface actually exists and has an IP
|
||||||
|
let nvpn0_ip = tokio::process::Command::new("ip")
|
||||||
|
.args(["-4", "addr", "show", "nvpn0"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| {
|
||||||
|
let out = String::from_utf8_lossy(&o.stdout).to_string();
|
||||||
|
out.lines()
|
||||||
|
.find(|l| l.contains("inet "))
|
||||||
|
.and_then(|l| l.split_whitespace().nth(1))
|
||||||
|
.map(|s| s.split('/').next().unwrap_or(s).to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
// NostrVPN IP: only report if nvpn0 tunnel is actually up with its own IP,
|
||||||
|
// and that IP is distinct from the standalone WireGuard IP
|
||||||
|
let nvpn_ip = nvpn0_ip.as_ref().and_then(|ip| {
|
||||||
|
if wg_ip.as_deref() == Some(ip.as_str()) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(ip.clone())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// NostrVPN is connected only if its dedicated tunnel (nvpn0) has a distinct IP
|
||||||
|
let nvpn_connected = status.provider.as_deref() == Some("nostr-vpn") && nvpn_ip.is_some();
|
||||||
|
|
||||||
|
// connected = NostrVPN tunnel is up OR another VPN provider is active OR standalone WireGuard is up
|
||||||
|
let is_connected = if status.provider.as_deref() == Some("nostr-vpn") {
|
||||||
|
nvpn_connected || wg_ip.is_some()
|
||||||
|
} else {
|
||||||
|
status.connected || wg_ip.is_some()
|
||||||
|
};
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"connected": status.connected,
|
"connected": is_connected,
|
||||||
"provider": status.provider,
|
"provider": status.provider,
|
||||||
"interface": status.interface,
|
"interface": status.interface,
|
||||||
"ip_address": status.ip_address,
|
"ip_address": nvpn_ip,
|
||||||
"hostname": status.hostname,
|
"hostname": status.hostname,
|
||||||
"peers_connected": status.peers_connected,
|
"peers_connected": status.peers_connected,
|
||||||
"bytes_in": status.bytes_in,
|
"bytes_in": status.bytes_in,
|
||||||
"bytes_out": status.bytes_out,
|
"bytes_out": status.bytes_out,
|
||||||
"configured": config.enabled,
|
"configured": config.enabled,
|
||||||
"configured_provider": format!("{:?}", config.provider).to_lowercase(),
|
"configured_provider": format!("{:?}", config.provider).to_lowercase(),
|
||||||
|
"wg_ip": wg_ip,
|
||||||
|
"wg_pubkey": wg_pubkey,
|
||||||
|
"node_npub": node_npub,
|
||||||
|
"relay_url": relay_url,
|
||||||
|
"relay_onion": relay_onion,
|
||||||
|
"relay_direct": relay_direct,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,13 +169,8 @@ impl RpcHandler {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let wg_config = vpn::configure_wireguard(
|
let wg_config =
|
||||||
&self.config.data_dir,
|
vpn::configure_wireguard(&self.config.data_dir, address, dns, peer).await?;
|
||||||
address,
|
|
||||||
dns,
|
|
||||||
peer,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
info!("WireGuard VPN configured");
|
info!("WireGuard VPN configured");
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
@@ -104,7 +181,10 @@ impl RpcHandler {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
anyhow::bail!("Unknown provider: {} (expected tailscale or wireguard)", provider);
|
anyhow::bail!(
|
||||||
|
"Unknown provider: {} (expected tailscale or wireguard)",
|
||||||
|
provider
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,9 +270,464 @@ impl RpcHandler {
|
|||||||
.output()
|
.output()
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
vpn::VpnProvider::NostrVpn => {
|
||||||
|
let _ = tokio::process::Command::new("systemctl")
|
||||||
|
.args(["stop", "nostr-vpn"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("VPN disconnected");
|
info!("VPN disconnected");
|
||||||
Ok(serde_json::json!({ "disconnected": true }))
|
Ok(serde_json::json!({ "disconnected": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// vpn.invite — Generate a NostrVPN invite URL + QR for the mobile app.
|
||||||
|
/// Optionally accepts `npub` param to add the phone as a participant in the same call.
|
||||||
|
pub(super) async fn handle_vpn_invite(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
// If an npub was provided, add it as a participant first
|
||||||
|
if let Some(ref p) = params {
|
||||||
|
if let Some(peer_npub) = p.get("npub").and_then(|v| v.as_str()) {
|
||||||
|
if !peer_npub.is_empty() {
|
||||||
|
// Reuse add-participant logic
|
||||||
|
self.handle_vpn_add_participant(Some(serde_json::json!({ "npub": peer_npub })))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read nvpn config to build invite (convert hex to npub1 if needed)
|
||||||
|
let npub = vpn::read_nvpn_config_value("nostr", "public_key")
|
||||||
|
.await
|
||||||
|
.map(|k| vpn::ensure_npub(&k))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("No Nostr public key in nvpn config — VPN not configured")
|
||||||
|
})?;
|
||||||
|
// network_id is in [[networks]] array — read first entry
|
||||||
|
let network_id = vpn::read_nvpn_config_list_entry("networks", "network_id")
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|| "nostr-vpn".to_string());
|
||||||
|
|
||||||
|
// Read relays from config — filter out localhost relays (unreachable from phone)
|
||||||
|
let relays = vpn::read_nvpn_config_list("nostr", "relays").await;
|
||||||
|
let reachable: Vec<String> = relays
|
||||||
|
.iter()
|
||||||
|
.filter(|r| !r.contains("127.0.0.1") && !r.contains("localhost"))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let invite_relays = if reachable.is_empty() {
|
||||||
|
vec![
|
||||||
|
"wss://relay.damus.io".to_string(),
|
||||||
|
"wss://relay.primal.net".to_string(),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
reachable
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build invite as base64-encoded JSON (nvpn v2 format, no padding)
|
||||||
|
use base64::Engine;
|
||||||
|
let invite_payload = serde_json::json!({
|
||||||
|
"v": 2,
|
||||||
|
"networkName": network_id,
|
||||||
|
"networkId": network_id,
|
||||||
|
"inviterNpub": npub,
|
||||||
|
"inviterNodeName": "archipelago",
|
||||||
|
"admins": [npub],
|
||||||
|
"participants": [npub],
|
||||||
|
"relays": invite_relays,
|
||||||
|
});
|
||||||
|
let invite_b64 = base64::engine::general_purpose::STANDARD_NO_PAD
|
||||||
|
.encode(invite_payload.to_string().as_bytes());
|
||||||
|
let invite_url = format!("nvpn://invite/{}", invite_b64);
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
let qr = qrcode::QrCode::new(invite_url.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
|
||||||
|
let svg = qr
|
||||||
|
.render::<qrcode::render::svg::Color>()
|
||||||
|
.min_dimensions(256, 256)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"invite_url": invite_url,
|
||||||
|
"qr_svg": svg,
|
||||||
|
"npub": npub,
|
||||||
|
"network_id": network_id,
|
||||||
|
"relays": invite_relays,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// vpn.add-participant — Add an npub to the mesh network.
|
||||||
|
pub(super) async fn handle_vpn_add_participant(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let npub = params
|
||||||
|
.get("npub")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'npub'"))?;
|
||||||
|
|
||||||
|
// Validate npub format
|
||||||
|
if !npub.starts_with("npub1") || npub.len() < 60 {
|
||||||
|
anyhow::bail!("Invalid npub format");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add participant by editing TOML config directly (nvpn set --participant replaces, not appends)
|
||||||
|
for config_path in vpn::NVPN_CONFIG_PATHS {
|
||||||
|
if let Ok(content) = tokio::fs::read_to_string(config_path).await {
|
||||||
|
if let Ok(mut table) = content.parse::<toml::Table>() {
|
||||||
|
if let Some(networks) = table.get_mut("networks").and_then(|v| v.as_array_mut())
|
||||||
|
{
|
||||||
|
for net in networks.iter_mut() {
|
||||||
|
if let Some(net_table) = net.as_table_mut() {
|
||||||
|
let participants = net_table
|
||||||
|
.entry("participants")
|
||||||
|
.or_insert_with(|| toml::Value::Array(vec![]));
|
||||||
|
if let Some(arr) = participants.as_array_mut() {
|
||||||
|
let npub_val = toml::Value::String(npub.to_string());
|
||||||
|
if !arr.contains(&npub_val) {
|
||||||
|
arr.push(npub_val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(new_content) = toml::to_string_pretty(&table) {
|
||||||
|
// Try direct write first; fall back to sudo cp for root-owned daemon config
|
||||||
|
if tokio::fs::write(config_path, &new_content).await.is_ok() {
|
||||||
|
info!("Added participant to {}", config_path);
|
||||||
|
} else {
|
||||||
|
// Write to temp file, then sudo cp to target
|
||||||
|
let tmp = format!("/tmp/.nvpn-config-{}", std::process::id());
|
||||||
|
if tokio::fs::write(&tmp, &new_content).await.is_ok() {
|
||||||
|
let cp = tokio::process::Command::new("sudo")
|
||||||
|
.args(["cp", &tmp, config_path])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let _ = tokio::fs::remove_file(&tmp).await;
|
||||||
|
match cp {
|
||||||
|
Ok(ref out) if out.status.success() => {
|
||||||
|
info!("Added participant to {} (via sudo)", config_path);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to write {} (even with sudo)",
|
||||||
|
config_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart daemon to pick up the new participant
|
||||||
|
let _ = tokio::process::Command::new("sudo")
|
||||||
|
.args(["systemctl", "restart", "nostr-vpn"])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
info!("VPN participant added: {}", npub);
|
||||||
|
Ok(serde_json::json!({ "added": true, "npub": npub }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// vpn.create-peer — Generate a WireGuard peer config + QR code for mobile devices.
|
||||||
|
pub(super) async fn handle_vpn_create_peer(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.unwrap_or(serde_json::json!({}));
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Mobile");
|
||||||
|
|
||||||
|
// Check that wg0 is up (standalone WireGuard)
|
||||||
|
let wg0_up = tokio::process::Command::new("ip")
|
||||||
|
.args(["link", "show", "wg0"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !wg0_up {
|
||||||
|
anyhow::bail!("WireGuard (wg0) is not running. Wait for first-boot to complete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a keypair for the new peer using wg genkey/pubkey
|
||||||
|
let genkey = tokio::process::Command::new("wg")
|
||||||
|
.arg("genkey")
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("wg genkey failed: {}", e))?;
|
||||||
|
if !genkey.status.success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"wg genkey failed: {}",
|
||||||
|
String::from_utf8_lossy(&genkey.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let peer_private = String::from_utf8_lossy(&genkey.stdout).trim().to_string();
|
||||||
|
|
||||||
|
let mut pubkey_cmd = tokio::process::Command::new("wg");
|
||||||
|
pubkey_cmd.arg("pubkey");
|
||||||
|
pubkey_cmd.stdin(std::process::Stdio::piped());
|
||||||
|
pubkey_cmd.stdout(std::process::Stdio::piped());
|
||||||
|
let mut pubkey_child = pubkey_cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| anyhow::anyhow!("wg pubkey spawn failed: {}", e))?;
|
||||||
|
if let Some(ref mut stdin) = pubkey_child.stdin {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
stdin.write_all(peer_private.as_bytes()).await?;
|
||||||
|
stdin.shutdown().await?;
|
||||||
|
}
|
||||||
|
let pubkey_out = pubkey_child.wait_with_output().await?;
|
||||||
|
let peer_public = String::from_utf8_lossy(&pubkey_out.stdout)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Read server's WireGuard public key (standalone WG key, then fall back to nvpn)
|
||||||
|
let server_pubkey = if let Ok(key) =
|
||||||
|
tokio::fs::read_to_string("/var/lib/archipelago/wireguard/public.key").await
|
||||||
|
{
|
||||||
|
key.trim().to_string()
|
||||||
|
} else {
|
||||||
|
vpn::read_nvpn_config_value("node", "public_key")
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Cannot read server public key"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect host IP — prefer config, then nvpn, then system detection
|
||||||
|
let host_ip = if self.config.host_ip != "127.0.0.1" {
|
||||||
|
self.config.host_ip.clone()
|
||||||
|
} else {
|
||||||
|
// Fallback: get public IP via external service
|
||||||
|
tokio::process::Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null || hostname -I | awk '{print $1}'")
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| self.config.host_ip.clone())
|
||||||
|
};
|
||||||
|
let endpoint = format!("{}:51820", host_ip);
|
||||||
|
|
||||||
|
// Allocate a peer IP (simple: hash the peer name)
|
||||||
|
let peer_num = (name.bytes().map(|b| b as u32).sum::<u32>() % 253) + 2;
|
||||||
|
let peer_ip = format!("10.44.0.{}/32", peer_num);
|
||||||
|
|
||||||
|
// Build WireGuard config for the mobile device
|
||||||
|
let wg_config = format!(
|
||||||
|
"[Interface]\nPrivateKey = {}\nAddress = {}\nDNS = 1.1.1.1\n\n[Peer]\nPublicKey = {}\nEndpoint = {}\nAllowedIPs = 10.44.0.0/16\nPersistentKeepalive = 25\n",
|
||||||
|
peer_private, peer_ip, server_pubkey, endpoint
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate QR code as SVG
|
||||||
|
let qr = qrcode::QrCode::new(wg_config.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
|
||||||
|
let svg = qr
|
||||||
|
.render::<qrcode::render::svg::Color>()
|
||||||
|
.min_dimensions(256, 256)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Save peer info
|
||||||
|
let peers_dir = self.config.data_dir.join("nostr-vpn/peers");
|
||||||
|
tokio::fs::create_dir_all(&peers_dir).await.ok();
|
||||||
|
let peer_info = serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"public_key": peer_public,
|
||||||
|
"ip": peer_ip,
|
||||||
|
"config": wg_config,
|
||||||
|
"created": chrono::Utc::now().to_rfc3339(),
|
||||||
|
});
|
||||||
|
tokio::fs::write(
|
||||||
|
peers_dir.join(format!("{}.json", name.to_lowercase().replace(' ', "-"))),
|
||||||
|
serde_json::to_string_pretty(&peer_info)?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
// Add this peer to the server's WireGuard interface (managed by nvpn).
|
||||||
|
// Try add-peer first; if wg0 doesn't exist, run setup then retry.
|
||||||
|
let peer_filename = format!("{}.json", name.to_lowercase().replace(' ', "-"));
|
||||||
|
let mut peer_added = false;
|
||||||
|
for attempt in 0..2 {
|
||||||
|
let add = tokio::process::Command::new("sudo")
|
||||||
|
.args(["archipelago-wg", "add-peer", &peer_public, &peer_ip])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
match add {
|
||||||
|
Ok(ref out) if out.status.success() => {
|
||||||
|
peer_added = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(ref out) => {
|
||||||
|
let err = String::from_utf8_lossy(&out.stderr);
|
||||||
|
tracing::warn!("add-peer attempt {}: {}", attempt + 1, err);
|
||||||
|
if attempt == 0 {
|
||||||
|
// wg0 may not exist yet — try creating it
|
||||||
|
let server_privkey = vpn::read_nvpn_config_value("node", "private_key")
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !server_privkey.is_empty() {
|
||||||
|
let key_path = "/tmp/.wg-server-key";
|
||||||
|
tokio::fs::write(key_path, &server_privkey).await.ok();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(
|
||||||
|
key_path,
|
||||||
|
std::fs::Permissions::from_mode(0o600),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
let _ = tokio::process::Command::new("sudo")
|
||||||
|
.args(["archipelago-wg", "setup", key_path])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
tokio::fs::remove_file(key_path).await.ok();
|
||||||
|
}
|
||||||
|
// Brief pause before retry
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("add-peer command error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !peer_added {
|
||||||
|
let _ = tokio::fs::remove_file(peers_dir.join(&peer_filename)).await;
|
||||||
|
anyhow::bail!(
|
||||||
|
"Failed to register peer with WireGuard. Check that wg0 interface is up."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("VPN peer created: {} ({})", name, peer_ip);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"peer_ip": peer_ip,
|
||||||
|
"config": wg_config,
|
||||||
|
"qr_svg": svg,
|
||||||
|
"public_key": peer_public,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// vpn.list-peers — List configured VPN peers (WireGuard + NostrVPN participants).
|
||||||
|
pub(super) async fn handle_vpn_list_peers(&self) -> Result<serde_json::Value> {
|
||||||
|
let peers_dir = self.config.data_dir.join("nostr-vpn/peers");
|
||||||
|
let mut peers = Vec::new();
|
||||||
|
|
||||||
|
// WireGuard manual peers (from JSON files)
|
||||||
|
if let Ok(mut entries) = tokio::fs::read_dir(&peers_dir).await {
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
if entry
|
||||||
|
.path()
|
||||||
|
.extension()
|
||||||
|
.map(|e| e == "json")
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
if let Ok(content) = tokio::fs::read_to_string(entry.path()).await {
|
||||||
|
if let Ok(mut peer) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||||
|
peer.as_object_mut()
|
||||||
|
.map(|o| o.insert("type".to_string(), "wireguard".into()));
|
||||||
|
peers.push(peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NostrVPN peer loading removed — standalone WireGuard only
|
||||||
|
Ok(serde_json::json!({ "peers": peers }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// vpn.peer-config — Retrieve stored config + QR for an existing peer.
|
||||||
|
pub(super) async fn handle_vpn_peer_config(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'name'"))?;
|
||||||
|
|
||||||
|
let filename = format!("{}.json", name.to_lowercase().replace(' ', "-"));
|
||||||
|
let peer_file = self.config.data_dir.join("nostr-vpn/peers").join(&filename);
|
||||||
|
|
||||||
|
let content = tokio::fs::read_to_string(&peer_file)
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("Peer '{}' not found", name))?;
|
||||||
|
let peer: serde_json::Value = serde_json::from_str(&content)?;
|
||||||
|
|
||||||
|
let config = peer.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"No config stored for peer '{}' — recreate the device to get a new QR code",
|
||||||
|
name
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let qr = qrcode::QrCode::new(config.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("QR generation failed: {}", e))?;
|
||||||
|
let svg = qr
|
||||||
|
.render::<qrcode::render::svg::Color>()
|
||||||
|
.min_dimensions(256, 256)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"peer_ip": peer.get("ip").and_then(|v| v.as_str()).unwrap_or(""),
|
||||||
|
"config": config,
|
||||||
|
"qr_svg": svg,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// vpn.remove-peer — Remove a VPN peer by name.
|
||||||
|
pub(super) async fn handle_vpn_remove_peer(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let name = params
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing 'name'"))?;
|
||||||
|
|
||||||
|
let filename = format!("{}.json", name.to_lowercase().replace(' ', "-"));
|
||||||
|
let peer_file = self.config.data_dir.join("nostr-vpn/peers").join(&filename);
|
||||||
|
|
||||||
|
// Read peer's public key before deleting, to remove from WireGuard interface
|
||||||
|
let peer_pubkey = tokio::fs::read_to_string(&peer_file)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|c| serde_json::from_str::<serde_json::Value>(&c).ok())
|
||||||
|
.and_then(|v| {
|
||||||
|
v.get("public_key")
|
||||||
|
.and_then(|k| k.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
if tokio::fs::remove_file(&peer_file).await.is_ok() {
|
||||||
|
// Remove peer from WireGuard interface
|
||||||
|
if let Some(pubkey) = peer_pubkey {
|
||||||
|
let _ = tokio::process::Command::new("sudo")
|
||||||
|
.args(["archipelago-wg", "remove-peer", &pubkey])
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
info!("VPN peer removed: {}", name);
|
||||||
|
Ok(serde_json::json!({ "removed": true }))
|
||||||
|
} else {
|
||||||
|
anyhow::bail!("Peer '{}' not found", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ use crate::wallet::{ecash, profits};
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_wallet_ecash_balance(
|
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
|
||||||
&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?;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"balance_sats": wallet.balance(),
|
"balance_sats": wallet.balance(),
|
||||||
"token_count": wallet.tokens.iter().filter(|t| !t.spent).count(),
|
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
|
||||||
|
"mint_url": wallet.mint_url,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +23,41 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||||
|
|
||||||
if amount_sats == 0 || amount_sats > 1_000_000 {
|
if amount_sats == 0 || amount_sats > 1_000_000 {
|
||||||
return Err(anyhow::anyhow!("Amount must be between 1 and 1,000,000 sats"));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Amount must be between 1 and 1,000,000 sats"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = ecash::mint_tokens(&self.config.data_dir, amount_sats).await?;
|
// Step 1: Get a mint quote (returns Lightning invoice)
|
||||||
|
let quote = ecash::mint_quote(&self.config.data_dir, amount_sats).await?;
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"token_id": token.id,
|
"quote_id": quote.quote,
|
||||||
"amount_sats": token.amount_sats,
|
"bolt11": quote.request,
|
||||||
|
"state": quote.state,
|
||||||
|
"amount_sats": amount_sats,
|
||||||
|
"message": "Pay the Lightning invoice, then call wallet.ecash-mint-claim with the quote_id",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim minted tokens after paying the Lightning invoice.
|
||||||
|
pub(super) async fn handle_wallet_ecash_mint_claim(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let quote_id = params
|
||||||
|
.get("quote_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing quote_id"))?;
|
||||||
|
let amount_sats = params
|
||||||
|
.get("amount_sats")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||||
|
|
||||||
|
let minted = ecash::mint_tokens(&self.config.data_dir, quote_id, amount_sats).await?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"minted_sats": minted,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,14 +66,41 @@ impl RpcHandler {
|
|||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
let token_id = params
|
let bolt11 = params
|
||||||
.get("token_id")
|
.get("bolt11")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing token_id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing bolt11 (Lightning invoice)"))?;
|
||||||
|
|
||||||
|
// Step 1: Get melt quote
|
||||||
|
let quote = ecash::melt_quote(&self.config.data_dir, bolt11).await?;
|
||||||
|
|
||||||
let amount = ecash::melt_tokens(&self.config.data_dir, token_id).await?;
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"melted_sats": amount,
|
"quote_id": quote.quote,
|
||||||
|
"amount_sats": quote.amount,
|
||||||
|
"fee_reserve_sats": quote.fee_reserve,
|
||||||
|
"total_needed_sats": quote.amount + quote.fee_reserve,
|
||||||
|
"message": "Call wallet.ecash-melt-confirm with quote_id and bolt11 to execute",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm and execute a melt (pay Lightning invoice with ecash).
|
||||||
|
pub(super) async fn handle_wallet_ecash_melt_confirm(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let quote_id = params
|
||||||
|
.get("quote_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing quote_id"))?;
|
||||||
|
let bolt11 = params
|
||||||
|
.get("bolt11")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
|
||||||
|
|
||||||
|
let melted = ecash::melt_tokens(&self.config.data_dir, quote_id, bolt11).await?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"melted_sats": melted,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,23 +137,20 @@ impl RpcHandler {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_wallet_ecash_history(
|
pub(super) async fn handle_wallet_ecash_history(&self) -> Result<serde_json::Value> {
|
||||||
&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?;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"transactions": wallet.transactions,
|
"transactions": wallet.transactions,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_wallet_networking_profits(
|
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
|
||||||
&self,
|
|
||||||
) -> Result<serde_json::Value> {
|
|
||||||
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
|
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"total_sats": summary.total_sats,
|
"total_sats": summary.total_sats,
|
||||||
"content_sales_sats": summary.content_sales_sats,
|
"content_sales_sats": summary.content_sales_sats,
|
||||||
"routing_fees_sats": summary.routing_fees_sats,
|
"routing_fees_sats": summary.routing_fees_sats,
|
||||||
|
"streaming_revenue_sats": summary.streaming_revenue_sats,
|
||||||
"recent": summary.recent,
|
"recent": summary.recent,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ fn is_webhook_host_private(host: &str) -> bool {
|
|||||||
|| v4.is_private()
|
|| v4.is_private()
|
||||||
|| v4.is_link_local()
|
|| v4.is_link_local()
|
||||||
|| v4.is_unspecified()
|
|| v4.is_unspecified()
|
||||||
|| v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1]) // CGNAT
|
|| v4.octets()[0] == 100 && (64..=127).contains(&v4.octets()[1])
|
||||||
|
// CGNAT
|
||||||
}
|
}
|
||||||
std::net::IpAddr::V6(v6) => {
|
std::net::IpAddr::V6(v6) => {
|
||||||
if v6.is_loopback() || v6.is_unspecified() {
|
if v6.is_loopback() || v6.is_unspecified() {
|
||||||
@@ -45,8 +46,8 @@ fn is_webhook_host_private(host: &str) -> bool {
|
|||||||
}
|
}
|
||||||
// Unique local (fd00::/8, fc00::/7)
|
// Unique local (fd00::/8, fc00::/7)
|
||||||
let segments = v6.segments();
|
let segments = v6.segments();
|
||||||
(segments[0] & 0xfe00) == 0xfc00
|
(segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80
|
||||||
|| (segments[0] & 0xffc0) == 0xfe80 // link-local
|
// link-local
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -66,7 +67,8 @@ fn is_webhook_host_private(host: &str) -> bool {
|
|||||||
let mut all_ok = true;
|
let mut all_ok = true;
|
||||||
for (i, part) in parts.iter().enumerate() {
|
for (i, part) in parts.iter().enumerate() {
|
||||||
let val = if part.starts_with("0x") || part.starts_with("0X") {
|
let val = if part.starts_with("0x") || part.starts_with("0X") {
|
||||||
u64::from_str_radix(part.trim_start_matches("0x").trim_start_matches("0X"), 16).ok()
|
u64::from_str_radix(part.trim_start_matches("0x").trim_start_matches("0X"), 16)
|
||||||
|
.ok()
|
||||||
} else if part.starts_with('0') && part.len() > 1 {
|
} else if part.starts_with('0') && part.len() > 1 {
|
||||||
u64::from_str_radix(part, 8).ok()
|
u64::from_str_radix(part, 8).ok()
|
||||||
} else {
|
} else {
|
||||||
@@ -74,12 +76,18 @@ fn is_webhook_host_private(host: &str) -> bool {
|
|||||||
};
|
};
|
||||||
match val {
|
match val {
|
||||||
Some(v) if v <= 255 => octets[i] = v as u8,
|
Some(v) if v <= 255 => octets[i] = v as u8,
|
||||||
_ => { all_ok = false; break; }
|
_ => {
|
||||||
|
all_ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if all_ok {
|
if all_ok {
|
||||||
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
|
let v4 = std::net::Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
|
||||||
return v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified();
|
return v4.is_loopback()
|
||||||
|
|| v4.is_private()
|
||||||
|
|| v4.is_link_local()
|
||||||
|
|| v4.is_unspecified();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,8 +126,8 @@ impl RpcHandler {
|
|||||||
anyhow::bail!("Webhook URL too long");
|
anyhow::bail!("Webhook URL too long");
|
||||||
}
|
}
|
||||||
// Parse URL properly to handle edge cases (IPv6, userinfo, etc.)
|
// Parse URL properly to handle edge cases (IPv6, userinfo, etc.)
|
||||||
let parsed = reqwest::Url::parse(url)
|
let parsed =
|
||||||
.map_err(|_| anyhow::anyhow!("Invalid webhook URL"))?;
|
reqwest::Url::parse(url).map_err(|_| anyhow::anyhow!("Invalid webhook URL"))?;
|
||||||
// Require https:// in production
|
// Require https:// in production
|
||||||
if !self.config.dev_mode && parsed.scheme() != "https" {
|
if !self.config.dev_mode && parsed.scheme() != "https" {
|
||||||
anyhow::bail!("Webhook URL must use HTTPS in production");
|
anyhow::bail!("Webhook URL must use HTTPS in production");
|
||||||
@@ -152,14 +160,18 @@ impl RpcHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Some(events) = params.get("events") {
|
if let Some(events) = params.get("events") {
|
||||||
if let Ok(parsed) = serde_json::from_value::<Vec<webhooks::WebhookEvent>>(events.clone())
|
if let Ok(parsed) =
|
||||||
|
serde_json::from_value::<Vec<webhooks::WebhookEvent>>(events.clone())
|
||||||
{
|
{
|
||||||
config.events = parsed;
|
config.events = parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
webhooks::save_config(&self.config.data_dir, &config).await?;
|
webhooks::save_config(&self.config.data_dir, &config).await?;
|
||||||
info!("Webhook config updated: enabled={}, url={}", config.enabled, config.url);
|
info!(
|
||||||
|
"Webhook config updated: enabled={}, url={}",
|
||||||
|
config.enabled, config.url
|
||||||
|
);
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"configured": true,
|
"configured": true,
|
||||||
|
|||||||
+107
-104
@@ -14,18 +14,14 @@ use crate::totp::TotpData;
|
|||||||
/// - AppUser: access specific apps, no system configuration
|
/// - AppUser: access specific apps, no system configuration
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[derive(Default)]
|
||||||
pub enum UserRole {
|
pub enum UserRole {
|
||||||
|
#[default]
|
||||||
Admin,
|
Admin,
|
||||||
Viewer,
|
Viewer,
|
||||||
AppUser,
|
AppUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for UserRole {
|
|
||||||
fn default() -> Self {
|
|
||||||
UserRole::Admin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserRole {
|
impl UserRole {
|
||||||
/// Check if this role allows a given RPC method.
|
/// Check if this role allows a given RPC method.
|
||||||
pub fn can_access(&self, method: &str) -> bool {
|
pub fn can_access(&self, method: &str) -> bool {
|
||||||
@@ -102,9 +98,13 @@ impl AuthManager {
|
|||||||
if self.is_setup().await? {
|
if self.is_setup().await? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
tracing::info!("[onboarding] no user found — creating default user (password: password123)");
|
tracing::info!(
|
||||||
|
"[onboarding] no user found — creating default user (password: password123)"
|
||||||
|
);
|
||||||
self.setup_user("password123").await?;
|
self.setup_user("password123").await?;
|
||||||
tracing::info!("[onboarding] default user created — user should change password after login");
|
tracing::info!(
|
||||||
|
"[onboarding] default user created — user should change password after login"
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,11 +149,7 @@ impl AuthManager {
|
|||||||
// Persist to onboarding.json (works even before user/setup exists)
|
// Persist to onboarding.json (works even before user/setup exists)
|
||||||
let onboarding_file = self.data_dir.join("onboarding.json");
|
let onboarding_file = self.data_dir.join("onboarding.json");
|
||||||
let state = OnboardingState { complete: true };
|
let state = OnboardingState { complete: true };
|
||||||
fs::write(
|
fs::write(&onboarding_file, serde_json::to_string_pretty(&state)?).await?;
|
||||||
&onboarding_file,
|
|
||||||
serde_json::to_string_pretty(&state)?,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
// Also update user.json if it exists (keeps them in sync)
|
// Also update user.json if it exists (keeps them in sync)
|
||||||
if let Some(mut user) = self.get_user().await? {
|
if let Some(mut user) = self.get_user().await? {
|
||||||
user.onboarding_complete = true;
|
user.onboarding_complete = true;
|
||||||
@@ -168,11 +164,7 @@ impl AuthManager {
|
|||||||
pub async fn reset_onboarding(&self) -> Result<()> {
|
pub async fn reset_onboarding(&self) -> Result<()> {
|
||||||
let onboarding_file = self.data_dir.join("onboarding.json");
|
let onboarding_file = self.data_dir.join("onboarding.json");
|
||||||
let state = OnboardingState { complete: false };
|
let state = OnboardingState { complete: false };
|
||||||
fs::write(
|
fs::write(&onboarding_file, serde_json::to_string_pretty(&state)?).await?;
|
||||||
&onboarding_file,
|
|
||||||
serde_json::to_string_pretty(&state)?,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if let Some(mut user) = self.get_user().await? {
|
if let Some(mut user) = self.get_user().await? {
|
||||||
user.onboarding_complete = false;
|
user.onboarding_complete = false;
|
||||||
let user_file = self.data_dir.join("user.json");
|
let user_file = self.data_dir.join("user.json");
|
||||||
@@ -203,7 +195,11 @@ impl AuthManager {
|
|||||||
|
|
||||||
/// Check if 2FA is enabled for the user.
|
/// Check if 2FA is enabled for the user.
|
||||||
pub async fn is_totp_enabled(&self) -> Result<bool> {
|
pub async fn is_totp_enabled(&self) -> Result<bool> {
|
||||||
Ok(self.get_user().await?.map(|u| u.totp.is_some()).unwrap_or(false))
|
Ok(self
|
||||||
|
.get_user()
|
||||||
|
.await?
|
||||||
|
.map(|u| u.totp.is_some())
|
||||||
|
.unwrap_or(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the TOTP data (if 2FA is enabled).
|
/// Get the TOTP data (if 2FA is enabled).
|
||||||
@@ -213,7 +209,10 @@ impl AuthManager {
|
|||||||
|
|
||||||
/// Save TOTP data to user.json (enable 2FA).
|
/// Save TOTP data to user.json (enable 2FA).
|
||||||
pub async fn save_totp(&self, totp_data: TotpData) -> Result<()> {
|
pub async fn save_totp(&self, totp_data: TotpData) -> Result<()> {
|
||||||
let mut user = self.get_user().await?.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
|
let mut user = self
|
||||||
|
.get_user()
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
|
||||||
user.totp = Some(totp_data);
|
user.totp = Some(totp_data);
|
||||||
let user_file = self.data_dir.join("user.json");
|
let user_file = self.data_dir.join("user.json");
|
||||||
fs::write(&user_file, serde_json::to_string_pretty(&user)?).await?;
|
fs::write(&user_file, serde_json::to_string_pretty(&user)?).await?;
|
||||||
@@ -222,7 +221,10 @@ impl AuthManager {
|
|||||||
|
|
||||||
/// Remove TOTP data from user.json (disable 2FA).
|
/// Remove TOTP data from user.json (disable 2FA).
|
||||||
pub async fn remove_totp(&self) -> Result<()> {
|
pub async fn remove_totp(&self) -> Result<()> {
|
||||||
let mut user = self.get_user().await?.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
|
let mut user = self
|
||||||
|
.get_user()
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("User not set up"))?;
|
||||||
user.totp = None;
|
user.totp = None;
|
||||||
let user_file = self.data_dir.join("user.json");
|
let user_file = self.data_dir.join("user.json");
|
||||||
fs::write(&user_file, serde_json::to_string_pretty(&user)?).await?;
|
fs::write(&user_file, serde_json::to_string_pretty(&user)?).await?;
|
||||||
@@ -320,6 +322,90 @@ fn validate_password_strength(password: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Change the archipelago user's SSH/login password.
|
||||||
|
/// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors).
|
||||||
|
/// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH.
|
||||||
|
async fn change_ssh_password(new_password: &str) -> Result<()> {
|
||||||
|
let ssh_user =
|
||||||
|
std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||||
|
|
||||||
|
// Generate crypt hash via openssl (SHA-512, compatible with /etc/shadow)
|
||||||
|
// Use /usr/bin/openssl - systemd services often have minimal PATH
|
||||||
|
let mut hash_child = tokio::process::Command::new("/usr/bin/openssl")
|
||||||
|
.args(["passwd", "-6", "-stdin"])
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to run openssl: {}. Is openssl installed?", e))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let mut stdin = hash_child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Failed to open openssl stdin"))?;
|
||||||
|
stdin.write_all(new_password.as_bytes()).await?;
|
||||||
|
stdin.flush().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash_result = hash_child.wait_with_output().await?;
|
||||||
|
if !hash_result.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&hash_result.stderr);
|
||||||
|
anyhow::bail!("openssl passwd failed: {}", stderr);
|
||||||
|
}
|
||||||
|
let hash = String::from_utf8(hash_result.stdout)?.trim().to_string();
|
||||||
|
if hash.is_empty() {
|
||||||
|
anyhow::bail!("openssl passwd produced empty hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
// usermod -p writes directly to /etc/shadow, bypassing PAM
|
||||||
|
// Use /usr/sbin/usermod - not always in systemd's PATH
|
||||||
|
let status = tokio::process::Command::new("/usr/sbin/usermod")
|
||||||
|
.args(["-p", &hash, &ssh_user])
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !status.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&status.stderr);
|
||||||
|
anyhow::bail!("usermod failed: {}", stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("SSH password updated for user {}", ssh_user);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a password with Argon2id (memory-hard, GPU/ASIC resistant).
|
||||||
|
/// Uses PHC string format ($argon2id$v=19$m=65536,t=3,p=4$...) for self-describing storage.
|
||||||
|
fn argon2id_hash(password: &str) -> Result<String> {
|
||||||
|
use argon2::password_hash::SaltString;
|
||||||
|
use argon2::{Argon2, Params, PasswordHasher};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let params = Params::new(65536, 3, 4, Some(32))
|
||||||
|
.map_err(|e| anyhow::anyhow!("Invalid Argon2 params: {}", e))?;
|
||||||
|
let hasher = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||||
|
let hash = hasher
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Argon2id hash failed: {}", e))?;
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a password against an Argon2id PHC string hash.
|
||||||
|
fn argon2id_verify(password: &str, hash: &str) -> bool {
|
||||||
|
use argon2::password_hash::PasswordHash;
|
||||||
|
use argon2::{Argon2, PasswordVerifier};
|
||||||
|
|
||||||
|
let parsed = match PasswordHash::new(hash) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &parsed)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -399,86 +485,3 @@ mod tests {
|
|||||||
assert!(validate_password_strength("MyPassword1234").is_err());
|
assert!(validate_password_strength("MyPassword1234").is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Change the archipelago user's SSH/login password.
|
|
||||||
/// Uses usermod + openssl to bypass PAM (avoids "Authentication token manipulation" errors).
|
|
||||||
/// Uses absolute paths (/usr/bin/openssl, /usr/sbin/usermod) for systemd's minimal PATH.
|
|
||||||
async fn change_ssh_password(new_password: &str) -> Result<()> {
|
|
||||||
let ssh_user = std::env::var("ARCHIPELAGO_SSH_USER").unwrap_or_else(|_| "archipelago".to_string());
|
|
||||||
|
|
||||||
// Generate crypt hash via openssl (SHA-512, compatible with /etc/shadow)
|
|
||||||
// Use /usr/bin/openssl - systemd services often have minimal PATH
|
|
||||||
let mut hash_child = tokio::process::Command::new("/usr/bin/openssl")
|
|
||||||
.args(["passwd", "-6", "-stdin"])
|
|
||||||
.stdin(std::process::Stdio::piped())
|
|
||||||
.stdout(std::process::Stdio::piped())
|
|
||||||
.stderr(std::process::Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to run openssl: {}. Is openssl installed?", e))?;
|
|
||||||
|
|
||||||
{
|
|
||||||
use tokio::io::AsyncWriteExt;
|
|
||||||
let mut stdin = hash_child
|
|
||||||
.stdin
|
|
||||||
.take()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Failed to open openssl stdin"))?;
|
|
||||||
stdin.write_all(new_password.as_bytes()).await?;
|
|
||||||
stdin.flush().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hash_result = hash_child.wait_with_output().await?;
|
|
||||||
if !hash_result.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&hash_result.stderr);
|
|
||||||
anyhow::bail!("openssl passwd failed: {}", stderr);
|
|
||||||
}
|
|
||||||
let hash = String::from_utf8(hash_result.stdout)?
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
if hash.is_empty() {
|
|
||||||
anyhow::bail!("openssl passwd produced empty hash");
|
|
||||||
}
|
|
||||||
|
|
||||||
// usermod -p writes directly to /etc/shadow, bypassing PAM
|
|
||||||
// Use /usr/sbin/usermod - not always in systemd's PATH
|
|
||||||
let status = tokio::process::Command::new("/usr/sbin/usermod")
|
|
||||||
.args(["-p", &hash, &ssh_user])
|
|
||||||
.output()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !status.status.success() {
|
|
||||||
let stderr = String::from_utf8_lossy(&status.stderr);
|
|
||||||
anyhow::bail!("usermod failed: {}", stderr);
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("SSH password updated for user {}", ssh_user);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hash a password with Argon2id (memory-hard, GPU/ASIC resistant).
|
|
||||||
/// Uses PHC string format ($argon2id$v=19$m=65536,t=3,p=4$...) for self-describing storage.
|
|
||||||
fn argon2id_hash(password: &str) -> Result<String> {
|
|
||||||
use argon2::{Argon2, Params, PasswordHasher};
|
|
||||||
use argon2::password_hash::SaltString;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
|
|
||||||
let salt = SaltString::generate(&mut OsRng);
|
|
||||||
let params = Params::new(65536, 3, 4, Some(32))
|
|
||||||
.map_err(|e| anyhow::anyhow!("Invalid Argon2 params: {}", e))?;
|
|
||||||
let hasher = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
|
||||||
let hash = hasher
|
|
||||||
.hash_password(password.as_bytes(), &salt)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Argon2id hash failed: {}", e))?;
|
|
||||||
Ok(hash.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify a password against an Argon2id PHC string hash.
|
|
||||||
fn argon2id_verify(password: &str, hash: &str) -> bool {
|
|
||||||
use argon2::{Argon2, PasswordVerifier};
|
|
||||||
use argon2::password_hash::PasswordHash;
|
|
||||||
|
|
||||||
let parsed = match PasswordHash::new(hash) {
|
|
||||||
Ok(h) => h,
|
|
||||||
Err(_) => return false,
|
|
||||||
};
|
|
||||||
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
//! Deterministic default avatars derived from a Nostr/Ed25519 pubkey.
|
||||||
|
//!
|
||||||
|
//! Two flavours are generated as base64-encoded SVG data URLs so they can
|
||||||
|
//! live directly in `IdentityProfile.picture` without any blob-store round
|
||||||
|
//! trip:
|
||||||
|
//!
|
||||||
|
//! - [`identicon`] — a 5×5 symmetric grid (GitHub-style) for sub-identities.
|
||||||
|
//! - [`master_node_svg`] — a hexagonal-network motif for the primary
|
||||||
|
//! seed-derived identity (derivation index 0). Distinct at a glance from
|
||||||
|
//! the identicons so the user can tell their own node at 48 px.
|
||||||
|
//!
|
||||||
|
//! Both read the first 8 bytes of the hex pubkey, so the same key always
|
||||||
|
//! produces the same avatar — useful for reconstructing history without
|
||||||
|
//! storing the blob.
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
/// Convert a byte to an HSL triple biased toward readable foregrounds on
|
||||||
|
/// dark backgrounds (saturation 60–85%, lightness 52–70%).
|
||||||
|
fn hue_color(seed: u8) -> String {
|
||||||
|
let hue = (seed as u16) * 360 / 256;
|
||||||
|
format!("hsl({}, 72%, 60%)", hue)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accent_color(seed: u8) -> String {
|
||||||
|
let hue = (seed as u16) * 360 / 256;
|
||||||
|
format!("hsl({}, 80%, 68%)", hue)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_svg(svg: &str) -> String {
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
|
||||||
|
format!("data:image/svg+xml;base64,{}", b64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the first 8 bytes from a hex pubkey. Returns `[0u8; 8]` if the
|
||||||
|
/// input is too short or malformed — callers get a consistent default
|
||||||
|
/// avatar rather than an error.
|
||||||
|
fn seed_bytes(pubkey_hex: &str) -> [u8; 8] {
|
||||||
|
let mut out = [0u8; 8];
|
||||||
|
let clean: String = pubkey_hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
|
||||||
|
for (i, byte) in out.iter_mut().enumerate() {
|
||||||
|
let lo = i * 2;
|
||||||
|
if clean.len() >= lo + 2 {
|
||||||
|
*byte = u8::from_str_radix(&clean[lo..lo + 2], 16).unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 5×5 mirrored identicon. ~700 bytes of SVG, ~1 KB as a data URL.
|
||||||
|
pub fn identicon(pubkey_hex: &str) -> String {
|
||||||
|
let bytes = seed_bytes(pubkey_hex);
|
||||||
|
let fg = hue_color(bytes[0]);
|
||||||
|
let bg = "#171a24";
|
||||||
|
|
||||||
|
// 15 bit slots (3 visible columns × 5 rows). Mirror to 5×5.
|
||||||
|
// Use bytes[1..=2] as 16 bits, drop the MSB so we get 15.
|
||||||
|
let bits = u16::from_be_bytes([bytes[1], bytes[2]]) & 0x7fff;
|
||||||
|
|
||||||
|
let mut cells = String::with_capacity(512);
|
||||||
|
let cell_px: u32 = 16;
|
||||||
|
for row in 0..5u32 {
|
||||||
|
for col in 0..5u32 {
|
||||||
|
let src_col = if col < 3 { col } else { 4 - col };
|
||||||
|
let bit_idx = row * 3 + src_col;
|
||||||
|
if (bits >> bit_idx) & 1 == 1 {
|
||||||
|
let x = col * cell_px;
|
||||||
|
let y = row * cell_px;
|
||||||
|
cells.push_str(&format!(
|
||||||
|
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"/>",
|
||||||
|
x, y, cell_px, cell_px
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let svg = format!(
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 80 80\" \
|
||||||
|
shape-rendering=\"crispEdges\">\
|
||||||
|
<rect width=\"80\" height=\"80\" fill=\"{bg}\"/>\
|
||||||
|
<g fill=\"{fg}\">{cells}</g>\
|
||||||
|
</svg>"
|
||||||
|
);
|
||||||
|
encode_svg(&svg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hex-network motif for the master (seed-index-0) identity. Central hex
|
||||||
|
/// plus six ring hexes connected by faint edges, with an accent colour
|
||||||
|
/// derived from the pubkey. Distinct silhouette from the 5×5 identicon so
|
||||||
|
/// the node identity reads differently at every size.
|
||||||
|
pub fn master_node_svg(pubkey_hex: &str) -> String {
|
||||||
|
let bytes = seed_bytes(pubkey_hex);
|
||||||
|
let accent = accent_color(bytes[0]);
|
||||||
|
let accent2 = accent_color(bytes[0].wrapping_add(64));
|
||||||
|
let pattern = bytes[3] & 0x3f; // 6 bits — one per ring hex
|
||||||
|
|
||||||
|
// Hexagon vertices (point-up) at radius 16, centred on (c, c).
|
||||||
|
let hex_path = |cx: f64, cy: f64, r: f64| -> String {
|
||||||
|
let mut pts = String::new();
|
||||||
|
for i in 0..6 {
|
||||||
|
let theta = std::f64::consts::FRAC_PI_3 * (i as f64) - std::f64::consts::FRAC_PI_2;
|
||||||
|
let x = cx + r * theta.cos();
|
||||||
|
let y = cy + r * theta.sin();
|
||||||
|
if i == 0 {
|
||||||
|
pts.push_str(&format!("M{:.2},{:.2}", x, y));
|
||||||
|
} else {
|
||||||
|
pts.push_str(&format!(" L{:.2},{:.2}", x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pts.push_str(" Z");
|
||||||
|
pts
|
||||||
|
};
|
||||||
|
|
||||||
|
let c = 64.0;
|
||||||
|
let ring_r = 36.0;
|
||||||
|
|
||||||
|
// Ring centres (6 hexes at 60° intervals around centre).
|
||||||
|
let ring_centres: Vec<(f64, f64)> = (0..6)
|
||||||
|
.map(|i| {
|
||||||
|
let theta = std::f64::consts::FRAC_PI_3 * (i as f64) - std::f64::consts::FRAC_PI_2;
|
||||||
|
(c + ring_r * theta.cos(), c + ring_r * theta.sin())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut ring_hexes = String::new();
|
||||||
|
let mut edges = String::new();
|
||||||
|
for (i, (rx, ry)) in ring_centres.iter().enumerate() {
|
||||||
|
// Alternate fill/stroke based on pattern bits so two nodes never
|
||||||
|
// share the same ring silhouette.
|
||||||
|
let filled = (pattern >> i) & 1 == 1;
|
||||||
|
let fill = if filled { &accent } else { "none" };
|
||||||
|
let stroke_w = if filled { 0.0 } else { 1.4 };
|
||||||
|
ring_hexes.push_str(&format!(
|
||||||
|
"<path d=\"{}\" fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\" opacity=\"0.92\"/>",
|
||||||
|
hex_path(*rx, *ry, 10.5),
|
||||||
|
fill,
|
||||||
|
&accent,
|
||||||
|
stroke_w
|
||||||
|
));
|
||||||
|
// Edge from centre to this ring node.
|
||||||
|
edges.push_str(&format!(
|
||||||
|
"<line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" \
|
||||||
|
stroke=\"{}\" stroke-width=\"1\" opacity=\"0.35\"/>",
|
||||||
|
c, c, rx, ry, &accent2
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let svg = format!(
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 128 128\">\
|
||||||
|
<defs>\
|
||||||
|
<radialGradient id=\"bg\" cx=\"50%\" cy=\"50%\" r=\"60%\">\
|
||||||
|
<stop offset=\"0%\" stop-color=\"#1c2030\"/>\
|
||||||
|
<stop offset=\"100%\" stop-color=\"#0a0d16\"/>\
|
||||||
|
</radialGradient>\
|
||||||
|
</defs>\
|
||||||
|
<rect width=\"128\" height=\"128\" fill=\"url(#bg)\"/>\
|
||||||
|
{edges}\
|
||||||
|
{ring_hexes}\
|
||||||
|
<path d=\"{centre_hex}\" fill=\"{accent}\" stroke=\"#ffffff\" stroke-width=\"1.5\"/>\
|
||||||
|
</svg>",
|
||||||
|
centre_hex = hex_path(c, c, 16.0),
|
||||||
|
);
|
||||||
|
encode_svg(&svg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a default [`IdentityProfile`]-shaped picture for the given
|
||||||
|
/// identity. The master (seed index 0) gets the node SVG; everyone else
|
||||||
|
/// gets the identicon.
|
||||||
|
pub fn default_picture(pubkey_hex: &str, is_master: bool) -> String {
|
||||||
|
if is_master {
|
||||||
|
master_node_svg(pubkey_hex)
|
||||||
|
} else {
|
||||||
|
identicon(pubkey_hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identicon_is_deterministic() {
|
||||||
|
let a = identicon("aabbccddeeff0011");
|
||||||
|
let b = identicon("aabbccddeeff0011");
|
||||||
|
assert_eq!(a, b);
|
||||||
|
assert!(a.starts_with("data:image/svg+xml;base64,"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn master_is_distinct_from_identicon() {
|
||||||
|
let pk = "aabbccddeeff0011";
|
||||||
|
assert_ne!(identicon(pk), master_node_svg(pk));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_short_or_malformed_hex() {
|
||||||
|
// Shouldn't panic, should still return a valid data URL.
|
||||||
|
let a = identicon("");
|
||||||
|
assert!(a.starts_with("data:image/svg+xml;base64,"));
|
||||||
|
let b = master_node_svg("xyz!!!");
|
||||||
|
assert!(b.starts_with("data:image/svg+xml;base64,"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,8 +109,8 @@ pub async fn create_full_backup(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let meta_path = backups_dir.join(format!("{}.meta.json", metadata.id));
|
let meta_path = backups_dir.join(format!("{}.meta.json", metadata.id));
|
||||||
let meta_json = serde_json::to_string_pretty(&metadata)
|
let meta_json =
|
||||||
.context("Failed to serialize metadata")?;
|
serde_json::to_string_pretty(&metadata).context("Failed to serialize metadata")?;
|
||||||
fs::write(&meta_path, meta_json)
|
fs::write(&meta_path, meta_json)
|
||||||
.await
|
.await
|
||||||
.context("Failed to write metadata")?;
|
.context("Failed to write metadata")?;
|
||||||
@@ -123,11 +123,7 @@ pub async fn create_full_backup(
|
|||||||
///
|
///
|
||||||
/// Uses atomic staging: extracts to a temporary directory first, validates,
|
/// Uses atomic staging: extracts to a temporary directory first, validates,
|
||||||
/// then swaps into place with rollback on failure.
|
/// then swaps into place with rollback on failure.
|
||||||
pub async fn restore_full_backup(
|
pub async fn restore_full_backup(data_dir: &Path, backup_id: &str, passphrase: &str) -> Result<()> {
|
||||||
data_dir: &Path,
|
|
||||||
backup_id: &str,
|
|
||||||
passphrase: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let backup_path = data_dir.join("backups").join(format!("{}.bak", backup_id));
|
let backup_path = data_dir.join("backups").join(format!("{}.bak", backup_id));
|
||||||
if !backup_path.exists() {
|
if !backup_path.exists() {
|
||||||
anyhow::bail!("Backup not found: {}", backup_id);
|
anyhow::bail!("Backup not found: {}", backup_id);
|
||||||
@@ -146,7 +142,11 @@ pub async fn restore_full_backup(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
if let Ok(stdout) = String::from_utf8(output.stdout) {
|
if let Ok(stdout) = String::from_utf8(output.stdout) {
|
||||||
if let Some(avail) = stdout.lines().nth(1).and_then(|l| l.trim().parse::<u64>().ok()) {
|
if let Some(avail) = stdout
|
||||||
|
.lines()
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|l| l.trim().parse::<u64>().ok())
|
||||||
|
{
|
||||||
if avail < backup_size * 2 {
|
if avail < backup_size * 2 {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Insufficient disk space for restore: need {}MB, have {}MB",
|
"Insufficient disk space for restore: need {}MB, have {}MB",
|
||||||
@@ -173,8 +173,8 @@ pub async fn restore_full_backup(
|
|||||||
.context("Failed to create staging directory")?;
|
.context("Failed to create staging directory")?;
|
||||||
|
|
||||||
let staging_clone = staging_dir.clone();
|
let staging_clone = staging_dir.clone();
|
||||||
if let Err(e) = tokio::task::spawn_blocking(move || extract_tar_gz(&staging_clone, &tar_gz_data))
|
if let Err(e) =
|
||||||
.await?
|
tokio::task::spawn_blocking(move || extract_tar_gz(&staging_clone, &tar_gz_data)).await?
|
||||||
{
|
{
|
||||||
let _ = fs::remove_dir_all(&staging_dir).await;
|
let _ = fs::remove_dir_all(&staging_dir).await;
|
||||||
return Err(e).context("Failed to extract backup to staging");
|
return Err(e).context("Failed to extract backup to staging");
|
||||||
@@ -273,7 +273,7 @@ pub async fn list_backups(data_dir: &Path) -> Result<Vec<BackupMetadata>> {
|
|||||||
while let Some(entry) = entries.next_entry().await? {
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().and_then(|e| e.to_str()) == Some("json")
|
if path.extension().and_then(|e| e.to_str()) == Some("json")
|
||||||
&& path.to_str().map_or(false, |s| s.contains(".meta."))
|
&& path.to_str().is_some_and(|s| s.contains(".meta."))
|
||||||
{
|
{
|
||||||
let content = match fs::read_to_string(&path).await {
|
let content = match fs::read_to_string(&path).await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -431,11 +431,7 @@ pub async fn list_usb_drives() -> Result<Vec<UsbDrive>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Copy a backup file to a mounted USB drive.
|
/// Copy a backup file to a mounted USB drive.
|
||||||
pub async fn backup_to_usb(
|
pub async fn backup_to_usb(data_dir: &Path, backup_id: &str, mount_point: &str) -> Result<PathBuf> {
|
||||||
data_dir: &Path,
|
|
||||||
backup_id: &str,
|
|
||||||
mount_point: &str,
|
|
||||||
) -> Result<PathBuf> {
|
|
||||||
let src = backup_file_path(data_dir, backup_id);
|
let src = backup_file_path(data_dir, backup_id);
|
||||||
if !src.exists() {
|
if !src.exists() {
|
||||||
anyhow::bail!("Backup not found: {}", backup_id);
|
anyhow::bail!("Backup not found: {}", backup_id);
|
||||||
@@ -551,7 +547,10 @@ fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> {
|
|||||||
|
|
||||||
for entry_result in archive.entries().context("Failed to read tar entries")? {
|
for entry_result in archive.entries().context("Failed to read tar entries")? {
|
||||||
let mut entry = entry_result.context("Failed to read tar entry")?;
|
let mut entry = entry_result.context("Failed to read tar entry")?;
|
||||||
let entry_path = entry.path().context("Failed to get entry path")?.to_path_buf();
|
let entry_path = entry
|
||||||
|
.path()
|
||||||
|
.context("Failed to get entry path")?
|
||||||
|
.to_path_buf();
|
||||||
|
|
||||||
// Reject entries with path traversal components
|
// Reject entries with path traversal components
|
||||||
for component in entry_path.components() {
|
for component in entry_path.components() {
|
||||||
@@ -570,7 +569,9 @@ fn extract_tar_gz(data_dir: &Path, tar_gz_data: &[u8]) -> Result<()> {
|
|||||||
target.canonicalize()?
|
target.canonicalize()?
|
||||||
} else if let Some(parent) = target.parent() {
|
} else if let Some(parent) = target.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
parent.canonicalize()?.join(target.file_name().unwrap_or_default())
|
parent
|
||||||
|
.canonicalize()?
|
||||||
|
.join(target.file_name().unwrap_or_default())
|
||||||
} else {
|
} else {
|
||||||
target.clone()
|
target.clone()
|
||||||
};
|
};
|
||||||
@@ -720,10 +721,14 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let result = verify_backup(dir.path(), &meta.id, "my-pass").await.unwrap();
|
let result = verify_backup(dir.path(), &meta.id, "my-pass")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(result.valid);
|
assert!(result.valid);
|
||||||
|
|
||||||
let bad_result = verify_backup(dir.path(), &meta.id, "wrong-pass").await.unwrap();
|
let bad_result = verify_backup(dir.path(), &meta.id, "wrong-pass")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
assert!(!bad_result.valid);
|
assert!(!bad_result.valid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ pub async fn restore_encrypted_backup(
|
|||||||
.get("blob")
|
.get("blob")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'blob' in backup"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'blob' in backup"))?;
|
||||||
let blob = BASE64.decode(blob_b64).context("Invalid base64 in backup blob")?;
|
let blob = BASE64
|
||||||
|
.decode(blob_b64)
|
||||||
|
.context("Invalid base64 in backup blob")?;
|
||||||
|
|
||||||
if blob.len() < SALT_LEN + NONCE_LEN {
|
if blob.len() < SALT_LEN + NONCE_LEN {
|
||||||
anyhow::bail!("Backup blob too short");
|
anyhow::bail!("Backup blob too short");
|
||||||
@@ -110,7 +112,9 @@ pub async fn restore_encrypted_backup(
|
|||||||
// Write the restored key
|
// Write the restored key
|
||||||
fs::create_dir_all(identity_dir).await?;
|
fs::create_dir_all(identity_dir).await?;
|
||||||
let key_path = identity_dir.join("node_key");
|
let key_path = identity_dir.join("node_key");
|
||||||
fs::write(&key_path, &plaintext).await.context("Writing restored key")?;
|
fs::write(&key_path, &plaintext)
|
||||||
|
.await
|
||||||
|
.context("Writing restored key")?;
|
||||||
|
|
||||||
// Set restrictive permissions
|
// Set restrictive permissions
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -122,7 +126,10 @@ pub async fn restore_encrypted_backup(
|
|||||||
|
|
||||||
// Derive DID and pubkey from the restored key
|
// Derive DID and pubkey from the restored key
|
||||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(
|
let signing_key = ed25519_dalek::SigningKey::from_bytes(
|
||||||
plaintext.as_slice().try_into().map_err(|_| anyhow::anyhow!("Invalid key"))?,
|
plaintext
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Invalid key"))?,
|
||||||
);
|
);
|
||||||
let pubkey = signing_key.verifying_key();
|
let pubkey = signing_key.verifying_key();
|
||||||
let pubkey_hex = hex::encode(pubkey.as_bytes());
|
let pubkey_hex = hex::encode(pubkey.as_bytes());
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! - `identity`: Encrypted DID identity key backup (existing).
|
//! - `identity`: Encrypted DID identity key backup (existing).
|
||||||
//! - `full`: Full system backup — identity + app data + configs + settings.
|
//! - `full`: Full system backup — identity + app data + configs + settings.
|
||||||
|
|
||||||
mod identity;
|
|
||||||
pub mod full;
|
pub mod full;
|
||||||
|
mod identity;
|
||||||
|
|
||||||
pub use identity::{create_encrypted_backup, restore_encrypted_backup};
|
pub use identity::{create_encrypted_backup, restore_encrypted_backup};
|
||||||
|
|||||||
@@ -70,4 +70,3 @@ pub async fn bitcoin_rpc_credentials() -> (String, String) {
|
|||||||
.await;
|
.await;
|
||||||
(RPC_USER.to_string(), pass.clone())
|
(RPC_USER.to_string(), pass.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//! Content-addressed blob store for attachments shared over mesh/federation.
|
||||||
|
//!
|
||||||
|
//! Blobs live at `${data_dir}/blobs/<cid>` where `cid` is the hex-encoded
|
||||||
|
//! SHA-256 of the content. A sibling `<cid>.meta` file holds JSON metadata
|
||||||
|
//! (mime, filename, size, created_at). Capability URLs are HMAC-signed tokens
|
||||||
|
//! scoped to a recipient pubkey and expiry, verified before serving.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::fs;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
/// Default capability URL validity window.
|
||||||
|
pub const DEFAULT_CAP_TTL_SECS: u64 = 7 * 24 * 60 * 60;
|
||||||
|
|
||||||
|
/// Maximum blob size accepted by the store (64 MiB). Keep attachments
|
||||||
|
/// reasonable so /var/lib/archipelago doesn't balloon unnoticed.
|
||||||
|
pub const MAX_BLOB_SIZE: u64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BlobMeta {
|
||||||
|
pub cid: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub mime: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub filename: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
/// Optional raw thumbnail bytes (small — up to ~60 bytes is LoRa-safe).
|
||||||
|
/// Stored alongside meta so ContentRef senders don't re-fetch the blob.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub thumb_bytes: Option<Vec<u8>>,
|
||||||
|
/// Public blobs (profile pictures, banners) are served at `/blob/<cid>`
|
||||||
|
/// without a capability check so external Nostr clients can fetch them.
|
||||||
|
/// Missing in legacy metadata = default false (cap required).
|
||||||
|
#[serde(default)]
|
||||||
|
pub public: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BlobStore {
|
||||||
|
root: PathBuf,
|
||||||
|
/// HMAC key used to sign capability URLs. Derived from node identity;
|
||||||
|
/// callers pass it in so we don't duplicate key management here.
|
||||||
|
cap_key: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobStore {
|
||||||
|
/// Create (or open) a blob store rooted at `data_dir/blobs`.
|
||||||
|
pub async fn open(data_dir: &Path, cap_key: [u8; 32]) -> Result<Self> {
|
||||||
|
let root = data_dir.join("blobs");
|
||||||
|
fs::create_dir_all(&root)
|
||||||
|
.await
|
||||||
|
.context("create blobs dir")?;
|
||||||
|
Ok(Self { root, cap_key })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_for(&self, cid: &str) -> PathBuf {
|
||||||
|
self.root.join(cid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta_path_for(&self, cid: &str) -> PathBuf {
|
||||||
|
self.root.join(format!("{}.meta", cid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write bytes to the store, returning the CID and metadata. Idempotent:
|
||||||
|
/// identical bytes produce the same CID and short-circuit re-writes.
|
||||||
|
pub async fn put(
|
||||||
|
&self,
|
||||||
|
bytes: &[u8],
|
||||||
|
mime: &str,
|
||||||
|
filename: Option<String>,
|
||||||
|
thumb_bytes: Option<Vec<u8>>,
|
||||||
|
public: bool,
|
||||||
|
) -> Result<BlobMeta> {
|
||||||
|
if bytes.len() as u64 > MAX_BLOB_SIZE {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Blob too large: {} bytes (max {})",
|
||||||
|
bytes.len(),
|
||||||
|
MAX_BLOB_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(bytes);
|
||||||
|
let cid = hex::encode(hasher.finalize());
|
||||||
|
let meta = BlobMeta {
|
||||||
|
cid: cid.clone(),
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
mime: mime.to_string(),
|
||||||
|
filename,
|
||||||
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
thumb_bytes,
|
||||||
|
public,
|
||||||
|
};
|
||||||
|
|
||||||
|
let blob_path = self.path_for(&cid);
|
||||||
|
if !blob_path.exists() {
|
||||||
|
let mut f = fs::File::create(&blob_path).await.context("create blob")?;
|
||||||
|
f.write_all(bytes).await.context("write blob")?;
|
||||||
|
f.sync_all().await.ok();
|
||||||
|
}
|
||||||
|
let meta_json = serde_json::to_vec(&meta)?;
|
||||||
|
fs::write(self.meta_path_for(&cid), meta_json)
|
||||||
|
.await
|
||||||
|
.context("write blob meta")?;
|
||||||
|
Ok(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read raw bytes for a CID. Errors if missing.
|
||||||
|
pub async fn get(&self, cid: &str) -> Result<Vec<u8>> {
|
||||||
|
let path = self.path_for(cid);
|
||||||
|
fs::read(&path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("blob not found: {}", cid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load metadata for a CID.
|
||||||
|
pub async fn meta(&self, cid: &str) -> Result<BlobMeta> {
|
||||||
|
let raw = fs::read(self.meta_path_for(cid))
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("blob meta not found: {}", cid))?;
|
||||||
|
Ok(serde_json::from_slice(&raw)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a CID is held locally.
|
||||||
|
pub async fn has(&self, cid: &str) -> bool {
|
||||||
|
fs::try_exists(self.path_for(cid)).await.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign a capability token: HMAC-SHA256(cid || peer_pubkey || expiry).
|
||||||
|
/// Returned token is hex — callers append `?cap=<token>&exp=<epoch>` to
|
||||||
|
/// the blob URL sent to the peer.
|
||||||
|
pub fn issue_capability(&self, cid: &str, peer_pubkey_hex: &str, expiry_epoch: u64) -> String {
|
||||||
|
let mut mac = HmacSha256::new_from_slice(&self.cap_key).expect("hmac key");
|
||||||
|
mac.update(cid.as_bytes());
|
||||||
|
mac.update(b"|");
|
||||||
|
mac.update(peer_pubkey_hex.as_bytes());
|
||||||
|
mac.update(b"|");
|
||||||
|
mac.update(&expiry_epoch.to_be_bytes());
|
||||||
|
hex::encode(mac.finalize().into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a capability token against (cid, peer_pubkey, expiry).
|
||||||
|
/// Returns Ok(()) on success, Err describing the failure otherwise.
|
||||||
|
/// Expired tokens fail even with a correct signature.
|
||||||
|
pub fn verify_capability(
|
||||||
|
&self,
|
||||||
|
cid: &str,
|
||||||
|
peer_pubkey_hex: &str,
|
||||||
|
expiry_epoch: u64,
|
||||||
|
token_hex: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let now = chrono::Utc::now().timestamp() as u64;
|
||||||
|
if expiry_epoch < now {
|
||||||
|
return Err(anyhow!("capability expired"));
|
||||||
|
}
|
||||||
|
let expected = self.issue_capability(cid, peer_pubkey_hex, expiry_epoch);
|
||||||
|
// Constant-time compare via HMAC verify.
|
||||||
|
let token_bytes =
|
||||||
|
hex::decode(token_hex).map_err(|_| anyhow!("capability token not hex"))?;
|
||||||
|
let expected_bytes = hex::decode(&expected).unwrap();
|
||||||
|
if token_bytes.len() != expected_bytes.len() {
|
||||||
|
return Err(anyhow!("capability length mismatch"));
|
||||||
|
}
|
||||||
|
// hmac::Mac::verify is the idiomatic constant-time path, but we
|
||||||
|
// already computed `expected` so fall back to ct_eq via subtle.
|
||||||
|
let mut diff = 0u8;
|
||||||
|
for (a, b) in token_bytes.iter().zip(expected_bytes.iter()) {
|
||||||
|
diff |= a ^ b;
|
||||||
|
}
|
||||||
|
if diff == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("capability signature mismatch"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
-36
@@ -82,18 +82,21 @@ impl Config {
|
|||||||
pub async fn load() -> Result<Self> {
|
pub async fn load() -> Result<Self> {
|
||||||
// Default configuration
|
// Default configuration
|
||||||
let mut config = Self::default();
|
let mut config = Self::default();
|
||||||
|
|
||||||
// Detect if running from macOS app bundle
|
// Detect if running from macOS app bundle
|
||||||
if let Ok(exe_path) = std::env::current_exe() {
|
if let Ok(exe_path) = std::env::current_exe() {
|
||||||
if let Some(exe_str) = exe_path.to_str() {
|
if let Some(exe_str) = exe_path.to_str() {
|
||||||
if exe_str.contains(".app/Contents/MacOS") {
|
if exe_str.contains(".app/Contents/MacOS") {
|
||||||
// Running from macOS bundle - use user's Library directory
|
// Running from macOS bundle - use user's Library directory
|
||||||
if let Some(home) = std::env::var_os("HOME") {
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
let app_support = PathBuf::from(home)
|
let app_support =
|
||||||
.join("Library/Application Support/Archipelago");
|
PathBuf::from(home).join("Library/Application Support/Archipelago");
|
||||||
config.data_dir = app_support.join("data");
|
config.data_dir = app_support.join("data");
|
||||||
config.dev_data_dir = app_support.join("data");
|
config.dev_data_dir = app_support.join("data");
|
||||||
tracing::info!("🍎 Detected macOS bundle, using: {}", app_support.display());
|
tracing::info!(
|
||||||
|
"🍎 Detected macOS bundle, using: {}",
|
||||||
|
app_support.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,10 +105,11 @@ impl Config {
|
|||||||
// Try to load from config file
|
// Try to load from config file
|
||||||
let config_path = Path::new("/etc/archipelago/config.toml");
|
let config_path = Path::new("/etc/archipelago/config.toml");
|
||||||
if config_path.exists() {
|
if config_path.exists() {
|
||||||
let content = fs::read_to_string(config_path).await
|
let content = fs::read_to_string(config_path)
|
||||||
|
.await
|
||||||
.context("Failed to read config file")?;
|
.context("Failed to read config file")?;
|
||||||
let file_config: Config = toml::de::from_str(&content)
|
let file_config: Config =
|
||||||
.context("Failed to parse config file")?;
|
toml::de::from_str(&content).context("Failed to parse config file")?;
|
||||||
config = file_config;
|
config = file_config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +122,8 @@ impl Config {
|
|||||||
let parts: Vec<&str> = bind.split(':').collect();
|
let parts: Vec<&str> = bind.split(':').collect();
|
||||||
if parts.len() == 2 {
|
if parts.len() == 2 {
|
||||||
config.bind_host = parts[0].to_string();
|
config.bind_host = parts[0].to_string();
|
||||||
config.bind_port = parts[1].parse()
|
config.bind_port = parts[1]
|
||||||
|
.parse()
|
||||||
.context("Invalid port in ARCHIPELAGO_BIND")?;
|
.context("Invalid port in ARCHIPELAGO_BIND")?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +142,8 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(offset) = std::env::var("ARCHIPELAGO_PORT_OFFSET") {
|
if let Ok(offset) = std::env::var("ARCHIPELAGO_PORT_OFFSET") {
|
||||||
config.port_offset = offset.parse()
|
config.port_offset = offset
|
||||||
|
.parse()
|
||||||
.context("Invalid port offset in ARCHIPELAGO_PORT_OFFSET")?;
|
.context("Invalid port offset in ARCHIPELAGO_PORT_OFFSET")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,12 +179,14 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure data directory exists
|
||||||
fs::create_dir_all(&config.data_dir).await
|
fs::create_dir_all(&config.data_dir)
|
||||||
|
.await
|
||||||
.context("Failed to create data directory")?;
|
.context("Failed to create data directory")?;
|
||||||
|
|
||||||
// Ensure dev data directory exists if in dev mode
|
// Ensure dev data directory exists if in dev mode
|
||||||
if config.dev_mode {
|
if config.dev_mode {
|
||||||
fs::create_dir_all(&config.dev_data_dir).await
|
fs::create_dir_all(&config.dev_data_dir)
|
||||||
|
.await
|
||||||
.context("Failed to create dev data directory")?;
|
.context("Failed to create dev data directory")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +207,12 @@ impl Default for Config {
|
|||||||
port_offset: 10000,
|
port_offset: 10000,
|
||||||
bitcoin_simulation: BitcoinSimulation::Mock,
|
bitcoin_simulation: BitcoinSimulation::Mock,
|
||||||
dev_data_dir: PathBuf::from("/tmp/archipelago-dev"),
|
dev_data_dir: PathBuf::from("/tmp/archipelago-dev"),
|
||||||
nostr_discovery_enabled: true,
|
// Discoverability is opt-in. Until the user explicitly enables it
|
||||||
|
// (Settings UI / `nostr_discovery_enabled = true` in config), no
|
||||||
|
// presence event is ever published and `handshake.poll` never
|
||||||
|
// contacts a relay. This is the sole knob that controls whether
|
||||||
|
// we leak our DID + npub to the public Nostr relays.
|
||||||
|
nostr_discovery_enabled: false,
|
||||||
nostr_relays: vec![
|
nostr_relays: vec![
|
||||||
"wss://relay.damus.io".into(),
|
"wss://relay.damus.io".into(),
|
||||||
"wss://relay.nostr.info".into(),
|
"wss://relay.nostr.info".into(),
|
||||||
@@ -223,50 +236,110 @@ mod tests {
|
|||||||
assert_eq!(config.host_ip, "127.0.0.1");
|
assert_eq!(config.host_ip, "127.0.0.1");
|
||||||
assert!(!config.dev_mode);
|
assert!(!config.dev_mode);
|
||||||
assert_eq!(config.port_offset, 10000);
|
assert_eq!(config.port_offset, 10000);
|
||||||
assert!(config.nostr_discovery_enabled);
|
assert!(!config.nostr_discovery_enabled);
|
||||||
assert_eq!(config.nostr_relays.len(), 2);
|
assert_eq!(config.nostr_relays.len(), 2);
|
||||||
assert_eq!(config.nostr_tor_proxy, Some("127.0.0.1:9050".to_string()));
|
assert_eq!(config.nostr_tor_proxy, Some("127.0.0.1:9050".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_container_runtime_from_str_podman() {
|
fn test_container_runtime_from_str_podman() {
|
||||||
assert!(matches!(ContainerRuntime::from_str("podman"), ContainerRuntime::Podman));
|
assert!(matches!(
|
||||||
assert!(matches!(ContainerRuntime::from_str("Podman"), ContainerRuntime::Podman));
|
ContainerRuntime::from_str("podman"),
|
||||||
assert!(matches!(ContainerRuntime::from_str("PODMAN"), ContainerRuntime::Podman));
|
ContainerRuntime::Podman
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
ContainerRuntime::from_str("Podman"),
|
||||||
|
ContainerRuntime::Podman
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
ContainerRuntime::from_str("PODMAN"),
|
||||||
|
ContainerRuntime::Podman
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_container_runtime_from_str_docker() {
|
fn test_container_runtime_from_str_docker() {
|
||||||
assert!(matches!(ContainerRuntime::from_str("docker"), ContainerRuntime::Docker));
|
assert!(matches!(
|
||||||
assert!(matches!(ContainerRuntime::from_str("Docker"), ContainerRuntime::Docker));
|
ContainerRuntime::from_str("docker"),
|
||||||
assert!(matches!(ContainerRuntime::from_str("DOCKER"), ContainerRuntime::Docker));
|
ContainerRuntime::Docker
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
ContainerRuntime::from_str("Docker"),
|
||||||
|
ContainerRuntime::Docker
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
ContainerRuntime::from_str("DOCKER"),
|
||||||
|
ContainerRuntime::Docker
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_container_runtime_from_str_auto() {
|
fn test_container_runtime_from_str_auto() {
|
||||||
assert!(matches!(ContainerRuntime::from_str("auto"), ContainerRuntime::Auto));
|
assert!(matches!(
|
||||||
assert!(matches!(ContainerRuntime::from_str("Auto"), ContainerRuntime::Auto));
|
ContainerRuntime::from_str("auto"),
|
||||||
|
ContainerRuntime::Auto
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
ContainerRuntime::from_str("Auto"),
|
||||||
|
ContainerRuntime::Auto
|
||||||
|
));
|
||||||
// Unknown strings default to Auto
|
// Unknown strings default to Auto
|
||||||
assert!(matches!(ContainerRuntime::from_str("unknown"), ContainerRuntime::Auto));
|
assert!(matches!(
|
||||||
assert!(matches!(ContainerRuntime::from_str(""), ContainerRuntime::Auto));
|
ContainerRuntime::from_str("unknown"),
|
||||||
|
ContainerRuntime::Auto
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
ContainerRuntime::from_str(""),
|
||||||
|
ContainerRuntime::Auto
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_bitcoin_simulation_from_str() {
|
fn test_bitcoin_simulation_from_str() {
|
||||||
assert!(matches!(BitcoinSimulation::from_str("mock"), BitcoinSimulation::Mock));
|
assert!(matches!(
|
||||||
assert!(matches!(BitcoinSimulation::from_str("Mock"), BitcoinSimulation::Mock));
|
BitcoinSimulation::from_str("mock"),
|
||||||
assert!(matches!(BitcoinSimulation::from_str("testnet"), BitcoinSimulation::Testnet));
|
BitcoinSimulation::Mock
|
||||||
assert!(matches!(BitcoinSimulation::from_str("Testnet"), BitcoinSimulation::Testnet));
|
));
|
||||||
assert!(matches!(BitcoinSimulation::from_str("mainnet"), BitcoinSimulation::Mainnet));
|
assert!(matches!(
|
||||||
assert!(matches!(BitcoinSimulation::from_str("Mainnet"), BitcoinSimulation::Mainnet));
|
BitcoinSimulation::from_str("Mock"),
|
||||||
assert!(matches!(BitcoinSimulation::from_str("none"), BitcoinSimulation::None));
|
BitcoinSimulation::Mock
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
BitcoinSimulation::from_str("testnet"),
|
||||||
|
BitcoinSimulation::Testnet
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
BitcoinSimulation::from_str("Testnet"),
|
||||||
|
BitcoinSimulation::Testnet
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
BitcoinSimulation::from_str("mainnet"),
|
||||||
|
BitcoinSimulation::Mainnet
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
BitcoinSimulation::from_str("Mainnet"),
|
||||||
|
BitcoinSimulation::Mainnet
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
BitcoinSimulation::from_str("none"),
|
||||||
|
BitcoinSimulation::None
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_bitcoin_simulation_unknown_defaults_to_none() {
|
fn test_bitcoin_simulation_unknown_defaults_to_none() {
|
||||||
assert!(matches!(BitcoinSimulation::from_str(""), BitcoinSimulation::None));
|
assert!(matches!(
|
||||||
assert!(matches!(BitcoinSimulation::from_str("signet"), BitcoinSimulation::None));
|
BitcoinSimulation::from_str(""),
|
||||||
assert!(matches!(BitcoinSimulation::from_str("garbage"), BitcoinSimulation::None));
|
BitcoinSimulation::None
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
BitcoinSimulation::from_str("signet"),
|
||||||
|
BitcoinSimulation::None
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
BitcoinSimulation::from_str("garbage"),
|
||||||
|
BitcoinSimulation::None
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -280,7 +353,10 @@ mod tests {
|
|||||||
assert_eq!(deserialized.log_level, config.log_level);
|
assert_eq!(deserialized.log_level, config.log_level);
|
||||||
assert_eq!(deserialized.dev_mode, config.dev_mode);
|
assert_eq!(deserialized.dev_mode, config.dev_mode);
|
||||||
assert_eq!(deserialized.port_offset, config.port_offset);
|
assert_eq!(deserialized.port_offset, config.port_offset);
|
||||||
assert_eq!(deserialized.nostr_discovery_enabled, config.nostr_discovery_enabled);
|
assert_eq!(
|
||||||
|
deserialized.nostr_discovery_enabled,
|
||||||
|
config.nostr_discovery_enabled
|
||||||
|
);
|
||||||
assert_eq!(deserialized.nostr_relays, config.nostr_relays);
|
assert_eq!(deserialized.nostr_relays, config.nostr_relays);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,9 +409,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_config_nostr_discovery_enabled_by_default() {
|
fn test_config_nostr_discovery_disabled_by_default() {
|
||||||
|
// Discoverability is opt-in: nothing is published to public relays
|
||||||
|
// until the user explicitly turns it on. Flipping this back to
|
||||||
|
// `true` would silently start leaking the local DID + npub on every
|
||||||
|
// boot — guard rail.
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
assert!(config.nostr_discovery_enabled);
|
assert!(!config.nostr_discovery_enabled);
|
||||||
assert!(config.nostr_tor_proxy.is_some());
|
assert!(config.nostr_tor_proxy.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/// Centralized constants for the Archipelago backend.
|
//! Centralized constants for the Archipelago backend.
|
||||||
/// Avoids hardcoded values scattered across the codebase.
|
//! Avoids hardcoded values scattered across the codebase.
|
||||||
|
|
||||||
/// Bitcoin Core RPC endpoint (localhost only).
|
/// Bitcoin Core RPC endpoint (localhost only).
|
||||||
pub const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
|
pub const BITCOIN_RPC_URL: &str = "http://127.0.0.1:8332/";
|
||||||
@@ -9,4 +9,3 @@ pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
|
|||||||
|
|
||||||
/// Tor SOCKS5 proxy for outbound onion connections.
|
/// Tor SOCKS5 proxy for outbound onion connections.
|
||||||
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
|
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ impl DevDataManager {
|
|||||||
// Map production path to dev path
|
// Map production path to dev path
|
||||||
// e.g., /var/lib/archipelago/bitcoin -> /tmp/archipelago-dev/bitcoin
|
// e.g., /var/lib/archipelago/bitcoin -> /tmp/archipelago-dev/bitcoin
|
||||||
let app_dir = self.get_app_data_dir(app_id);
|
let app_dir = self.get_app_data_dir(app_id);
|
||||||
|
|
||||||
// Extract the relative path from the production path
|
// Extract the relative path from the production path
|
||||||
if let Some(relative) = volume_source.strip_prefix("/var/lib/archipelago/") {
|
if let Some(relative) = volume_source.strip_prefix("/var/lib/archipelago/") {
|
||||||
app_dir.join(relative)
|
app_dir.join(relative)
|
||||||
@@ -74,10 +74,10 @@ mod tests {
|
|||||||
async fn test_map_volume_path() {
|
async fn test_map_volume_path() {
|
||||||
let temp_dir = std::env::temp_dir().join("test-archipelago");
|
let temp_dir = std::env::temp_dir().join("test-archipelago");
|
||||||
let manager = DevDataManager::new(temp_dir.clone());
|
let manager = DevDataManager::new(temp_dir.clone());
|
||||||
|
|
||||||
let dev_path = manager.map_volume_path("bitcoin-core", "/var/lib/archipelago/bitcoin");
|
let dev_path = manager.map_volume_path("bitcoin-core", "/var/lib/archipelago/bitcoin");
|
||||||
assert!(dev_path.to_string_lossy().contains("bitcoin-core"));
|
assert!(dev_path.to_string_lossy().contains("bitcoin-core"));
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
|
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ mod tests {
|
|||||||
|
|
||||||
let app_dir = manager.create_app_data_dir("test-app").await.unwrap();
|
let app_dir = manager.create_app_data_dir("test-app").await.unwrap();
|
||||||
assert!(app_dir.exists());
|
assert!(app_dir.exists());
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
|
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use archipelago_container::{
|
|
||||||
AppManifest, BitcoinSimulator, BitcoinSimulationMode, ContainerRuntime as ContainerRuntimeTrait,
|
|
||||||
ContainerStatus, PortManager,
|
|
||||||
};
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use archipelago_container::{
|
||||||
|
AppManifest, BitcoinSimulationMode, BitcoinSimulator,
|
||||||
|
ContainerRuntime as ContainerRuntimeTrait, ContainerStatus, PortManager,
|
||||||
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::config::{Config, ContainerRuntime, BitcoinSimulation};
|
use crate::config::{BitcoinSimulation, Config, ContainerRuntime};
|
||||||
use crate::container::data_manager::DevDataManager;
|
use crate::container::data_manager::DevDataManager;
|
||||||
|
|
||||||
pub struct DevContainerOrchestrator {
|
pub struct DevContainerOrchestrator {
|
||||||
@@ -28,26 +28,22 @@ impl DevContainerOrchestrator {
|
|||||||
ContainerRuntime::Docker => {
|
ContainerRuntime::Docker => {
|
||||||
Arc::new(archipelago_container::DockerRuntime::new(user.clone()))
|
Arc::new(archipelago_container::DockerRuntime::new(user.clone()))
|
||||||
}
|
}
|
||||||
ContainerRuntime::Auto => {
|
ContainerRuntime::Auto => Arc::new(
|
||||||
Arc::new(
|
archipelago_container::AutoRuntime::new(user.clone())
|
||||||
archipelago_container::AutoRuntime::new(user.clone())
|
.await
|
||||||
.await
|
.context("Failed to create auto runtime")?,
|
||||||
.context("Failed to create auto runtime")?,
|
),
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let port_manager = Arc::new(PortManager::new(config.port_offset));
|
let port_manager = Arc::new(PortManager::new(config.port_offset));
|
||||||
let bitcoin_simulator = Arc::new(BitcoinSimulator::new(
|
let bitcoin_simulator = Arc::new(BitcoinSimulator::new(BitcoinSimulationMode::from(
|
||||||
BitcoinSimulationMode::from(
|
match &config.bitcoin_simulation {
|
||||||
match &config.bitcoin_simulation {
|
BitcoinSimulation::Mock => "mock",
|
||||||
BitcoinSimulation::Mock => "mock",
|
BitcoinSimulation::Testnet => "testnet",
|
||||||
BitcoinSimulation::Testnet => "testnet",
|
BitcoinSimulation::Mainnet => "mainnet",
|
||||||
BitcoinSimulation::Mainnet => "mainnet",
|
BitcoinSimulation::None => "none",
|
||||||
BitcoinSimulation::None => "none",
|
},
|
||||||
}
|
)));
|
||||||
),
|
|
||||||
));
|
|
||||||
let data_manager = Arc::new(DevDataManager::new(config.dev_data_dir.clone()));
|
let data_manager = Arc::new(DevDataManager::new(config.dev_data_dir.clone()));
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -77,13 +73,11 @@ impl DevContainerOrchestrator {
|
|||||||
version: _,
|
version: _,
|
||||||
} = dep
|
} = dep
|
||||||
{
|
{
|
||||||
if dep_id == "bitcoin-core" {
|
if dep_id == "bitcoin-core" && !self.bitcoin_simulator.is_bitcoin_available() {
|
||||||
if !self.bitcoin_simulator.is_bitcoin_available() {
|
return Err(anyhow::anyhow!(
|
||||||
return Err(anyhow::anyhow!(
|
"Bitcoin Core dependency not satisfied (simulation: {:?})",
|
||||||
"Bitcoin Core dependency not satisfied (simulation: {:?})",
|
self.bitcoin_simulator.mode()
|
||||||
self.bitcoin_simulator.mode()
|
));
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +207,8 @@ impl DevContainerOrchestrator {
|
|||||||
if let Some(app_id) = app_id.strip_suffix("-dev") {
|
if let Some(app_id) = app_id.strip_suffix("-dev") {
|
||||||
if let Ok(Some(ports)) = self.port_manager.get_port_mapping(app_id) {
|
if let Ok(Some(ports)) = self.port_manager.get_port_mapping(app_id) {
|
||||||
let mut container_with_ports = container.clone();
|
let mut container_with_ports = container.clone();
|
||||||
container_with_ports.ports = ports.iter().map(|p| p.to_string()).collect();
|
container_with_ports.ports =
|
||||||
|
ports.iter().map(|p| p.to_string()).collect();
|
||||||
result.push(container_with_ports);
|
result.push(container_with_ports);
|
||||||
} else {
|
} else {
|
||||||
result.push(container);
|
result.push(container);
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
// Scans docker-compose containers and converts them to package data
|
// Scans docker-compose containers and converts them to package data
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use archipelago_container::{ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient};
|
use archipelago_container::{
|
||||||
|
ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient,
|
||||||
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
use super::image_versions;
|
||||||
use crate::data_model::{
|
use crate::data_model::{
|
||||||
Description, InstalledPackageDataEntry, InterfaceAddress, Interfaces, MainInterface, Manifest,
|
Description, InstalledPackageDataEntry, InterfaceAddress, Interfaces, MainInterface, Manifest,
|
||||||
PackageDataEntry, PackageState, ServiceStatus, StaticFiles,
|
PackageDataEntry, PackageState, ServiceStatus, StaticFiles,
|
||||||
@@ -30,11 +33,11 @@ impl DockerPackageScanner {
|
|||||||
return Ok(HashMap::new());
|
return Ok(HashMap::new());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
debug!("Found {} containers", containers.len());
|
debug!("Found {} containers", containers.len());
|
||||||
|
|
||||||
let mut packages = HashMap::new();
|
let mut packages = HashMap::new();
|
||||||
|
|
||||||
// Backend services that should not appear as apps
|
// Backend services that should not appear as apps
|
||||||
let excluded_services = [
|
let excluded_services = [
|
||||||
"btcpay-db",
|
"btcpay-db",
|
||||||
@@ -64,13 +67,16 @@ impl DockerPackageScanner {
|
|||||||
"indeedhub-build_relay_1",
|
"indeedhub-build_relay_1",
|
||||||
"indeedhub-build_ffmpeg-worker_1",
|
"indeedhub-build_ffmpeg-worker_1",
|
||||||
];
|
];
|
||||||
|
|
||||||
// First pass: collect UI containers
|
// First pass: collect UI containers
|
||||||
let mut ui_containers: HashMap<String, String> = HashMap::new();
|
let mut ui_containers: HashMap<String, String> = HashMap::new();
|
||||||
for container in &containers {
|
for container in &containers {
|
||||||
if container.name.ends_with("-ui") {
|
if container.name.ends_with("-ui") {
|
||||||
// Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup)
|
// Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup)
|
||||||
let parent_app = container.name.strip_suffix("-ui").unwrap_or(&container.name);
|
let parent_app = container
|
||||||
|
.name
|
||||||
|
.strip_suffix("-ui")
|
||||||
|
.unwrap_or(&container.name);
|
||||||
let canonical_id = parent_app
|
let canonical_id = parent_app
|
||||||
.strip_prefix("archy-")
|
.strip_prefix("archy-")
|
||||||
.unwrap_or(parent_app)
|
.unwrap_or(parent_app)
|
||||||
@@ -82,14 +88,16 @@ impl DockerPackageScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("Found {} UI containers", ui_containers.len());
|
debug!("Found {} UI containers", ui_containers.len());
|
||||||
|
|
||||||
for container in containers {
|
for container in containers {
|
||||||
// Extract app ID from container name
|
// Extract app ID from container name
|
||||||
// Support both archy-* containers (docker-compose) and plain names (manual)
|
// Support both archy-* containers (docker-compose) and plain names (manual)
|
||||||
let app_id = if container.name.starts_with("archy-") {
|
let app_id = if container.name.starts_with("archy-") {
|
||||||
container.name.strip_prefix("archy-")
|
container
|
||||||
|
.name
|
||||||
|
.strip_prefix("archy-")
|
||||||
.unwrap_or(&container.name)
|
.unwrap_or(&container.name)
|
||||||
.to_string()
|
.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -102,7 +110,7 @@ impl DockerPackageScanner {
|
|||||||
"immich_server" => "immich".to_string(),
|
"immich_server" => "immich".to_string(),
|
||||||
_ => app_id,
|
_ => app_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Skip backend services (databases, APIs, etc.)
|
// Skip backend services (databases, APIs, etc.)
|
||||||
if excluded_services.contains(&app_id.as_str()) {
|
if excluded_services.contains(&app_id.as_str()) {
|
||||||
debug!("Skipping backend service: {}", app_id);
|
debug!("Skipping backend service: {}", app_id);
|
||||||
@@ -121,10 +129,10 @@ impl DockerPackageScanner {
|
|||||||
debug!("Skipping UI container: {}", app_id);
|
debug!("Skipping UI container: {}", app_id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get metadata for this app
|
// Get metadata for this app
|
||||||
let metadata = get_app_metadata(&app_id);
|
let metadata = get_app_metadata(&app_id);
|
||||||
|
|
||||||
// Resolve UI address: separate UI containers > static map > dynamic ports
|
// Resolve UI address: separate UI containers > static map > dynamic ports
|
||||||
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) {
|
let lan_address = if let Some(ui_address) = ui_containers.get(&app_id) {
|
||||||
// Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui)
|
// Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui)
|
||||||
@@ -141,18 +149,44 @@ impl DockerPackageScanner {
|
|||||||
extract_lan_address(&container.ports)
|
extract_lan_address(&container.ports)
|
||||||
.or_else(|| PodmanClient::lan_address_for(&app_id))
|
.or_else(|| PodmanClient::lan_address_for(&app_id))
|
||||||
};
|
};
|
||||||
|
|
||||||
debug!("Container {}: ports={:?}, lan_address={:?}", app_id, container.ports, lan_address);
|
debug!(
|
||||||
|
"Container {}: ports={:?}, lan_address={:?}",
|
||||||
|
app_id, container.ports, lan_address
|
||||||
|
);
|
||||||
|
|
||||||
// Convert container state to package/service state
|
// Convert container state to package/service state
|
||||||
let (package_state, service_status) = convert_state(&container.state);
|
let (package_state, service_status) = convert_state(&container.state);
|
||||||
|
|
||||||
let tor_address = read_tor_address(&app_id).await;
|
let tor_address = read_tor_address(&app_id).await;
|
||||||
|
|
||||||
|
// Extract actual version from container image tag
|
||||||
|
let running_version = image_versions::extract_version_from_image(&container.image);
|
||||||
|
|
||||||
|
// Check for available update by comparing running image vs pinned image
|
||||||
|
let available_update =
|
||||||
|
image_versions::pinned_image_for_app(&app_id).and_then(|pinned| {
|
||||||
|
if pinned != container.image {
|
||||||
|
let pinned_version = image_versions::extract_version_from_image(&pinned);
|
||||||
|
// Don't flag if both are "latest" — no meaningful diff
|
||||||
|
if pinned_version != "latest" || running_version != "latest" {
|
||||||
|
Some(pinned_version)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let package = PackageDataEntry {
|
let package = PackageDataEntry {
|
||||||
state: package_state.clone(),
|
state: package_state.clone(),
|
||||||
health: container.health.clone(),
|
health: container.health.clone(),
|
||||||
exit_code: if package_state == PackageState::Exited { container.exit_code } else { None },
|
exit_code: if package_state == PackageState::Exited {
|
||||||
|
container.exit_code
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
static_files: StaticFiles {
|
static_files: StaticFiles {
|
||||||
license: "MIT".to_string(),
|
license: "MIT".to_string(),
|
||||||
instructions: metadata.description.clone(),
|
instructions: metadata.description.clone(),
|
||||||
@@ -161,7 +195,7 @@ impl DockerPackageScanner {
|
|||||||
manifest: Manifest {
|
manifest: Manifest {
|
||||||
id: app_id.clone(),
|
id: app_id.clone(),
|
||||||
title: metadata.title.clone(),
|
title: metadata.title.clone(),
|
||||||
version: "1.0.0".to_string(),
|
version: running_version,
|
||||||
description: Description {
|
description: Description {
|
||||||
short: metadata.description.clone(),
|
short: metadata.description.clone(),
|
||||||
long: metadata.description.clone(),
|
long: metadata.description.clone(),
|
||||||
@@ -188,6 +222,7 @@ impl DockerPackageScanner {
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
available_update,
|
||||||
installed: Some(InstalledPackageDataEntry {
|
installed: Some(InstalledPackageDataEntry {
|
||||||
current_dependents: HashMap::new(),
|
current_dependents: HashMap::new(),
|
||||||
current_dependencies: HashMap::new(),
|
current_dependencies: HashMap::new(),
|
||||||
@@ -202,7 +237,7 @@ impl DockerPackageScanner {
|
|||||||
"main".to_string(),
|
"main".to_string(),
|
||||||
InterfaceAddress {
|
InterfaceAddress {
|
||||||
tor_address: tor,
|
tor_address: tor,
|
||||||
lan_address: lan_address,
|
lan_address,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
addresses
|
addresses
|
||||||
@@ -213,11 +248,15 @@ impl DockerPackageScanner {
|
|||||||
}),
|
}),
|
||||||
install_progress: None,
|
install_progress: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
packages.insert(app_id.clone(), package);
|
packages.insert(app_id.clone(), package);
|
||||||
info!("Detected container: {} ({})", metadata.title, package_state_str(&package_state));
|
info!(
|
||||||
|
"Detected container: {} ({})",
|
||||||
|
metadata.title,
|
||||||
|
package_state_str(&package_state)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(packages)
|
Ok(packages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +275,9 @@ fn get_app_tier(app_id: &str) -> &'static str {
|
|||||||
// Core: required for basic Bitcoin node
|
// Core: required for basic Bitcoin node
|
||||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "core",
|
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "core",
|
||||||
"lnd" => "core",
|
"lnd" => "core",
|
||||||
"mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" => "core",
|
"mempool" | "mempool-web" | "mempool-api" | "electrumx" | "mempool-electrs" | "electrs" => {
|
||||||
|
"core"
|
||||||
|
}
|
||||||
"btcpay" | "btcpay-server" | "btcpayserver" => "core",
|
"btcpay" | "btcpay-server" | "btcpayserver" => "core",
|
||||||
"dwn" => "core",
|
"dwn" => "core",
|
||||||
"filebrowser" => "core",
|
"filebrowser" => "core",
|
||||||
@@ -560,7 +601,8 @@ fn is_real_onion_address(s: &str) -> bool {
|
|||||||
/// Uses TOR_DATA_DIR env var if set, else /var/lib/archipelago/tor.
|
/// Uses TOR_DATA_DIR env var if set, else /var/lib/archipelago/tor.
|
||||||
pub async fn read_tor_address(app_id: &str) -> Option<String> {
|
pub async fn read_tor_address(app_id: &str) -> Option<String> {
|
||||||
let service = tor_service_name(app_id)?;
|
let service = tor_service_name(app_id)?;
|
||||||
let base = std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| "/var/lib/archipelago/tor".to_string());
|
let base =
|
||||||
|
std::env::var("TOR_DATA_DIR").unwrap_or_else(|_| "/var/lib/archipelago/tor".to_string());
|
||||||
|
|
||||||
// Try readable hostname copy first (when system Tor owns hidden_service dirs)
|
// Try readable hostname copy first (when system Tor owns hidden_service dirs)
|
||||||
let hostnames_path = std::path::Path::new(&base)
|
let hostnames_path = std::path::Path::new(&base)
|
||||||
@@ -627,5 +669,6 @@ fn package_state_str(state: &PackageState) -> &str {
|
|||||||
PackageState::RestoringBackup => "restoring-backup",
|
PackageState::RestoringBackup => "restoring-backup",
|
||||||
PackageState::Removing => "removing",
|
PackageState::Removing => "removing",
|
||||||
PackageState::BackingUp => "backing-up",
|
PackageState::BackingUp => "backing-up",
|
||||||
|
PackageState::Updating => "updating",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
//! Parser for image-versions.sh — single source of truth for pinned container images.
|
||||||
|
//!
|
||||||
|
//! Reads the deployed file at /opt/archipelago/image-versions.sh (or the repo-local
|
||||||
|
//! scripts/image-versions.sh as fallback) and exposes lookup functions so the container
|
||||||
|
//! scanner can compare running images against pinned targets.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Cached parse result, invalidated when file mtime changes.
|
||||||
|
static CACHE: Mutex<Option<CacheEntry>> = Mutex::new(None);
|
||||||
|
|
||||||
|
struct CacheEntry {
|
||||||
|
mtime: SystemTime,
|
||||||
|
images: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File search order — production path first, then repo-local for dev.
|
||||||
|
const PATHS: &[&str] = &[
|
||||||
|
"/opt/archipelago/image-versions.sh",
|
||||||
|
"scripts/image-versions.sh",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Parse image-versions.sh and return map of variable names to full image refs.
|
||||||
|
/// Result is cached and only re-parsed when the file's mtime changes.
|
||||||
|
fn load_image_versions() -> HashMap<String, String> {
|
||||||
|
let (path, mtime) = match find_file() {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
debug!("image-versions.sh not found in any search path");
|
||||||
|
return HashMap::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
{
|
||||||
|
let cache = CACHE.lock().unwrap();
|
||||||
|
if let Some(ref entry) = *cache {
|
||||||
|
if entry.mtime == mtime {
|
||||||
|
return entry.images.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse fresh
|
||||||
|
let content = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Failed to read {}: {}", path, e);
|
||||||
|
return HashMap::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let images = parse_image_versions(&content);
|
||||||
|
debug!("Parsed {} image versions from {}", images.len(), path);
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
{
|
||||||
|
let mut cache = CACHE.lock().unwrap();
|
||||||
|
*cache = Some(CacheEntry {
|
||||||
|
mtime,
|
||||||
|
images: images.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
images
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_file() -> Option<(String, SystemTime)> {
|
||||||
|
for p in PATHS {
|
||||||
|
let path = Path::new(p);
|
||||||
|
if let Ok(meta) = path.metadata() {
|
||||||
|
if let Ok(mtime) = meta.modified() {
|
||||||
|
return Some((p.to_string(), mtime));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse shell variable assignments, expanding $ARCHY_REGISTRY.
|
||||||
|
fn parse_image_versions(content: &str) -> HashMap<String, String> {
|
||||||
|
let mut vars = HashMap::new();
|
||||||
|
let mut registry = String::new();
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match VAR="value" or VAR=value
|
||||||
|
if let Some((key, val)) = parse_assignment(line) {
|
||||||
|
let expanded = val.replace("$ARCHY_REGISTRY", ®istry);
|
||||||
|
if key == "ARCHY_REGISTRY" {
|
||||||
|
registry = expanded.clone();
|
||||||
|
}
|
||||||
|
vars.insert(key.to_string(), expanded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only *_IMAGE entries
|
||||||
|
vars.retain(|k, _| k.ends_with("_IMAGE"));
|
||||||
|
vars
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_assignment(line: &str) -> Option<(&str, &str)> {
|
||||||
|
let eq = line.find('=')?;
|
||||||
|
let key = &line[..eq];
|
||||||
|
|
||||||
|
// Validate key is a shell variable name
|
||||||
|
if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let val = &line[eq + 1..];
|
||||||
|
// Strip surrounding quotes
|
||||||
|
let val = val
|
||||||
|
.strip_prefix('"')
|
||||||
|
.and_then(|v| v.strip_suffix('"'))
|
||||||
|
.unwrap_or(val);
|
||||||
|
|
||||||
|
Some((key, val))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map app ID (as seen by the container scanner) to image variable name.
|
||||||
|
fn image_var_for_app(app_id: &str) -> Option<&'static str> {
|
||||||
|
match app_id {
|
||||||
|
// Bitcoin stack
|
||||||
|
"bitcoin-knots" | "bitcoin" | "bitcoin-core" => Some("BITCOIN_KNOTS_IMAGE"),
|
||||||
|
"lnd" => Some("LND_IMAGE"),
|
||||||
|
"electrumx" => Some("ELECTRUMX_IMAGE"),
|
||||||
|
"electrs" | "mempool-electrs" => Some("ELECTRUMX_IMAGE"),
|
||||||
|
|
||||||
|
// Mempool stack (primary = web)
|
||||||
|
"mempool" | "mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
|
||||||
|
|
||||||
|
// BTCPay stack (primary = server)
|
||||||
|
"btcpay" | "btcpay-server" | "btcpayserver" => Some("BTCPAY_IMAGE"),
|
||||||
|
|
||||||
|
// Apps
|
||||||
|
"homeassistant" | "home-assistant" => Some("HOMEASSISTANT_IMAGE"),
|
||||||
|
"grafana" => Some("GRAFANA_IMAGE"),
|
||||||
|
"uptime-kuma" => Some("UPTIME_KUMA_IMAGE"),
|
||||||
|
"jellyfin" => Some("JELLYFIN_IMAGE"),
|
||||||
|
"photoprism" => Some("PHOTOPRISM_IMAGE"),
|
||||||
|
"ollama" => Some("OLLAMA_IMAGE"),
|
||||||
|
"vaultwarden" => Some("VAULTWARDEN_IMAGE"),
|
||||||
|
"nextcloud" => Some("NEXTCLOUD_IMAGE"),
|
||||||
|
"searxng" => Some("SEARXNG_IMAGE"),
|
||||||
|
"cryptpad" => Some("CRYPTPAD_IMAGE"),
|
||||||
|
"filebrowser" => Some("FILEBROWSER_IMAGE"),
|
||||||
|
"nginx-proxy-manager" => Some("NPM_IMAGE"),
|
||||||
|
"portainer" => Some("PORTAINER_IMAGE"),
|
||||||
|
"tailscale" => Some("TAILSCALE_IMAGE"),
|
||||||
|
|
||||||
|
// Fedimint
|
||||||
|
"fedimint" | "fedimintd" => Some("FEDIMINT_IMAGE"),
|
||||||
|
"fedimint-gateway" => Some("FEDIMINT_GATEWAY_IMAGE"),
|
||||||
|
|
||||||
|
// Nostr / VPN
|
||||||
|
"nostr-rs-relay" => Some("NOSTR_RS_RELAY_IMAGE"),
|
||||||
|
"nostr-vpn" => Some("NOSTR_VPN_IMAGE"),
|
||||||
|
"fips" => Some("FIPS_IMAGE"),
|
||||||
|
|
||||||
|
// Immich (primary = server)
|
||||||
|
"immich" | "immich_server" => Some("IMMICH_SERVER_IMAGE"),
|
||||||
|
|
||||||
|
// Penpot (primary = frontend)
|
||||||
|
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
|
||||||
|
|
||||||
|
// DWN
|
||||||
|
"dwn" => Some("DWN_SERVER_IMAGE"),
|
||||||
|
|
||||||
|
// AI
|
||||||
|
"routstr" => Some("ROUTSTR_IMAGE"),
|
||||||
|
|
||||||
|
// Networking
|
||||||
|
"adguardhome" => Some("ADGUARDHOME_IMAGE"),
|
||||||
|
"tor" | "archy-tor" => Some("ALPINE_TOR_IMAGE"),
|
||||||
|
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the full pinned image reference for an app ID.
|
||||||
|
pub fn pinned_image_for_app(app_id: &str) -> Option<String> {
|
||||||
|
let var = image_var_for_app(app_id)?;
|
||||||
|
let images = load_image_versions();
|
||||||
|
images.get(var).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract version tag from a full image reference.
|
||||||
|
/// e.g. "git.tx1138.com/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
|
||||||
|
/// Returns "latest" if no tag or tag is empty.
|
||||||
|
pub fn extract_version_from_image(image: &str) -> String {
|
||||||
|
// Split off the tag after the last colon, but only if it comes after the last slash
|
||||||
|
// (to avoid splitting on registry port like "registry.example.com:3000")
|
||||||
|
if let Some(slash_pos) = image.rfind('/') {
|
||||||
|
let after_slash = &image[slash_pos..];
|
||||||
|
if let Some(colon_pos) = after_slash.rfind(':') {
|
||||||
|
let tag = &after_slash[colon_pos + 1..];
|
||||||
|
if !tag.is_empty() {
|
||||||
|
return tag.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"latest".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container names and their image variable names for multi-container stacks.
|
||||||
|
/// Returns empty vec for single-container apps.
|
||||||
|
pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
|
||||||
|
match app_id {
|
||||||
|
"mempool" | "mempool-web" => vec![
|
||||||
|
("archy-mempool-db", "MARIADB_IMAGE"),
|
||||||
|
("mempool-api", "MEMPOOL_BACKEND_IMAGE"),
|
||||||
|
("archy-mempool-web", "MEMPOOL_WEB_IMAGE"),
|
||||||
|
],
|
||||||
|
"btcpay" | "btcpay-server" | "btcpayserver" => vec![
|
||||||
|
("archy-btcpay-db", "BTCPAY_POSTGRES_IMAGE"),
|
||||||
|
("archy-nbxplorer", "NBXPLORER_IMAGE"),
|
||||||
|
("btcpay-server", "BTCPAY_IMAGE"),
|
||||||
|
],
|
||||||
|
"immich" | "immich_server" => vec![
|
||||||
|
("immich_postgres", "IMMICH_POSTGRES_IMAGE"),
|
||||||
|
("immich_redis", "REDIS_IMAGE"),
|
||||||
|
("immich_server", "IMMICH_SERVER_IMAGE"),
|
||||||
|
],
|
||||||
|
"penpot" | "penpot-frontend" => vec![
|
||||||
|
("penpot-postgres", "PENPOT_POSTGRES_IMAGE"),
|
||||||
|
("penpot-valkey", "PENPOT_VALKEY_IMAGE"),
|
||||||
|
("penpot-backend", "PENPOT_BACKEND_IMAGE"),
|
||||||
|
("penpot-exporter", "PENPOT_EXPORTER_IMAGE"),
|
||||||
|
("penpot-frontend", "PENPOT_FRONTEND_IMAGE"),
|
||||||
|
],
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all pinned images for a stack update. Returns vec of (container_name, full_image_ref).
|
||||||
|
pub fn pinned_images_for_stack(app_id: &str) -> Vec<(String, String)> {
|
||||||
|
let images = load_image_versions();
|
||||||
|
containers_for_stack(app_id)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(name, var)| images.get(var).map(|img| (name.to_string(), img.clone())))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_version() {
|
||||||
|
assert_eq!(
|
||||||
|
extract_version_from_image("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"),
|
||||||
|
"v0.18.4-beta"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract_version_from_image("git.tx1138.com/lfg2025/grafana:10.2.0"),
|
||||||
|
"10.2.0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract_version_from_image("localhost/myapp:latest"),
|
||||||
|
"latest"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract_version_from_image("git.tx1138.com/lfg2025/bitcoin-knots:latest"),
|
||||||
|
"latest"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_image_versions() {
|
||||||
|
let content = r#"
|
||||||
|
ARCHY_REGISTRY="git.tx1138.com/lfg2025"
|
||||||
|
LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
|
||||||
|
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
|
||||||
|
# comment
|
||||||
|
NOT_AN_IMAGE="something"
|
||||||
|
"#;
|
||||||
|
let parsed = parse_image_versions(content);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.get("LND_IMAGE"),
|
||||||
|
Some(&"git.tx1138.com/lfg2025/lnd:v0.18.4-beta".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.get("GRAFANA_IMAGE"),
|
||||||
|
Some(&"git.tx1138.com/lfg2025/grafana:10.2.0".to_string())
|
||||||
|
);
|
||||||
|
assert!(!parsed.contains_key("NOT_AN_IMAGE"));
|
||||||
|
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_image_var_mapping() {
|
||||||
|
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
|
||||||
|
assert_eq!(
|
||||||
|
image_var_for_app("bitcoin-knots"),
|
||||||
|
Some("BITCOIN_KNOTS_IMAGE")
|
||||||
|
);
|
||||||
|
assert_eq!(image_var_for_app("unknown-app"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
//! image pulls (with configurable failures for retry testing).
|
//! image pulls (with configurable failures for retry testing).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU32, Ordering}};
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
};
|
||||||
|
|
||||||
/// Container state matching podman's real states.
|
/// Container state matching podman's real states.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -70,7 +73,10 @@ impl MockPodman {
|
|||||||
pub fn pull_image(&self, image: &str) -> Result<(), String> {
|
pub fn pull_image(&self, image: &str) -> Result<(), String> {
|
||||||
self.pull_attempt_count.fetch_add(1, Ordering::SeqCst);
|
self.pull_attempt_count.fetch_add(1, Ordering::SeqCst);
|
||||||
if self.fail_pull.load(Ordering::SeqCst) {
|
if self.fail_pull.load(Ordering::SeqCst) {
|
||||||
return Err(format!("Error: initializing source docker://{}: connection refused", image));
|
return Err(format!(
|
||||||
|
"Error: initializing source docker://{}: connection refused",
|
||||||
|
image
|
||||||
|
));
|
||||||
}
|
}
|
||||||
self.images.lock().unwrap().push(image.to_string());
|
self.images.lock().unwrap().push(image.to_string());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -102,7 +108,10 @@ impl MockPodman {
|
|||||||
stop_timeout_used: None,
|
stop_timeout_used: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.containers.lock().unwrap().insert(name.to_string(), container);
|
self.containers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(name.to_string(), container);
|
||||||
Ok(format!("abc123def456_{}", name))
|
Ok(format!("abc123def456_{}", name))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +152,9 @@ impl MockPodman {
|
|||||||
|
|
||||||
/// Simulate `podman inspect <name> --format {{.State.Status}}`.
|
/// Simulate `podman inspect <name> --format {{.State.Status}}`.
|
||||||
pub fn inspect_state(&self, name: &str) -> Option<String> {
|
pub fn inspect_state(&self, name: &str) -> Option<String> {
|
||||||
self.containers.lock().unwrap()
|
self.containers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
.get(name)
|
.get(name)
|
||||||
.map(|c| c.state.as_str().to_string())
|
.map(|c| c.state.as_str().to_string())
|
||||||
}
|
}
|
||||||
@@ -165,17 +176,22 @@ impl MockPodman {
|
|||||||
|
|
||||||
/// Pre-load a container in a specific state.
|
/// Pre-load a container in a specific state.
|
||||||
pub fn preload_container(&self, name: &str, image: &str, state: MockContainerState) {
|
pub fn preload_container(&self, name: &str, image: &str, state: MockContainerState) {
|
||||||
self.containers.lock().unwrap().insert(name.to_string(), MockContainer {
|
self.containers.lock().unwrap().insert(
|
||||||
name: name.to_string(),
|
name.to_string(),
|
||||||
image: image.to_string(),
|
MockContainer {
|
||||||
state,
|
name: name.to_string(),
|
||||||
stop_timeout_used: None,
|
image: image.to_string(),
|
||||||
});
|
state,
|
||||||
|
stop_timeout_used: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the stop timeout that was used for a container.
|
/// Get the stop timeout that was used for a container.
|
||||||
pub fn get_stop_timeout(&self, name: &str) -> Option<u64> {
|
pub fn get_stop_timeout(&self, name: &str) -> Option<u64> {
|
||||||
self.containers.lock().unwrap()
|
self.containers
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
.get(name)
|
.get(name)
|
||||||
.and_then(|c| c.stop_timeout_used)
|
.and_then(|c| c.stop_timeout_used)
|
||||||
}
|
}
|
||||||
@@ -200,8 +216,12 @@ mod tests {
|
|||||||
let mock = MockPodman::new();
|
let mock = MockPodman::new();
|
||||||
mock.pull_image("test:latest").unwrap();
|
mock.pull_image("test:latest").unwrap();
|
||||||
assert!(mock.image_exists("test:latest"));
|
assert!(mock.image_exists("test:latest"));
|
||||||
mock.create_and_start("test-container", "test:latest").unwrap();
|
mock.create_and_start("test-container", "test:latest")
|
||||||
assert_eq!(mock.inspect_state("test-container"), Some("running".to_string()));
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
mock.inspect_state("test-container"),
|
||||||
|
Some("running".to_string())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod data_manager;
|
pub mod data_manager;
|
||||||
pub mod dev_orchestrator;
|
pub mod dev_orchestrator;
|
||||||
pub mod docker_packages;
|
pub mod docker_packages;
|
||||||
|
pub mod image_versions;
|
||||||
|
pub mod registry;
|
||||||
|
|
||||||
pub use dev_orchestrator::DevContainerOrchestrator;
|
pub use dev_orchestrator::DevContainerOrchestrator;
|
||||||
pub use docker_packages::DockerPackageScanner;
|
pub use docker_packages::DockerPackageScanner;
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
//! Dynamic container registry configuration.
|
||||||
|
//!
|
||||||
|
//! Manages a list of container registries that the node uses to pull app images.
|
||||||
|
//! Registries are tried in order — if the first fails, the next is attempted.
|
||||||
|
//! Configuration is persisted to disk and editable via RPC.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::fs;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
const REGISTRY_FILE: &str = "config/registries.json";
|
||||||
|
|
||||||
|
/// A single container registry.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Registry {
|
||||||
|
/// Registry URL (e.g., "git.tx1138.com/lfg2025" or "23.182.128.160:3000/lfg2025").
|
||||||
|
pub url: String,
|
||||||
|
/// Human-readable name.
|
||||||
|
pub name: String,
|
||||||
|
/// Whether TLS verification is required (false for HTTP registries).
|
||||||
|
pub tls_verify: bool,
|
||||||
|
/// Whether this registry is enabled.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Priority (lower = tried first).
|
||||||
|
#[serde(default)]
|
||||||
|
pub priority: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegistryConfig {
|
||||||
|
pub registries: Vec<Registry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RegistryConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
registries: vec![
|
||||||
|
Registry {
|
||||||
|
url: "git.tx1138.com/lfg2025".to_string(),
|
||||||
|
name: "Archipelago Primary".to_string(),
|
||||||
|
tls_verify: true,
|
||||||
|
enabled: true,
|
||||||
|
priority: 0,
|
||||||
|
},
|
||||||
|
Registry {
|
||||||
|
url: "23.182.128.160:3000/lfg2025".to_string(),
|
||||||
|
name: "Archipelago Fallback".to_string(),
|
||||||
|
tls_verify: false,
|
||||||
|
enabled: true,
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegistryConfig {
|
||||||
|
/// Get enabled registries sorted by priority.
|
||||||
|
pub fn active_registries(&self) -> Vec<&Registry> {
|
||||||
|
let mut regs: Vec<&Registry> = self.registries.iter().filter(|r| r.enabled).collect();
|
||||||
|
regs.sort_by_key(|r| r.priority);
|
||||||
|
regs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite an image reference to use a specific registry.
|
||||||
|
/// E.g., "git.tx1138.com/lfg2025/bitcoin-knots:latest" with registry "23.182.128.160:3000/lfg2025"
|
||||||
|
/// becomes "23.182.128.160:3000/lfg2025/bitcoin-knots:latest".
|
||||||
|
pub fn rewrite_image(&self, image: &str, registry: &Registry) -> String {
|
||||||
|
// Extract the image name (last component after the org/namespace)
|
||||||
|
// Handles: "registry/org/image:tag" -> "image:tag"
|
||||||
|
let image_name = extract_image_name(image);
|
||||||
|
format!("{}/{}", registry.url, image_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate fallback image URLs to try (excludes the original since it already failed).
|
||||||
|
pub fn image_candidates(&self, image: &str) -> Vec<(String, bool)> {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
|
||||||
|
// Rewrite for each active registry (skip if identical to original)
|
||||||
|
for reg in self.active_registries() {
|
||||||
|
let rewritten = self.rewrite_image(image, reg);
|
||||||
|
if rewritten != image {
|
||||||
|
candidates.push((rewritten, reg.tls_verify));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the image name from a full image reference.
|
||||||
|
/// "git.tx1138.com/lfg2025/bitcoin-knots:latest" -> "bitcoin-knots:latest"
|
||||||
|
/// "docker.io/gitea/gitea:1.23" -> "gitea:1.23"
|
||||||
|
fn extract_image_name(image: &str) -> &str {
|
||||||
|
// Split by '/' and take the last segment (image:tag)
|
||||||
|
image.rsplit('/').next().unwrap_or(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load registry config from disk.
|
||||||
|
pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||||
|
let path = data_dir.join(REGISTRY_FILE);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(RegistryConfig::default());
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.await
|
||||||
|
.context("Failed to read registry config")?;
|
||||||
|
let config: RegistryConfig =
|
||||||
|
serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default());
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save registry config to disk.
|
||||||
|
pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result<()> {
|
||||||
|
let dir = data_dir.join("config");
|
||||||
|
fs::create_dir_all(&dir)
|
||||||
|
.await
|
||||||
|
.context("Failed to create config dir")?;
|
||||||
|
let path = data_dir.join(REGISTRY_FILE);
|
||||||
|
let content =
|
||||||
|
serde_json::to_string_pretty(config).context("Failed to serialize registry config")?;
|
||||||
|
fs::write(&path, content)
|
||||||
|
.await
|
||||||
|
.context("Failed to write registry config")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try pulling an image from configured registries in priority order.
|
||||||
|
/// Returns the image reference that succeeded.
|
||||||
|
pub async fn pull_from_registries(data_dir: &Path, image: &str, tmpdir: &str) -> Result<String> {
|
||||||
|
let config = load_registries(data_dir).await?;
|
||||||
|
let candidates = config.image_candidates(image);
|
||||||
|
|
||||||
|
for (candidate, tls_verify) in &candidates {
|
||||||
|
debug!("Trying registry: {}", candidate);
|
||||||
|
|
||||||
|
let mut args = vec!["pull".to_string(), candidate.clone()];
|
||||||
|
if !tls_verify {
|
||||||
|
args.push("--tls-verify=false".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = tokio::process::Command::new("podman")
|
||||||
|
.args(&args)
|
||||||
|
.env("TMPDIR", tmpdir)
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let status = if let Some(ref mut c) = child {
|
||||||
|
match tokio::time::timeout(std::time::Duration::from_secs(120), c.wait()).await {
|
||||||
|
Ok(Ok(s)) => Some(s.success()),
|
||||||
|
_ => {
|
||||||
|
let _ = c.kill().await;
|
||||||
|
let _ = c.wait().await;
|
||||||
|
debug!("Fallback pull timed out: {}", candidate);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if status == Some(true) {
|
||||||
|
// If we pulled from a non-original registry, tag it with the original name
|
||||||
|
if candidate != image {
|
||||||
|
let _ = tokio::process::Command::new("podman")
|
||||||
|
.args(["tag", candidate, image])
|
||||||
|
.status()
|
||||||
|
.await;
|
||||||
|
info!(
|
||||||
|
"Pulled {} from fallback registry, tagged as {}",
|
||||||
|
candidate, image
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info!("Pulled {} from primary registry", image);
|
||||||
|
}
|
||||||
|
return Ok(candidate.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Failed to pull from {}", candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"Failed to pull {} from all {} configured registries",
|
||||||
|
image,
|
||||||
|
candidates.len()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_image_name() {
|
||||||
|
assert_eq!(
|
||||||
|
extract_image_name("git.tx1138.com/lfg2025/bitcoin-knots:latest"),
|
||||||
|
"bitcoin-knots:latest"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract_image_name("docker.io/gitea/gitea:1.23"),
|
||||||
|
"gitea:1.23"
|
||||||
|
);
|
||||||
|
assert_eq!(extract_image_name("localhost/myimage:v1"), "myimage:v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rewrite_image() {
|
||||||
|
let config = RegistryConfig::default();
|
||||||
|
let fallback = &config.registries[1];
|
||||||
|
assert_eq!(
|
||||||
|
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", fallback),
|
||||||
|
"23.182.128.160:3000/lfg2025/bitcoin-knots:latest"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_image_candidates() {
|
||||||
|
let config = RegistryConfig::default();
|
||||||
|
let candidates = config.image_candidates("git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
|
||||||
|
assert!(candidates.len() >= 2);
|
||||||
|
assert_eq!(candidates[0].0, "git.tx1138.com/lfg2025/lnd:v0.18.4-beta");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_active_registries_sorted() {
|
||||||
|
let config = RegistryConfig::default();
|
||||||
|
let active = config.active_registries();
|
||||||
|
assert_eq!(active.len(), 2);
|
||||||
|
assert!(active[0].priority <= active[1].priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_load_default() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let config = load_registries(tmp.path()).await.unwrap();
|
||||||
|
assert_eq!(config.registries.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_save_load_roundtrip() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let mut config = RegistryConfig::default();
|
||||||
|
config.registries.push(Registry {
|
||||||
|
url: "myregistry.com/apps".into(),
|
||||||
|
name: "Custom".into(),
|
||||||
|
tls_verify: true,
|
||||||
|
enabled: true,
|
||||||
|
priority: 5,
|
||||||
|
});
|
||||||
|
save_registries(tmp.path(), &config).await.unwrap();
|
||||||
|
let loaded = load_registries(tmp.path()).await.unwrap();
|
||||||
|
assert_eq!(loaded.registries.len(), 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user