Compare commits
419
Commits
v1.3.2
...
v1.7.87-alpha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd1cf9ba7 | ||
|
|
8d4b309753 | ||
|
|
b11c6c17d1 | ||
|
|
e474a2b4c9 | ||
|
|
00c32688f8 | ||
|
|
d6f108d818 | ||
|
|
6a30ff11bd | ||
|
|
22df3f8f5f | ||
|
|
87853fc29c | ||
|
|
b7c2fd081f | ||
|
|
809b76526e | ||
|
|
760796f650 | ||
|
|
10e4f218a6 | ||
|
|
84b283f5b6 | ||
|
|
8f2e03df2a | ||
|
|
c79afa9541 | ||
|
|
f818f1dcc1 | ||
|
|
de60f7e21e | ||
|
|
881478a873 | ||
|
|
755ba5562d | ||
|
|
182f18ecf3 | ||
|
|
1a3d726eac | ||
|
|
c393b96da3 | ||
|
|
09ec64932f | ||
|
|
9079d404d6 | ||
|
|
af9d531a00 | ||
|
|
136eda16c9 | ||
|
|
626a89bdbc | ||
|
|
68784be4db | ||
|
|
853d51ae14 | ||
|
|
a578834462 | ||
|
|
c31c3765f4 | ||
|
|
bdd5a2c43e | ||
|
|
8eb03d106e | ||
|
|
4da6e3b43c | ||
|
|
7be7420c4f | ||
|
|
34c4e87d14 | ||
|
|
e61c757633 | ||
|
|
cc1f8fba72 | ||
|
|
556f2e7cac | ||
|
|
0898c54765 | ||
|
|
f4368785f0 | ||
|
|
608f4c17f0 | ||
|
|
92c58141af | ||
|
|
7b2f4cb05f | ||
|
|
e65e76cd9d | ||
|
|
6d03ed5a69 | ||
|
|
522c046525 | ||
|
|
56f956973e | ||
|
|
bd69ef41d5 | ||
|
|
eeb08fc78f | ||
|
|
1836b035b4 | ||
|
|
3e01e57c8d | ||
|
|
ca3e2ee0ca | ||
|
|
5859ef77e7 | ||
|
|
f0bd49d03d | ||
|
|
cede77f3bc | ||
|
|
dd8a6cd9d7 | ||
|
|
ab96c97cb9 | ||
|
|
881779005a | ||
|
|
20bc9f250c | ||
|
|
87be717f40 | ||
|
|
75d147b69f | ||
|
|
edaece8716 | ||
|
|
ab27fb97f8 | ||
|
|
d736364ad7 | ||
|
|
e9898ead76 | ||
|
|
b25d41c5c6 | ||
|
|
32902d3891 | ||
|
|
92c578d3d9 | ||
|
|
6240064acf | ||
|
|
19dbf60f03 | ||
|
|
b49d8f1f8a | ||
|
|
ec36ac7e2c | ||
|
|
7104ba0cbf | ||
|
|
d0b08d2790 | ||
|
|
76288f541e | ||
|
|
b701e125b4 | ||
|
|
837ba63466 | ||
|
|
8191d92bed | ||
|
|
ae8359da4b | ||
|
|
d91b858d9b | ||
|
|
19f2125a4d | ||
|
|
a992abcd06 | ||
|
|
4d6b4f76af | ||
|
|
0a94c0097f | ||
|
|
413d50116e | ||
|
|
daad50325b | ||
|
|
e05e356d64 | ||
|
|
cfb304a001 | ||
|
|
7804223152 | ||
|
|
a322b04021 | ||
|
|
645cf69ed7 | ||
|
|
01ec0565a6 | ||
|
|
30505f41ff | ||
|
|
5818541721 | ||
|
|
b8053c00ca | ||
|
|
f95e9a1cd0 | ||
|
|
be50dc3235 | ||
|
|
2ff47f88a7 | ||
|
|
835c525218 | ||
|
|
3202b79e41 | ||
|
|
c0751e2551 | ||
|
|
1a0d8a432c | ||
|
|
745cb1c626 | ||
|
|
10fbb8f87c | ||
|
|
aad0ba5234 | ||
|
|
281e65e697 | ||
|
|
384f12de7a | ||
|
|
bd96c0475d | ||
|
|
9a89a000d4 | ||
|
|
97ce23d773 | ||
|
|
65576bd755 | ||
|
|
5b2e02bd43 | ||
|
|
9becafafd3 | ||
|
|
5074572373 | ||
|
|
ec1dce93a9 | ||
|
|
b9eb6eb18a | ||
|
|
c55a4f4e86 | ||
|
|
01f416ae5d | ||
|
|
f80daff8ba | ||
|
|
1103c2c710 | ||
|
|
1b6c500657 | ||
|
|
5be2febe13 | ||
|
|
6bbe1b96cf | ||
|
|
8f13298805 | ||
|
|
ba2eece9aa | ||
|
|
6603227874 | ||
|
|
27ff1d5b52 | ||
|
|
f9e34fd0c6 | ||
|
|
23c4e7441f | ||
|
|
2bf8181110 | ||
|
|
0684491072 | ||
|
|
05e6c2e738 | ||
|
|
be9f9528c3 | ||
|
|
7ab788d178 | ||
|
|
f507b847ef | ||
|
|
8a2899ab4a | ||
|
|
992b673b20 | ||
|
|
4ec6ca98c1 | ||
|
|
dffa7e99bb | ||
|
|
8f83b37d51 | ||
|
|
4d05705315 | ||
|
|
05b41f8946 | ||
|
|
ed73e4709b | ||
|
|
0bd4e49a8c | ||
|
|
310c709aba | ||
|
|
dbf755e908 | ||
|
|
2572688468 | ||
|
|
4bf35f95e6 | ||
|
|
4edc420459 | ||
|
|
7af048cc1a | ||
|
|
2843cc1e84 | ||
|
|
c5ea41d0cb | ||
|
|
9d42645aa3 | ||
|
|
f6efe2f356 | ||
|
|
c4efb30382 | ||
|
|
cd6f8bad70 | ||
|
|
9f3d66e24e | ||
|
|
a272a79706 | ||
|
|
694e5b0a9d | ||
|
|
0f1ad47aec | ||
|
|
06dcdafda4 | ||
|
|
92612ddc70 | ||
|
|
353825b66c | ||
|
|
12f93cc15e | ||
|
|
4faac9cb74 | ||
|
|
b62b731db0 | ||
|
|
6c8cb50679 | ||
|
|
28e38a36a9 | ||
|
|
d9d5fa65e5 | ||
|
|
980c1b25f4 | ||
|
|
7e62ea07f7 | ||
|
|
576ff1a6de | ||
|
|
49b98e0271 | ||
|
|
702b5d64d3 | ||
|
|
1ad889608f | ||
|
|
0ea4f96de9 | ||
|
|
a8158b1ef5 | ||
|
|
cd69c3b2f6 | ||
|
|
39dd1d9dcc | ||
|
|
5baced5f5b | ||
|
|
cad63bdd76 | ||
|
|
bb2e3fab42 | ||
|
|
6a5fab709a | ||
|
|
2a2f10608b | ||
|
|
7257f72f4a | ||
|
|
30b31b3670 | ||
|
|
28819d1197 | ||
|
|
80765c5755 | ||
|
|
8acf7d1112 | ||
|
|
c396be8068 | ||
|
|
236a2dee85 | ||
|
|
758d3e47d8 | ||
|
|
3e9c192b48 | ||
|
|
ba8bd0bb86 | ||
|
|
6a0809d386 | ||
|
|
81c1613040 | ||
|
|
89199bb03b | ||
|
|
ca299e70e8 | ||
|
|
40a6eaca72 | ||
|
|
e103925a4e | ||
|
|
56af57a6f8 | ||
|
|
919055f3f1 | ||
|
|
0ac673deb4 | ||
|
|
d1bcf271f9 | ||
|
|
85417de952 | ||
|
|
b8d084368e | ||
|
|
36a6101026 | ||
|
|
cfc98c600e | ||
|
|
e206e1fc94 | ||
|
|
9cf1177b73 | ||
|
|
a7048f6d8e | ||
|
|
06feb85aa5 | ||
|
|
aa0677be57 | ||
|
|
974fce5870 | ||
|
|
682b93f2d6 | ||
|
|
18f0929614 | ||
|
|
1709149ebd | ||
|
|
2664074210 | ||
|
|
9868991900 | ||
|
|
0d15ca588a | ||
|
|
1c1416cc1a | ||
|
|
1735098d81 | ||
|
|
b5da6875d7 | ||
|
|
4b6a088e38 | ||
|
|
f8304aed90 | ||
|
|
40f76013dc | ||
|
|
4e2c6d210b | ||
|
|
7d8ddcccef | ||
|
|
f853d14421 | ||
|
|
f2360d570f | ||
|
|
749234b8b0 | ||
|
|
be8e5ee46b | ||
|
|
687c216e65 | ||
|
|
26630e5ffd | ||
|
|
fee690744d | ||
|
|
79f4ec4bde | ||
|
|
61bfdac7cc | ||
|
|
1e648800ad | ||
|
|
8a2cabdba9 | ||
|
|
4d1bd063e8 | ||
|
|
b8397b5ecb | ||
|
|
1e7df417a4 | ||
|
|
3db71adb55 | ||
|
|
66b4e2b313 | ||
|
|
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 | ||
|
|
dd0a01f95c | ||
|
|
62c13152a3 | ||
|
|
b27b426728 | ||
|
|
8f7798328b | ||
|
|
54f1213a7f | ||
|
|
2586a1dd86 | ||
|
|
9953a99010 | ||
|
|
fea256c5a8 | ||
|
|
aa957d0e87 | ||
|
|
4820995bfb | ||
|
|
f84194d9c6 | ||
|
|
843037af47 | ||
|
|
bf73ef7299 |
@@ -7,6 +7,14 @@
|
|||||||
# Allow demo assets (AIUI pre-built dist)
|
# Allow demo assets (AIUI pre-built dist)
|
||||||
!demo/
|
!demo/
|
||||||
|
|
||||||
|
# Allow backend source for ISO source builds
|
||||||
|
!core/
|
||||||
|
!scripts/
|
||||||
|
!image-recipe/
|
||||||
|
image-recipe/build/
|
||||||
|
image-recipe/results/
|
||||||
|
image-recipe/output/
|
||||||
|
|
||||||
# Exclude nested node_modules (will npm install in container)
|
# Exclude nested node_modules (will npm install in container)
|
||||||
neode-ui/node_modules
|
neode-ui/node_modules
|
||||||
neode-ui/dist
|
neode-ui/dist
|
||||||
|
|||||||
@@ -1,145 +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
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
clean: true
|
|
||||||
|
|
||||||
- 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
|
|
||||||
+23
@@ -57,6 +57,11 @@ coverage/
|
|||||||
*.dmg
|
*.dmg
|
||||||
*.app
|
*.app
|
||||||
|
|
||||||
|
# Release artifacts live in Gitea Release attachments, not Git history.
|
||||||
|
releases/**
|
||||||
|
!releases/
|
||||||
|
!releases/manifest.json
|
||||||
|
|
||||||
# macOS build output
|
# macOS build output
|
||||||
build/macos/
|
build/macos/
|
||||||
|
|
||||||
@@ -73,3 +78,21 @@ loop/loop.log.bak
|
|||||||
# Separate repos nested in tree
|
# Separate repos nested in tree
|
||||||
web/
|
web/
|
||||||
|
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Resilience harness reports (generated, contains session cookies)
|
||||||
|
scripts/resilience/reports/
|
||||||
|
|
||||||
|
# Codex / pnpm / python caches / editor backups
|
||||||
|
.codex
|
||||||
|
.codex-target-*/
|
||||||
|
.codex-tmp/
|
||||||
|
.pnpm-store/
|
||||||
|
**/__pycache__/
|
||||||
|
*.bak
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|
||||||
|
# Local evidence screenshots; intentional UI screenshots should live under an
|
||||||
|
# app/docs asset path with a descriptive filename.
|
||||||
|
Screenshot *.png
|
||||||
|
uploads/
|
||||||
|
|||||||
@@ -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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ fun WebViewScreen(
|
|||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
|
fun openExternalUrl(url: String) {
|
||||||
|
try {
|
||||||
|
val intent = android.content.Intent(
|
||||||
|
android.content.Intent.ACTION_VIEW,
|
||||||
|
android.net.Uri.parse(url),
|
||||||
|
)
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
WebView(context).apply {
|
WebView(context).apply {
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
@@ -220,13 +230,7 @@ fun WebViewScreen(
|
|||||||
// Keep navigation within the Archipelago server
|
// Keep navigation within the Archipelago server
|
||||||
if (url.startsWith(serverUrl)) return false
|
if (url.startsWith(serverUrl)) return false
|
||||||
// Open external URLs in the system browser
|
// Open external URLs in the system browser
|
||||||
try {
|
openExternalUrl(url)
|
||||||
val intent = android.content.Intent(
|
|
||||||
android.content.Intent.ACTION_VIEW,
|
|
||||||
android.net.Uri.parse(url),
|
|
||||||
)
|
|
||||||
context.startActivity(intent)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,18 +247,30 @@ fun WebViewScreen(
|
|||||||
isUserGesture: Boolean,
|
isUserGesture: Boolean,
|
||||||
resultMsg: android.os.Message?,
|
resultMsg: android.os.Message?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
// Extract the URL from the hit test
|
val transport = resultMsg?.obj as? WebView.WebViewTransport
|
||||||
val data = view?.hitTestResult?.extra
|
?: return false
|
||||||
if (data != null) {
|
|
||||||
try {
|
val popup = WebView(context).apply {
|
||||||
val intent = android.content.Intent(
|
settings.javaScriptEnabled = true
|
||||||
android.content.Intent.ACTION_VIEW,
|
webViewClient = object : WebViewClient() {
|
||||||
android.net.Uri.parse(data),
|
override fun shouldOverrideUrlLoading(
|
||||||
)
|
view: WebView?,
|
||||||
context.startActivity(intent)
|
request: WebResourceRequest?,
|
||||||
} catch (_: Exception) {}
|
): Boolean {
|
||||||
|
val url = request?.url?.toString() ?: return true
|
||||||
|
openExternalUrl(url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
if (url != null) openExternalUrl(url)
|
||||||
|
view?.stopLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
transport.webView = popup
|
||||||
|
resultMsg.sendToTarget()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
-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
|
|
||||||
+347
@@ -1,5 +1,352 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.7.87-alpha (2026-06-12)
|
||||||
|
|
||||||
|
- Bitcoin receive now calls LND's on-chain address endpoint with the correct REST method, and backend failures keep the specific address-generation error instead of collapsing into the generic operation-failed message.
|
||||||
|
- App launch credential interstitials now render as true full-screen overlays, and the launcher loading indicator uses the neutral brand palette instead of a blue spinner.
|
||||||
|
- Validation passed with `git diff --check`, `npm run type-check`, and the focused frontend tests for `bitcoinReceive` and `AppIconGrid`.
|
||||||
|
|
||||||
|
## v1.7.86-alpha (2026-06-12)
|
||||||
|
|
||||||
|
- Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.
|
||||||
|
- Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab.
|
||||||
|
- The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes.
|
||||||
|
- The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick.
|
||||||
|
- Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index.
|
||||||
|
- Validation passed with `git diff --check`, focused frontend tests, and `npm run type-check`.
|
||||||
|
|
||||||
|
## v1.7.85-alpha (2026-06-12)
|
||||||
|
|
||||||
|
- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.
|
||||||
|
- Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.
|
||||||
|
- LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.
|
||||||
|
- Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.
|
||||||
|
- Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.
|
||||||
|
- Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting.
|
||||||
|
|
||||||
|
## v1.7.84-alpha (2026-06-11)
|
||||||
|
|
||||||
|
- Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.
|
||||||
|
- Kiosk mode now includes a browser safe-area path for HDMI displays that crop edges, and self-update refreshes kiosk launcher/systemd files so display fixes ship to existing nodes. The experimental X11 scaling safe-area is opt-in to avoid stretching TV output.
|
||||||
|
- Wi-Fi setup now reports scan errors instead of showing an empty network list, supports retrying scans from the modal, parses escaped `nmcli` SSIDs correctly, and can join open networks without forcing a WPA password.
|
||||||
|
- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist.
|
||||||
|
- The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC.
|
||||||
|
- The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.
|
||||||
|
- Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.
|
||||||
|
- Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path.
|
||||||
|
|
||||||
|
## v1.7.83-alpha (2026-06-11)
|
||||||
|
|
||||||
|
- App launch metadata now derives more consistently from app manifests, with typed launch interfaces and catalog generation updates that keep packaged apps aligned with their runtime ports and launch surfaces.
|
||||||
|
- Revoked or unsupported app surfaces were removed from the catalog and release path, including OnlyOffice and the unvalidated Saleor surface, so the Marketplace no longer exposes apps that cannot be safely supported in this release.
|
||||||
|
- The frontend production build now passes strict TypeScript checks after tightening app details, Web5, cloud refresh, and credential test typing.
|
||||||
|
- Mobile and desktop app surfaces received release polish: improved mobile app layout, safer mesh desktop/tablet scrolling, and the Home system card now routes directly to monitoring.
|
||||||
|
- Bitcoin UI status rendering now avoids false stale/reconnecting states when fresh block snapshots advance, and guards optional DOM updates so the standalone Bitcoin UI is more resilient.
|
||||||
|
- Deploy tooling now excludes local Codex scratch output, archived image-build artifacts, and upload screenshots from target syncs, and bounded optional IndeedHub fixups so a stuck Podman helper cannot hold the deploy.
|
||||||
|
- Validation passed with `npm run type-check`, production `npm run build`, backend `cargo build --release`, catalog/release manifest checks, focused frontend tests, and live `.198` deploy verification through the frontend/service restart phase.
|
||||||
|
|
||||||
|
## v1.7.82-alpha (2026-05-22)
|
||||||
|
|
||||||
|
- Saleor storefront proxying now forwards `X-Forwarded-Host`, fixing Next.js Server Actions requests that compared the browser origin with the internal `storefront-app:3000` upstream host.
|
||||||
|
- Saleor storefront media now routes `/thumbnail/` and `/media/` through the same `9011` proxy to the Saleor API, fixing product image optimizer failures caused by `localhost:8000` media URLs.
|
||||||
|
- The Saleor storefront container receives an explicit internal media origin so rewritten media URLs resolve inside the Podman network without exposing private API ports to browsers.
|
||||||
|
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for storefront HTML, static assets, GraphQL, media redirects, and optimized product images.
|
||||||
|
|
||||||
|
## v1.7.81-alpha (2026-05-21)
|
||||||
|
|
||||||
|
- Saleor storefront installs now use the prebuilt registry image instead of building the Next.js app on-device, avoiding Podman build failures during stack installation.
|
||||||
|
- Existing Saleor stacks are repaired on adoption by recreating missing storefront containers, forcing the storefront app to bind `0.0.0.0:3000`, and resolving nginx upstreams dynamically after container restarts.
|
||||||
|
- The shipped Saleor storefront image now includes public assets and omits Vercel-only Speed Insights injection, fixing broken static asset responses and the local `/_vercel/speed-insights/script.js` browser warning.
|
||||||
|
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for `9011` storefront, static assets, and proxied GraphQL.
|
||||||
|
|
||||||
|
## v1.7.80-alpha (2026-05-21)
|
||||||
|
|
||||||
|
- Saleor storefront proxying now falls back to the direct request scheme when no forwarded protocol header is present, fixing direct `http://node:9011` launches that could generate an invalid same-origin GraphQL URL.
|
||||||
|
- The Saleor storefront release path keeps public proxy support intact by still honoring forwarded HTTPS headers for Nginx Proxy Manager domains while repairing local/direct port launches.
|
||||||
|
- Validation passed with `cargo fmt --check` and `cargo check` for the Archipelago backend before release staging.
|
||||||
|
|
||||||
|
## v1.7.79-alpha (2026-05-20)
|
||||||
|
|
||||||
|
- Saleor now installs the official Saleor Storefront as part of the stack, built from the pinned `saleor/storefront` source and served as the customer-facing shop on port `9011`.
|
||||||
|
- Saleor app launches now open the storefront while the admin dashboard remains available on port `9010` with the generated `admin@example.com` credentials shown in Archipelago.
|
||||||
|
- Public Nginx Proxy Manager hosts forwarding to the Saleor storefront also expose same-origin `/graphql/`, so public storefront domains can talk to the local Saleor API without mixed-content or private-LAN reachability failures.
|
||||||
|
- Saleor stack metadata, marketplace descriptions, catalog ports, scanner exclusions, and app-session routing now describe the storefront/dashboard/API split explicitly.
|
||||||
|
|
||||||
|
## v1.7.78-alpha (2026-05-20)
|
||||||
|
|
||||||
|
- Public Nginx Proxy Manager hosts for Saleor now keep browser GraphQL calls same-origin at `/graphql/` and proxy them to the local API on `8000`, fixing `Failed to fetch` when a public domain such as `noderunner.shop` was loaded from devices that cannot reach the node's private LAN/tailnet API address.
|
||||||
|
- Saleor's validated stack changes are now release-ready: dashboard origins on port `9010` are explicitly allowed for dashboard/API calls, preserving the working test-node install path for production nodes.
|
||||||
|
- NetBird launches now stay pinned to the unified dashboard/proxy origin on port `8087` instead of following stale runtime-discovered server URLs on `8086`.
|
||||||
|
- NetBird's local nginx proxy now routes browser API, OAuth, relay, and WebSocket traffic through `host.containers.internal:8086` instead of a hard-coded rootless Podman gateway IP, and includes the upstream `management.ProxyService` gRPC path.
|
||||||
|
- The mobile credentials interstitial now keeps credential lists scrollable and action buttons reachable in both My Apps and the mobile app icon grid.
|
||||||
|
- Android WebView popup windows now hand external popup URLs to the system browser, covering app login/signup flows that open secondary windows.
|
||||||
|
- Validation passed with `git diff --check`, `cargo check -p archipelago`, and the focused `npm test -- src/views/appSession/__tests__/appSessionConfig.test.ts` suite.
|
||||||
|
|
||||||
|
## v1.7.77-alpha (2026-05-20)
|
||||||
|
|
||||||
|
- Saleor first-use now exposes generated credentials through Archipelago instead of leaving users at an unexplained dashboard login: App Details shows copyable `admin@example.com` credentials, and My Apps/mobile icon launches show a pre-launch credentials modal.
|
||||||
|
- Saleor installs now create or repair the `admin@example.com` staff account idempotently after sample data loads, use the correct dashboard mount path, and re-check stack containers after startup so stopped containers are caught.
|
||||||
|
- NetBird embedded login now uses the upstream-compatible IdP signing-key behavior and sends ID tokens from the dashboard to the management API, fixing the post-signup `Unauthenticated` state while preserving the unified local proxy/logout routes.
|
||||||
|
- Transient unnamed Podman helper containers created during app install tasks are hidden from My Apps, so generated names like `eager_keldysh` no longer appear as user applications.
|
||||||
|
- Validation passed with catalog/release JSON checks, `npm run type-check`, and `cargo fmt --all --check --manifest-path core/Cargo.toml`; live checks on `100.114.134.21` confirmed Saleor dashboard/API availability, generated Saleor admin login, NetBird OAuth availability, and NetBird logout redirects.
|
||||||
|
|
||||||
|
## v1.7.76-alpha (2026-05-20)
|
||||||
|
|
||||||
|
- Saleor installs now use dashboard port `9010`, avoiding the existing Portainer `9000` binding on the test node while keeping API `8000`, Mailpit `8025`, and Jaeger `16686` unchanged.
|
||||||
|
- Saleor's Valkey cache no longer bind-mounts `/var/lib/archipelago/saleor-cache`, and the dashboard container has the minimal rootless nginx capabilities it needs to chown cache files, bind port 80 inside the container, and drop workers to the nginx user.
|
||||||
|
- NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts.
|
||||||
|
- Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown.
|
||||||
|
- Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager.
|
||||||
|
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart.
|
||||||
|
|
||||||
|
## v1.7.75-alpha (2026-05-19)
|
||||||
|
|
||||||
|
- Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger.
|
||||||
|
- Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy.
|
||||||
|
- Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens.
|
||||||
|
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release.
|
||||||
|
|
||||||
|
## v1.7.74-alpha (2026-05-19)
|
||||||
|
|
||||||
|
- App-session right panels now re-focus the iframe after load and when the frame area is activated, so wheel/touch scrolling works immediately after switching tabs or selecting an app on shorter screens.
|
||||||
|
- NetBird now launches through a unified local origin on port `8087` that proxies the dashboard plus `/oauth2`, `/api`, relay, WebSocket, and gRPC routes to `netbird-server`, fixing the embedded login flow that previously ended in `Unauthenticated` or `404 page not found` after logout.
|
||||||
|
- Existing NetBird installs are repaired on adopt/start by rewriting `config.yaml`, `dashboard.env`, and the local nginx proxy config, then creating the missing `netbird-dashboard` and `netbird` proxy containers when needed while preserving NetBird data.
|
||||||
|
- Saleor is still pending and is not included in this release; its registry/installer work remains local until it can be validated separately.
|
||||||
|
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||||
|
|
||||||
|
## v1.7.73-alpha (2026-05-19)
|
||||||
|
|
||||||
|
- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap.
|
||||||
|
- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps.
|
||||||
|
- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details.
|
||||||
|
- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`.
|
||||||
|
- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click.
|
||||||
|
|
||||||
|
## v1.7.72-alpha (2026-05-19)
|
||||||
|
|
||||||
|
- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.
|
||||||
|
- The follow-up release carries the NetBird install fix, Gitea icon polish, mobile app-session fallback updates, and rounder app icon masks from `v1.7.71-alpha` with the Settings modal notes included.
|
||||||
|
- The local Cargo lockfile version metadata is kept in sync with the release bump after the previous release build updated it.
|
||||||
|
|
||||||
|
## v1.7.71-alpha (2026-05-19)
|
||||||
|
|
||||||
|
- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on `100.70.96.88` where Podman rejected the missing host directory.
|
||||||
|
- NetBird start/restart ordering now starts `netbird-server` before the dashboard container so lifecycle actions bring the control plane up before the UI.
|
||||||
|
- App-session invalid IDs and panel-mode fallbacks now return to `/dashboard/apps`, avoiding the stale `/apps` route that could render a 404.
|
||||||
|
- Mobile launches for apps that block iframes now stay inside the Archipelago app-session fallback instead of automatically opening an external browser tab.
|
||||||
|
- Installed Gitea containers now report the packaged Gitea icon, and app icon masks use a rounder radius on mobile grids, app cards, and detail headers.
|
||||||
|
- Validation passed with `npm run type-check`, focused Vitest app-session/app-grid tests, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||||
|
|
||||||
|
## v1.7.70-alpha (2026-05-19)
|
||||||
|
|
||||||
|
- NetBird is being corrected from the peer/client daemon image to the self-hosted NetBird control-plane stack with a launchable dashboard on port `8087`, a combined management/signal/relay server on `8086`, and STUN on UDP `3478`.
|
||||||
|
- App sessions now always launch local apps through direct host ports and carry an explicit dashboard return target, so closing an iframe returns to the launching dashboard screen instead of falling through to browser history or a 404.
|
||||||
|
- Mobile app launches ignore stale desktop panel state and route into the full app-session webview consistently.
|
||||||
|
- The desktop sidebar now pins the logo/version at the top and controller/online/mode controls at the bottom, with only the navigation section scrolling on shorter screens.
|
||||||
|
- Validation passed with catalog JSON checks, `scripts/image-versions.sh` syntax check, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||||
|
|
||||||
|
## v1.7.69-alpha (2026-05-19)
|
||||||
|
|
||||||
|
- App installs now allow up to 10 minutes for the initial `package.install` RPC to return, matching slow container image pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors.
|
||||||
|
- Live diagnostics on `100.70.96.88` confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds.
|
||||||
|
- Gitea and other Docker-image app installs now stay visible during slow registry pulls instead of being marked as failed by the browser before backend install progress can complete.
|
||||||
|
- Gitea is now categorized as a known Data app in My Apps, so a running Gitea container appears with installed apps instead of being filtered into the Websites/Services split.
|
||||||
|
- NetBird `0.71.2` is now available in the app catalog and fallback marketplace data as a recommended networking app using the official `docker.io/netbirdio/netbird:0.71.2` image.
|
||||||
|
- NetBird installs get persistent state under `/var/lib/archipelago/netbird`, `NET_ADMIN`/`NET_RAW`, `/dev/net/tun`, `slirp4netns`, image-version pinning, backend metadata, and health checks through `netbird status`.
|
||||||
|
- The Archipelago terminal now includes `nano` on new disk installs and ISO builds, and self-update installs it on existing nodes if it is missing.
|
||||||
|
- Validation passed with catalog JSON checks, shell syntax checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||||
|
|
||||||
|
## v1.7.68-alpha (2026-05-19)
|
||||||
|
|
||||||
|
- BTCPay Server now ships on the official `docker.io/btcpayserver/btcpayserver:2.3.9` image, fixing the plugin catalog crash caused by newer plugin dependency version metadata while preserving existing datadirs and Postgres databases.
|
||||||
|
- BTCPay release and first-boot health checks no longer depend on `curl` inside the container; they use a bash TCP probe that works with the official image out of the box.
|
||||||
|
- Host nginx now serves Nginx Proxy Manager HTTP-01 challenge files before the Archipelago SPA fallback and is marked as the default HTTP/HTTPS virtual host, so public proxy hosts can issue certificates without hijacking local API traffic.
|
||||||
|
- Nginx Proxy Manager first-boot, runtime repair, and container-doctor paths now pre-create the ACME webroot, keep bind mounts owned by the rootless Archipelago user, and sync issued public proxy hosts into host nginx vhosts.
|
||||||
|
- The Nginx Proxy Manager host-nginx sync now skips proxy hosts with missing certificate files and rolls back the generated nginx include if validation fails, preventing a bad certificate path from poisoning later nginx reloads.
|
||||||
|
- App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry.
|
||||||
|
- System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel.
|
||||||
|
- Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell.
|
||||||
|
- Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.
|
||||||
|
- Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates.
|
||||||
|
|
||||||
|
## v1.7.67-alpha (2026-05-18)
|
||||||
|
|
||||||
|
- Home dashboard status cards now keep the last known good system, VPN, Bitcoin, and FIPS values while route changes or transient RPC failures are in flight, avoiding false "not configured" or "not running" flashes.
|
||||||
|
- Home, Web5 Monitoring, and the Monitoring page headline cards now share the same live system-stat snapshot for CPU, memory, disk, uptime, and load so the visible numbers agree across the UI.
|
||||||
|
- Settings What's New is filled through `v1.7.67-alpha`, including the missing historical `v1.7.44-alpha` through `v1.7.66-alpha` entries.
|
||||||
|
- Bitcoin/Knots/Core shell lifecycle specs now match the Rust app config memory policy: 8 GiB on normal hosts, 4 GiB on low-memory hosts, and pruned Knots uses a larger dbcache on hosts with enough RAM to improve IBD throughput.
|
||||||
|
- ElectrumX/electrs shell lifecycle specs now match the 4 GiB memory policy used by the Rust app config, reducing drift between first boot, reconcile, and app lifecycle paths.
|
||||||
|
- Live assessment of `100.70.96.88` identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup.
|
||||||
|
|
||||||
|
## v1.7.66-alpha (2026-05-18)
|
||||||
|
|
||||||
|
- Nginx Proxy Manager stale-port repair now detects stopped or `Created` Podman records by inspecting `podman ps -a` port metadata, covering records where `podman port nginx-proxy-manager` returns no mapping until start.
|
||||||
|
- Live recovery on `100.70.96.88` removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data.
|
||||||
|
- Validation confirmed Nginx Proxy Manager recovered as healthy and responds through direct admin port `8081`, host compatibility port `81`, and `/app/nginx-proxy-manager/`.
|
||||||
|
|
||||||
|
## v1.7.65-alpha (2026-05-18)
|
||||||
|
|
||||||
|
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
|
||||||
|
- Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
|
||||||
|
|
||||||
|
## v1.7.64-alpha (2026-05-18)
|
||||||
|
|
||||||
|
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
|
||||||
|
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
|
||||||
|
|
||||||
|
## v1.7.63-alpha (2026-05-18)
|
||||||
|
|
||||||
|
- Release automation now rebuilds the Rust backend after bumping the version and before hashing release artifacts, preventing OTA manifests from pointing at a stale backend binary.
|
||||||
|
- This corrected release carries the Nginx Proxy Manager stale-port repair in an updated backend binary, so nodes running `1.7.61-alpha` can actually receive and execute the fix.
|
||||||
|
- Validation confirmed the previously published `v1.7.62-alpha` backend artifact still contained `1.7.61-alpha`, explaining why nodes did not advance after applying that update.
|
||||||
|
|
||||||
|
## v1.7.62-alpha (2026-05-18)
|
||||||
|
|
||||||
|
- Nginx Proxy Manager start and restart now repair stale Podman containers that still publish the admin UI on host port `81`, which conflicts with host nginx on updated nodes.
|
||||||
|
- The repair recreates only the stale Nginx Proxy Manager container metadata while preserving `/var/lib/archipelago/nginx-proxy-manager` data and using the current `8081:81`, `8084:80`, and `8444:443` mappings.
|
||||||
|
- Runtime stale-listener cleanup for Nginx Proxy Manager is shared across start and restart paths so rootless port helper leftovers are still cleared before lifecycle retries.
|
||||||
|
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml` and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
|
||||||
|
|
||||||
|
## v1.7.61-alpha (2026-05-18)
|
||||||
|
|
||||||
|
- Multi-container stack installs now keep their app card in the `Installing` state for up to 20 minutes while dependency containers are being pulled and prepared.
|
||||||
|
- BTCPay Server installs no longer appear to vanish or fail after two minutes while Postgres and NBXplorer are still being created before the primary `btcpay-server` container exists.
|
||||||
|
- The stale-transition escape hatch remains short for start, stop, restart, update, and removal operations, so genuinely wedged lifecycle actions still recover quickly.
|
||||||
|
- Live validation on `100.70.96.88` confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect.
|
||||||
|
|
||||||
|
## v1.7.60-alpha (2026-05-18)
|
||||||
|
|
||||||
|
- Meshtastic serial detection now rejects malformed or incomplete handshakes instead of accepting unrelated serial devices as a fallback Meshtastic radio.
|
||||||
|
- Mesh radio auto-detection now skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks, avoiding interference with production peripherals.
|
||||||
|
- Meshtastic config sync now sends `want_config_id` with the correct protobuf wire type, fixing radio-side `ignore malformed toradio` errors and allowing node-info/contact ingestion.
|
||||||
|
- The stable `/dev/mesh-radio` udev rule no longer claims every `ttyACM*` device; it only matches known mesh USB serial adapters and known USB CDC ACM radio vendors.
|
||||||
|
- Live validation on `100.70.96.88` confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts.
|
||||||
|
|
||||||
|
## v1.7.59-alpha (2026-05-17)
|
||||||
|
|
||||||
|
- Mobile app launching now keeps known container apps inside Archipelago's app-session flow instead of forcing desktop-only new-tab behavior on phones.
|
||||||
|
- App sessions on mobile now respect the status-bar safe area so foreground iframe content starts below the device chrome while the fullscreen backdrop remains edge-to-edge.
|
||||||
|
- Prepackaged website launch buttons now resolve their curated website URLs before website-container fallback logic, restoring launches for the L484 sites and adding the Arch Presentation bookmark.
|
||||||
|
- Meshtastic contact discovery now drains the radio config stream through completion and retries config sync when the contact cache is empty, so nearby nodes already known by the radio are more likely to appear in Archipelago.
|
||||||
|
- The Apps page now includes a compact sideload button and modal for installing trusted Docker images with optional title, description, and port mapping metadata.
|
||||||
|
- Sideloaded app title and description metadata now persist through the backend app-config file so refreshed package scans do not collapse custom apps back to generic IDs.
|
||||||
|
- Validation passed with `npm test -- appLauncher`, `npm run build`, `cargo check -p archipelago`, and `cargo fmt --all --check`.
|
||||||
|
|
||||||
|
## v1.7.58-alpha (2026-05-17)
|
||||||
|
|
||||||
|
- Mesh networking now supports Meshtastic radios over the Meshtastic serial API in addition to existing MeshCore Companion USB radios.
|
||||||
|
- The mesh listener now probes preferred and auto-detected serial paths for both MeshCore and Meshtastic firmware, preserving the existing reconnect loop so unplug/replug and firmware hot-swap behavior stays consistent.
|
||||||
|
- Meshtastic text packets are translated into the existing Archipelago mesh frame pipeline, so current RPC handlers, transport routing, message storage, typed-message decoding, and UI state continue to work without a separate frontend path.
|
||||||
|
- Meshtastic node information is surfaced as normal mesh contacts using stable synthetic public keys derived from Meshtastic node numbers, allowing peer refresh and message attribution to reuse existing MeshCore contact handling.
|
||||||
|
- Outbound Archipelago mesh messages can now be sent through Meshtastic as channel text packets using the same command path used by MeshCore channel broadcasts.
|
||||||
|
- Device status now reports the detected firmware family as `meshcore` or `meshtastic` from the shared listener abstraction.
|
||||||
|
- Radio udev rules now include USB CDC ACM serial devices (`ttyACM*`) alongside CP2102, CH340, and FTDI adapters so Meshtastic boards are more likely to appear through the stable `/dev/mesh-radio` symlink.
|
||||||
|
- Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell.
|
||||||
|
- The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates.
|
||||||
|
- OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy.
|
||||||
|
- Local validation passed with `cargo check -p archipelago`; live SSH testing against `100.70.96.88` was not completed because temporary public-key authentication was rejected on the target.
|
||||||
|
|
||||||
|
## v1.7.57-alpha (2026-05-17)
|
||||||
|
|
||||||
|
- Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.
|
||||||
|
- App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.
|
||||||
|
- Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.
|
||||||
|
- Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.
|
||||||
|
- Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory.
|
||||||
|
|
||||||
|
## v1.7.56-alpha (2026-05-15)
|
||||||
|
|
||||||
|
- Health notifications now clear when an app is no longer unhealthy, including stale alerts for removed containers such as Portainer.
|
||||||
|
- Fresh installs now include the full Wi-Fi userspace stack (`wpasupplicant`, `wireless-regdb`, `iw`, `rfkill`, `polkitd`, `pciutils`, and `usbutils`) so NetworkManager can scan and connect with Intel Wi-Fi cards out of the box.
|
||||||
|
- The installed system now grants the `archipelago` service user explicit NetworkManager PolicyKit access for web-triggered Wi-Fi scans and connection changes.
|
||||||
|
- Wi-Fi connect now replaces stale/partial NetworkManager profiles and creates an explicit WPA-PSK profile with the supplied password, avoiding no-secret retry failures after a failed attempt.
|
||||||
|
- Settings password changes now update the Linux/SSH password through non-interactive sudo, so the web password and SSH password stay in sync when the checkbox is enabled.
|
||||||
|
- Quadlet environment values with spaces or shell metacharacters are quoted consistently, preventing env drift recreate loops for apps like nostr-rs-relay and Grafana.
|
||||||
|
- Boot/bootstrap reconcile avoids restarting running Bitcoin containers while repairing RPC config, preserving IBD progress on active nodes.
|
||||||
|
- Exit code 137 is labeled as SIGKILL instead of assuming OOM, avoiding false OOM alerts for orchestrator-managed recreates.
|
||||||
|
- Container reconcile force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically.
|
||||||
|
- Container health reporting is honest for running containers: Archipelago surfaces Podman's actual health state instead of marking every running container healthy.
|
||||||
|
- Quadlet reconciliation restarts services when stale health gates, port bindings, network aliases, exec commands, or healthchecks drift from the current manifest.
|
||||||
|
- Bitcoin Knots sync performance improves on fresh installs and updates with 8Gi container memory, a 4Gi dbcache, and full CPU parallelism.
|
||||||
|
- ElectrumX initial indexing gets more headroom: CPU caps are removed, memory is raised to 4Gi, cache is raised to 3Gi, and oversized sends are allowed for heavier wallet/indexing workloads.
|
||||||
|
- Mempool/ElectrumX lifecycle qualification respects pruned/non-archival Bitcoin nodes instead of installing a half-running stack with unhealthy dependencies.
|
||||||
|
- LND wallet/RPC helpers are more tolerant of container-owned files and updated REST port metadata, improving LND lifecycle and wallet-connect flows.
|
||||||
|
- Marketplace/catalog metadata carries richer container config so remote lifecycle tests install apps using the same settings users get from the UI.
|
||||||
|
- The app screensaver no longer activates during media-heavy app sessions such as IndeeHub, Jellyfin, Immich, PhotoPrism, and File Browser; apps can also pause/resume it with media playback messages.
|
||||||
|
- A fresh `1.7.56-alpha` unbundled installer ISO is built from the same primary VPS2 release line for easy download and USB flashing.
|
||||||
|
|
||||||
|
## v1.7.55-alpha (2026-05-13)
|
||||||
|
|
||||||
|
- Container reconcile now force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically.
|
||||||
|
- `.198` is green after the container-layer hardening pass: focused and broad non-destructive lifecycle audits pass, raw Podman health/state sweep is clean, and direct app probes return healthy responses.
|
||||||
|
- Release-candidate artifacts are staged separately from live update publishing while Gitea artifact hosting is repaired.
|
||||||
|
|
||||||
|
## v1.7.54-alpha (2026-05-06)
|
||||||
|
|
||||||
|
- Existing installs now self-repair nginx backend proxy locations for `/bitcoin-status` and `/api/app-catalog`, including hosts where `sites-enabled/archipelago` is a copied active file instead of a symlink.
|
||||||
|
- LND UI is consistently served on `18083` across first boot, Tor config, companion Quadlet reconciliation, OTA runtime payloads, and ISO scripts; stale companion units/images are rewritten instead of only checking service active state.
|
||||||
|
- OTA frontend tarballs now carry a clean runtime payload with updated scripts, docker UI sources, and canonical nginx config, preventing startup promotion from reintroducing stale host assets.
|
||||||
|
- Release ISO builds now support the primary HTTP app registry when bundling core images, so unbundled media includes File Browser/Cloud support instead of requiring a post-install Marketplace download.
|
||||||
|
- `.116` was live-updated with the new backend and runtime scripts; focused non-destructive lifecycle audit passes for Bitcoin Knots, LND, BTCPay, Mempool, and Grafana.
|
||||||
|
|
||||||
|
## v1.7.53-alpha (2026-05-05)
|
||||||
|
|
||||||
|
- Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between `bitcoin.conf` and container command args, fixing `Unable to bind all endpoints for RPC server` startup failures.
|
||||||
|
- Legacy Bitcoin container healthchecks no longer depend on `bitcoin-cli`, which is absent from current Knots images and can wedge Podman healthcheck runners.
|
||||||
|
- Update checks now prefer manifest OTA releases over stale git remotes unless `ARCHIPELAGO_GIT_UPDATES` is explicitly enabled, so installed nodes can see published releases from the VPS mirror.
|
||||||
|
|
||||||
|
## v1.7.52-alpha (2026-05-05)
|
||||||
|
|
||||||
|
- Tailscale now launches the local installed web UI on port `8240` and starts `tailscaled` before `tailscale web`, fixing unreachable installs after container creation.
|
||||||
|
- Grafana install/start/restart now repairs missing rootless host listeners on port `3000`, matching the existing SearXNG, Uptime Kuma, and Gitea recovery path.
|
||||||
|
- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.
|
||||||
|
- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`.
|
||||||
|
|
||||||
|
## v1.7.49-alpha (2026-04-30)
|
||||||
|
|
||||||
|
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.
|
||||||
|
- ElectrumX UI now exposes indexed height, local Bitcoin height, known headers, status, and progress source so indexing/waiting states are readable during long initial sync.
|
||||||
|
- Added container doctor timer and smoke/lifecycle test coverage for Bitcoin Knots/Core, ElectrumX, Mempool, BTCPay/NBXplorer, and UI surface availability.
|
||||||
|
- Bitcoin Core and Bitcoin Knots are mutually exclusive variants, with a real Bitcoin Core manifest and corrected install conflict handling.
|
||||||
|
- IndeeHub now launches only on direct web UI port `7778`; the broken `/app/indeedhub/` path proxy was removed, and port `7777` remains the Nostr relay.
|
||||||
|
- BTCPay/NBXplorer Postgres environment formatting fixed so installs do not carry malformed connection strings.
|
||||||
|
|
||||||
|
## v1.7.48-alpha (2026-04-29)
|
||||||
|
|
||||||
|
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in.
|
||||||
|
|
||||||
|
## v1.7.47-alpha (2026-04-29)
|
||||||
|
|
||||||
|
- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed.
|
||||||
|
- ElectrumX initial indexing is faster too. Its CPU cap is removed, container memory is 4GB, and its internal cache is now 3GB (default was 1.2GB).
|
||||||
|
|
||||||
|
## v1.7.46-alpha (2026-04-29)
|
||||||
|
|
||||||
|
- Health monitor no longer pages "Auto-restart failed" for orphaned containers. After a variant switch (bitcoin-core ↔ bitcoin-knots) the previous variant's container could survive uninstall and the health monitor would try restarting it forever. Now skipped silently with a debug log.
|
||||||
|
- Apps no longer disappear from My Apps when an install fails. The card stays visible with state=Stopped so the user can retry or uninstall, with the failure reason surfaced via the new install_progress.message field.
|
||||||
|
- "Downloading…" progress now actually advances during multi-image stack pulls. Was sticking at 20% until all pulls finished; now interpolates 20%→70% based on which image of N has landed.
|
||||||
|
- Pulled four docker.io images (bitcoin, gitea, nextcloud, valkey) into the lfg2025 registries on OVH and tx1138. Removes a docker.io dependency from first-boot installs.
|
||||||
|
- Resilience harness improvements: install-fail entries no longer vanish, install/uninstall/probe cells are timing-tolerant (60s retry on ui_probe and auth_probe), dep snapshots no longer leak companion containers into the dependent app's "new containers" set.
|
||||||
|
|
||||||
|
## v1.7.45-alpha (2026-04-29)
|
||||||
|
|
||||||
|
- Bitcoin RPC auth is durable. The dashboard reliably connects across container restart, image update, and reboot. Was failing on registry-pulled images that shipped a stale baked-in password.
|
||||||
|
- Multi-container apps show real install progress. IndeedHub (7), BTCPay (4), Mempool (3), Immich (3) — bar advances through Preparing → Pulling → Creating → Done instead of sitting at 0% until the very end.
|
||||||
|
- Apps no longer disappear from the dashboard mid-install. The container scanner now respects in-flight installs and updates instead of evicting an entry while its containers are still being created.
|
||||||
|
- IndeedHub installs cleanly on a fresh node. Five missing environment variables fixed; Nostr sign-in works on first install.
|
||||||
|
- Tailscale install no longer fails with "executable not found". Container command was a malformed shell string; now a proper command array.
|
||||||
|
- Removed three catalog entries that hung installs for ten minutes (dwn, endurain, ollama — no source images in our registries). Restored Nextcloud, sourced from docker.io.
|
||||||
|
- Bitcoin Core update path uses the correct image name (was pulling from a non-existent path).
|
||||||
|
- New ISO installs now allocate swap (sized to RAM, capped at 8GB, on the encrypted data partition). Without swap, container image builds and memory spikes were hitting OOM under load.
|
||||||
|
|
||||||
|
## v1.7.44-alpha (2026-04-28)
|
||||||
|
|
||||||
|
43de3b73 feat(orchestrator): complete container migration and release hardening
|
||||||
|
ce39430b feat(self-update): sync and rebuild UI containers on OTA
|
||||||
|
72dec5aa fix(lnd-ui): align container port across all specs
|
||||||
|
83aacdf2 chore(release): archive ISO build recipes, tarball-only releases
|
||||||
|
|
||||||
|
|
||||||
All notable changes to Archipelago will be documented in this file.
|
All notable changes to Archipelago will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
|||||||
@@ -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)
|
|
||||||
+1
-1
@@ -122,7 +122,7 @@ echo ""
|
|||||||
# Install custom app dependencies
|
# Install custom app dependencies
|
||||||
echo "Installing custom app dependencies..."
|
echo "Installing custom app dependencies..."
|
||||||
|
|
||||||
for app in did-wallet endurain morphos-server router web5-dwn; do
|
for app in did-wallet endurain morphos-server router; do
|
||||||
if [ -d "apps/$app" ]; then
|
if [ -d "apps/$app" ]; then
|
||||||
echo " - Installing $app dependencies..."
|
echo " - Installing $app dependencies..."
|
||||||
cd "apps/$app"
|
cd "apps/$app"
|
||||||
|
|||||||
@@ -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/)
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
- **Mempool** block explorer and fee estimator
|
- **Mempool** block explorer and fee estimator
|
||||||
- **Fedimint** federation guardian and gateway
|
- **Fedimint** federation guardian and gateway
|
||||||
|
|
||||||
### Self-Hosted Apps (30)
|
### Self-Hosted Apps (29)
|
||||||
Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, OnlyOffice, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
|
Bitcoin, Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
|
||||||
|
|
||||||
### Decentralized Identity
|
### Decentralized Identity
|
||||||
- Ed25519 node identity with DID Documents (did:key)
|
- Ed25519 node identity with DID Documents (did:key)
|
||||||
@@ -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
|
||||||
@@ -101,18 +101,24 @@ npm run build # Production build → web/dist/neode-ui/
|
|||||||
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
|
./scripts/deploy-to-target.sh --both # Deploy to both LAN servers
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build ISO
|
### Release (tarball-only)
|
||||||
|
|
||||||
|
Releases ship as a backend binary and a frontend tarball referenced by
|
||||||
|
`releases/manifest.json`. Nodes OTA-update via `scripts/self-update.sh`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh archipelago@<server>
|
./scripts/create-release.sh 1.2.3
|
||||||
cd ~/archy/image-recipe
|
git push gitea-local main --tags
|
||||||
sudo ./build-auto-installer-iso.sh
|
git push gitea-vps2 main --tags
|
||||||
```
|
```
|
||||||
|
|
||||||
|
ISO builds are archived under `image-recipe/_archived/` and not part of the
|
||||||
|
release deliverable.
|
||||||
|
|
||||||
## 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,535 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"updated": "2026-04-22T00:00:00Z",
|
||||||
|
"registry": "146.59.87.168:3000/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": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.",
|
||||||
|
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
|
||||||
|
"author": "Bitcoin Knots",
|
||||||
|
"category": "money",
|
||||||
|
"tier": "core",
|
||||||
|
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin-knots:latest",
|
||||||
|
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bitcoin-core",
|
||||||
|
"title": "Bitcoin Core",
|
||||||
|
"version": "28.4.0",
|
||||||
|
"description": "Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.",
|
||||||
|
"icon": "/assets/img/app-icons/bitcoin-core.svg",
|
||||||
|
"author": "Bitcoin Core contributors",
|
||||||
|
"category": "money",
|
||||||
|
"tier": "optional",
|
||||||
|
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin:28.4",
|
||||||
|
"repoUrl": "https://github.com/bitcoin/bitcoin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lnd",
|
||||||
|
"title": "LND",
|
||||||
|
"version": "0.18.4",
|
||||||
|
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
|
||||||
|
"icon": "/assets/img/app-icons/lnd.svg",
|
||||||
|
"author": "Lightning Labs",
|
||||||
|
"category": "money",
|
||||||
|
"tier": "core",
|
||||||
|
"dockerImage": "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta",
|
||||||
|
"repoUrl": "https://github.com/lightningnetwork/lnd",
|
||||||
|
"requires": [
|
||||||
|
"bitcoin-knots"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "btcpay-server",
|
||||||
|
"title": "BTCPay Server",
|
||||||
|
"version": "2.3.9",
|
||||||
|
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
|
||||||
|
"icon": "/assets/img/app-icons/btcpay-server.png",
|
||||||
|
"author": "BTCPay Server Foundation",
|
||||||
|
"category": "commerce",
|
||||||
|
"tier": "core",
|
||||||
|
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
|
||||||
|
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
|
||||||
|
"requires": [
|
||||||
|
"bitcoin-knots"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mempool",
|
||||||
|
"title": "Mempool Explorer",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||||
|
"icon": "/assets/img/app-icons/mempool.webp",
|
||||||
|
"author": "Mempool",
|
||||||
|
"category": "money",
|
||||||
|
"tier": "core",
|
||||||
|
"dockerImage": "146.59.87.168:3000/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 server indexing Bitcoin chain data for lightweight wallet queries.",
|
||||||
|
"icon": "/assets/img/app-icons/electrumx.png",
|
||||||
|
"author": "Luke Childs",
|
||||||
|
"category": "money",
|
||||||
|
"tier": "core",
|
||||||
|
"dockerImage": "146.59.87.168:3000/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 platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.",
|
||||||
|
"icon": "/assets/img/app-icons/indeedhub.png",
|
||||||
|
"author": "IndeeHub",
|
||||||
|
"category": "community",
|
||||||
|
"dockerImage": "146.59.87.168:3000/lfg2025/indeedhub:1.0.0",
|
||||||
|
"repoUrl": "https://github.com/indeedhub/indeedhub"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "botfights",
|
||||||
|
"title": "BotFights",
|
||||||
|
"version": "1.1.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.",
|
||||||
|
"icon": "/assets/img/app-icons/botfights.svg",
|
||||||
|
"author": "BotFights",
|
||||||
|
"category": "community",
|
||||||
|
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0",
|
||||||
|
"repoUrl": "https://botfights.net",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"9100:9100"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/botfights:/app/server/data"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
"NODE_ENV=production",
|
||||||
|
"PORT=9100",
|
||||||
|
"FIGHT_LOOP_ENABLED=true",
|
||||||
|
"ARCHY_EMBEDDED=1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gitea",
|
||||||
|
"title": "Gitea",
|
||||||
|
"version": "1.23",
|
||||||
|
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
|
||||||
|
"icon": "/assets/img/app-icons/gitea.svg",
|
||||||
|
"author": "Gitea",
|
||||||
|
"category": "development",
|
||||||
|
"dockerImage": "docker.io/gitea/gitea:1.23",
|
||||||
|
"repoUrl": "https://gitea.com",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"3001:3000",
|
||||||
|
"2222:22"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/gitea/data:/data",
|
||||||
|
"/var/lib/archipelago/gitea/config:/etc/gitea"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
"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__security__X_FRAME_OPTIONS="
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tier": "optional"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "filebrowser",
|
||||||
|
"title": "File Browser",
|
||||||
|
"version": "2.27.0",
|
||||||
|
"description": "Baseline Archipelago file manager service.",
|
||||||
|
"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",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"8083:80"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/filebrowser:/srv",
|
||||||
|
"/var/lib/archipelago/filebrowser-data:/data"
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
"--database=/data/database.db",
|
||||||
|
"--root=/srv",
|
||||||
|
"--address=0.0.0.0",
|
||||||
|
"--port=80"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nostr-rs-relay",
|
||||||
|
"title": "Nostr Relay (Rust)",
|
||||||
|
"version": "0.8.0",
|
||||||
|
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
|
||||||
|
"icon": "/assets/img/app-icons/nostr.svg",
|
||||||
|
"author": "Nostr RS Relay",
|
||||||
|
"category": "community",
|
||||||
|
"tier": "recommended",
|
||||||
|
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
|
||||||
|
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"8081:8080"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/nostr-relay:/usr/src/app/db"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
"RELAY_NAME=Archipelago Nostr Relay",
|
||||||
|
"RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "meshtastic",
|
||||||
|
"title": "Meshtastic",
|
||||||
|
"version": "2-daily-alpine",
|
||||||
|
"description": "Open-source mesh networking for LoRa radios. Create decentralized communication networks.",
|
||||||
|
"icon": "/assets/img/app-icons/meshcore.svg",
|
||||||
|
"author": "Meshtastic",
|
||||||
|
"category": "networking",
|
||||||
|
"tier": "recommended",
|
||||||
|
"dockerImage": "docker.io/meshtastic/meshtasticd:daily-alpine",
|
||||||
|
"repoUrl": "https://github.com/meshtastic/firmware",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"4403:4403"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/meshtastic:/var/lib/meshtasticd"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
"MESHTASTIC_PORT=/dev/ttyUSB0",
|
||||||
|
"MESHTASTIC_SERIAL=true"
|
||||||
|
],
|
||||||
|
"notes": "Requires a LoRa radio device at /dev/ttyUSB0. The config file is rendered from the app manifest before container start."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vaultwarden",
|
||||||
|
"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": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine",
|
||||||
|
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"8082:80"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/vaultwarden:/data"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "searxng",
|
||||||
|
"title": "SearXNG",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Privacy-respecting metasearch engine. Search the web without tracking.",
|
||||||
|
"icon": "/assets/img/app-icons/searxng.png",
|
||||||
|
"author": "SearXNG",
|
||||||
|
"category": "data",
|
||||||
|
"tier": "recommended",
|
||||||
|
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
|
||||||
|
"repoUrl": "https://github.com/searxng/searxng",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"8888:8080"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/searxng:/etc/searxng"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fedimint",
|
||||||
|
"title": "Fedimint",
|
||||||
|
"version": "0.10.0",
|
||||||
|
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
|
||||||
|
"icon": "/assets/img/app-icons/fedimint.png",
|
||||||
|
"author": "Fedimint",
|
||||||
|
"category": "money",
|
||||||
|
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
|
||||||
|
"repoUrl": "https://github.com/fedimint/fedimint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fedimint-gateway",
|
||||||
|
"title": "Fedimint Gateway",
|
||||||
|
"version": "0.10.0",
|
||||||
|
"description": "Fedimint gateway service with automatic LND-or-LDK backend selection.",
|
||||||
|
"icon": "/assets/img/app-icons/fedimint.png",
|
||||||
|
"author": "Fedimint",
|
||||||
|
"category": "money",
|
||||||
|
"dockerImage": "git.tx1138.com/lfg2025/gatewayd:v0.10.0",
|
||||||
|
"repoUrl": "https://github.com/fedimint/fedimint",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"8176:8176",
|
||||||
|
"9737:9737"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/fedimint-gateway:/data",
|
||||||
|
"/var/lib/archipelago/lnd:/lnd:ro"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
|
||||||
|
"repoUrl": "https://github.com/jellyfin/jellyfin",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"8096:8096"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/jellyfin/config:/config",
|
||||||
|
"/var/lib/archipelago/jellyfin/cache:/cache"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "146.59.87.168:3000/lfg2025/immich-server:release",
|
||||||
|
"repoUrl": "https://github.com/immich-app/immich"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "homeassistant",
|
||||||
|
"title": "Home Assistant",
|
||||||
|
"version": "2024.1.0",
|
||||||
|
"description": "Open source home automation platform. Control and monitor your smart home devices.",
|
||||||
|
"icon": "/assets/img/app-icons/homeassistant.png",
|
||||||
|
"author": "Home Assistant",
|
||||||
|
"category": "home",
|
||||||
|
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
|
||||||
|
"repoUrl": "https://github.com/home-assistant/core",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"8123:8123"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/home-assistant:/config"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
"TZ=UTC"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grafana",
|
||||||
|
"title": "Grafana",
|
||||||
|
"version": "10.2.0",
|
||||||
|
"description": "Analytics and monitoring platform. Visualize metrics and create dashboards.",
|
||||||
|
"icon": "/assets/img/app-icons/grafana.png",
|
||||||
|
"author": "Grafana Labs",
|
||||||
|
"category": "data",
|
||||||
|
"tier": "recommended",
|
||||||
|
"dockerImage": "grafana/grafana:10.2.0",
|
||||||
|
"repoUrl": "https://github.com/grafana/grafana",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"3000:3000"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/grafana:/var/lib/grafana"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
"GF_PATHS_DATA=/var/lib/grafana",
|
||||||
|
"GF_USERS_ALLOW_SIGN_UP=false"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "146.59.87.168:3000/lfg2025/tailscale:stable",
|
||||||
|
"repoUrl": "https://github.com/tailscale/tailscale",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"8240:8240"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/tailscale:/var/lib/tailscale"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
"TS_STATE_DIR=/var/lib/tailscale"
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "portainer",
|
||||||
|
"title": "Portainer",
|
||||||
|
"version": "2.19.4",
|
||||||
|
"description": "Container management web UI for the local Podman socket.",
|
||||||
|
"icon": "/assets/img/app-icons/portainer.webp",
|
||||||
|
"author": "Portainer",
|
||||||
|
"category": "development",
|
||||||
|
"tier": "optional",
|
||||||
|
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
|
||||||
|
"repoUrl": "https://github.com/portainer/portainer",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"9000:9000"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/portainer:/data",
|
||||||
|
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
|
||||||
|
],
|
||||||
|
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "netbird",
|
||||||
|
"title": "NetBird",
|
||||||
|
"version": "0.71.2",
|
||||||
|
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.",
|
||||||
|
"icon": "/assets/img/app-icons/netbird.svg",
|
||||||
|
"author": "NetBird",
|
||||||
|
"category": "networking",
|
||||||
|
"tier": "recommended",
|
||||||
|
"dockerImage": "docker.io/netbirdio/dashboard:v2.38.0",
|
||||||
|
"repoUrl": "https://github.com/netbirdio/netbird",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"8087:80",
|
||||||
|
"8086:80",
|
||||||
|
"3478:3478/udp"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/netbird:/var/lib/netbird"
|
||||||
|
],
|
||||||
|
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
|
||||||
|
"repoUrl": "https://github.com/louislam/uptime-kuma",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"3002:3001"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/uptime-kuma:/app/data"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
"TZ=UTC"
|
||||||
|
],
|
||||||
|
"args": [
|
||||||
|
"--",
|
||||||
|
"node",
|
||||||
|
"server/server.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "146.59.87.168:3000/lfg2025/photoprism:240915",
|
||||||
|
"repoUrl": "https://github.com/photoprism/photoprism",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"2342:2342"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/photoprism:/photoprism/storage"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
|
||||||
|
"PHOTOPRISM_DEFAULT_LOCALE=en"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nextcloud",
|
||||||
|
"title": "Nextcloud",
|
||||||
|
"version": "29",
|
||||||
|
"description": "Your own private cloud. File sync, calendars, contacts.",
|
||||||
|
"icon": "/assets/img/app-icons/nextcloud.webp",
|
||||||
|
"author": "Nextcloud",
|
||||||
|
"category": "data",
|
||||||
|
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:29",
|
||||||
|
"repoUrl": "https://github.com/nextcloud/server",
|
||||||
|
"containerConfig": {
|
||||||
|
"ports": [
|
||||||
|
"8085:80"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
"/var/lib/archipelago/nextcloud:/var/www/html"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-3
@@ -8,7 +8,6 @@
|
|||||||
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
|
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
|
||||||
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
|
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
|
||||||
| btcpay-server | 23000 (HTTP) | v1.13.5 |
|
| btcpay-server | 23000 (HTTP) | v1.13.5 |
|
||||||
| thunderhub | 3010 (HTTP) | v0.13.31 |
|
|
||||||
| mempool | 4080 (HTTP) | v2.5.0 |
|
| mempool | 4080 (HTTP) | v2.5.0 |
|
||||||
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
|
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
|
||||||
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
|
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
|
||||||
@@ -33,7 +32,6 @@
|
|||||||
| ollama | 11434 | v0.5.4 |
|
| ollama | 11434 | v0.5.4 |
|
||||||
| grafana | 3001 | v10.2.0 |
|
| grafana | 3001 | v10.2.0 |
|
||||||
| portainer | 9000 | v2.19.4 |
|
| portainer | 9000 | v2.19.4 |
|
||||||
| onlyoffice | 8088 | v7.5.1 |
|
|
||||||
| penpot | 8089 | v2.4 |
|
| penpot | 8089 | v2.4 |
|
||||||
|
|
||||||
## Building Apps
|
## Building Apps
|
||||||
@@ -44,7 +42,7 @@ cd apps
|
|||||||
./build.sh <app-id> # Build specific app
|
./build.sh <app-id> # Build specific app
|
||||||
```
|
```
|
||||||
|
|
||||||
Custom apps with local source: `router`, `did-wallet`, `web5-dwn`. All other apps use official container images.
|
Custom apps with local source: `router`, `did-wallet`. All other apps use official container images.
|
||||||
|
|
||||||
## App Structure
|
## App Structure
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -17,15 +17,13 @@ This document lists all port assignments for Archipelago apps.
|
|||||||
| mempool | 4080 | TCP | Web UI | 14080 |
|
| mempool | 4080 | TCP | Web UI | 14080 |
|
||||||
| ollama | 11434 | TCP | API | 21434 |
|
| ollama | 11434 | TCP | API | 21434 |
|
||||||
| searxng | 8888 | TCP | Web UI | 18888 |
|
| searxng | 8888 | TCP | Web UI | 18888 |
|
||||||
| onlyoffice | 8088 | TCP | Web UI | 18088 |
|
|
||||||
| penpot | 8089 | TCP | Web UI | 18089 |
|
| penpot | 8089 | TCP | Web UI | 18089 |
|
||||||
| lnd | 9735, 10009, 8080 | TCP | P2P, gRPC, REST | 19735, 20009, 18080 |
|
| lnd | 9735, 10009, 18080 | TCP | P2P, gRPC, REST | 19735, 20009, 28080 |
|
||||||
| core-lightning | 9736, 9835 | TCP | P2P, gRPC | 19736, 19835 |
|
| core-lightning | 9736, 9835 | TCP | P2P, gRPC | 19736, 19835 |
|
||||||
| nostr-rs-relay | 8081 | TCP | HTTP/WebSocket | 18081 |
|
| nostr-rs-relay | 8081 | TCP | HTTP/WebSocket | 18081 |
|
||||||
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
|
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
|
||||||
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
||||||
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
||||||
| web5-dwn | 3000 | TCP | HTTP API | 13000 |
|
|
||||||
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
||||||
|
|
||||||
## Development Ports (Offset: +10000)
|
## Development Ports (Offset: +10000)
|
||||||
@@ -47,7 +45,6 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
|
|||||||
| Mempool | http://localhost:14080 |
|
| Mempool | http://localhost:14080 |
|
||||||
| Ollama | http://localhost:21434 |
|
| Ollama | http://localhost:21434 |
|
||||||
| SearXNG | http://localhost:18888 |
|
| SearXNG | http://localhost:18888 |
|
||||||
| OnlyOffice | http://localhost:18088 |
|
|
||||||
| Penpot | http://localhost:18089 |
|
| Penpot | http://localhost:18089 |
|
||||||
| LND REST | http://localhost:18080 |
|
| LND REST | http://localhost:18080 |
|
||||||
| Core Lightning | http://localhost:19835 |
|
| Core Lightning | http://localhost:19835 |
|
||||||
@@ -55,7 +52,6 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
|
|||||||
| Strfry | http://localhost:18082 |
|
| Strfry | http://localhost:18082 |
|
||||||
| DID Wallet | http://localhost:18083 |
|
| DID Wallet | http://localhost:18083 |
|
||||||
| Router | http://localhost:18084 |
|
| Router | http://localhost:18084 |
|
||||||
| Web5 DWN | http://localhost:13000 |
|
|
||||||
| Meshtastic | http://localhost:14403 |
|
| Meshtastic | http://localhost:14403 |
|
||||||
|
|
||||||
## Port Conflict Resolution
|
## Port Conflict Resolution
|
||||||
|
|||||||
+2
-4
@@ -30,14 +30,13 @@ cd apps
|
|||||||
./build.sh
|
./build.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet, web5-dwn) will be built from source.
|
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet) will be built from source.
|
||||||
|
|
||||||
### Build Specific App
|
### Build Specific App
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./build.sh router
|
./build.sh router
|
||||||
./build.sh did-wallet
|
./build.sh did-wallet
|
||||||
./build.sh web5-dwn
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running Apps via Archipelago
|
## Running Apps via Archipelago
|
||||||
@@ -64,7 +63,6 @@ In development mode, apps are accessible on offset ports:
|
|||||||
|
|
||||||
- **Router**: http://localhost:18084
|
- **Router**: http://localhost:18084
|
||||||
- **DID Wallet**: http://localhost:18083
|
- **DID Wallet**: http://localhost:18083
|
||||||
- **Web5 DWN**: http://localhost:13000
|
|
||||||
- **Nostr RS Relay**: http://localhost:18081
|
- **Nostr RS Relay**: http://localhost:18081
|
||||||
- **Strfry**: http://localhost:18082
|
- **Strfry**: http://localhost:18082
|
||||||
|
|
||||||
@@ -72,7 +70,7 @@ See [PORTS.md](./PORTS.md) for complete port mapping.
|
|||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
### For Custom Apps (router, did-wallet, web5-dwn)
|
### For Custom Apps (router, did-wallet)
|
||||||
|
|
||||||
1. **Make changes** to source code in `apps/<app-id>/src/`
|
1. **Make changes** to source code in `apps/<app-id>/src/`
|
||||||
2. **Rebuild** the container:
|
2. **Rebuild** the container:
|
||||||
|
|||||||
+1
-3
@@ -8,7 +8,6 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
|
|||||||
- **bitcoin-knots** — Full Bitcoin node (v28.1)
|
- **bitcoin-knots** — Full Bitcoin node (v28.1)
|
||||||
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
|
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
|
||||||
- **btcpay-server** — Payment processor (v1.13.5)
|
- **btcpay-server** — Payment processor (v1.13.5)
|
||||||
- **thunderhub** — Lightning management UI (v0.13.31)
|
|
||||||
- **mempool** — Block explorer and fee estimator (v2.5.0)
|
- **mempool** — Block explorer and fee estimator (v2.5.0)
|
||||||
- **electrumx** — Electrum server
|
- **electrumx** — Electrum server
|
||||||
- **fedimint** — Federated Bitcoin minting (v0.10.0)
|
- **fedimint** — Federated Bitcoin minting (v0.10.0)
|
||||||
@@ -18,12 +17,11 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
|
|||||||
- **nostrudel** — Nostr web client (v0.40.0)
|
- **nostrudel** — Nostr web client (v0.40.0)
|
||||||
|
|
||||||
### Web5 & Identity
|
### Web5 & Identity
|
||||||
- **web5-dwn** — Decentralized Web Node (v0.4.0)
|
|
||||||
- **did-wallet** — Web5 DID Wallet
|
- **did-wallet** — Web5 DID Wallet
|
||||||
|
|
||||||
### Self-Hosted Services
|
### Self-Hosted Services
|
||||||
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
|
- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915)
|
||||||
- **vaultwarden** (v1.30.0-alpine), **onlyoffice** (v7.5.1), **penpot** (v2.4)
|
- **vaultwarden** (v1.30.0-alpine), **penpot** (v2.4)
|
||||||
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
|
- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17)
|
||||||
- **ollama** (v0.5.4), **grafana** (v10.2.0), **portainer** (v2.19.4)
|
- **ollama** (v0.5.4), **grafana** (v10.2.0), **portainer** (v2.19.4)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
app:
|
||||||
|
id: archy-btcpay-db
|
||||||
|
name: BTCPay Postgres
|
||||||
|
version: 15.17
|
||||||
|
description: Postgres backend for BTCPay and NBXplorer.
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: git.tx1138.com/lfg2025/postgres:15.17
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
data_uid: "100998:100998"
|
||||||
|
secret_env:
|
||||||
|
- key: POSTGRES_PASSWORD
|
||||||
|
secret_file: btcpay-db-password
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 20Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 1Gi
|
||||||
|
disk_limit: 20Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/postgres-btcpay
|
||||||
|
target: /var/lib/postgresql/data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=btcpay
|
||||||
|
- POSTGRES_USER=btcpay
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:5432
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
bitcoin_integration:
|
||||||
|
rpc_access: none
|
||||||
|
sync_required: false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
app:
|
||||||
|
id: archy-mempool-db
|
||||||
|
name: Mempool MariaDB
|
||||||
|
version: 11.4.10
|
||||||
|
description: MariaDB backend for the mempool explorer stack.
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: git.tx1138.com/lfg2025/mariadb:11.4.10
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
data_uid: "100998:100998"
|
||||||
|
secret_env:
|
||||||
|
- key: MYSQL_PASSWORD
|
||||||
|
secret_file: mempool-db-password
|
||||||
|
- key: MYSQL_ROOT_PASSWORD
|
||||||
|
secret_file: mysql-root-db-password
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 20Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 512Mi
|
||||||
|
disk_limit: 20Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/mysql-mempool
|
||||||
|
target: /var/lib/mysql
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- MYSQL_DATABASE=mempool
|
||||||
|
- MYSQL_USER=mempool
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:3306
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
bitcoin_integration:
|
||||||
|
rpc_access: none
|
||||||
|
sync_required: false
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
app:
|
||||||
|
id: archy-mempool-web
|
||||||
|
name: Mempool Web
|
||||||
|
version: 3.0.0
|
||||||
|
description: Frontend web UI for mempool explorer.
|
||||||
|
container_name: mempool
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: git.tx1138.com/lfg2025/mempool-frontend:v3.0.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: mempool-api
|
||||||
|
version: ">=3.0.0"
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 512Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 4080
|
||||||
|
container: 8080
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- FRONTEND_HTTP_PORT=8080
|
||||||
|
- BACKEND_MAINNET_HTTP_HOST=mempool-api
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://localhost:8080
|
||||||
|
path: /
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
bitcoin_integration:
|
||||||
|
rpc_access: none
|
||||||
|
sync_required: false
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
app:
|
||||||
|
id: archy-nbxplorer
|
||||||
|
name: NBXplorer
|
||||||
|
version: 2.6.0
|
||||||
|
description: BTCPay blockchain indexer service.
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: git.tx1138.com/lfg2025/nbxplorer:2.6.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
secret_env:
|
||||||
|
- key: NBXPLORER_BTCRPCPASSWORD
|
||||||
|
secret_file: bitcoin-rpc-password
|
||||||
|
- key: BTCPAY_DB_PASS
|
||||||
|
secret_file: btcpay-db-password
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: bitcoin-core
|
||||||
|
version: ">=26.0"
|
||||||
|
- app_id: archy-btcpay-db
|
||||||
|
version: ">=15.17"
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 2Gi
|
||||||
|
disk_limit: 20Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 32838
|
||||||
|
container: 32838
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/nbxplorer
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- NBXPLORER_DATADIR=/data
|
||||||
|
- NBXPLORER_NETWORK=mainnet
|
||||||
|
- NBXPLORER_CHAINS=btc
|
||||||
|
- NBXPLORER_BIND=0.0.0.0:32838
|
||||||
|
- NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332
|
||||||
|
- NBXPLORER_BTCRPCUSER=archipelago
|
||||||
|
- NBXPLORER_BTCNODEENDPOINT=bitcoin-knots:8333
|
||||||
|
- NBXPLORER_NOAUTH=1
|
||||||
|
- NBXPLORER_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://localhost:32838
|
||||||
|
path: /
|
||||||
|
interval: 30s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
bitcoin_integration:
|
||||||
|
rpc_access: read-only
|
||||||
|
sync_required: true
|
||||||
@@ -1,61 +1,94 @@
|
|||||||
app:
|
app:
|
||||||
id: bitcoin-core
|
id: bitcoin-core
|
||||||
name: Bitcoin Core
|
name: Bitcoin Core
|
||||||
version: 24.0.0
|
version: 28.4.0
|
||||||
description: Full Bitcoin node implementation. The reference implementation of the Bitcoin protocol.
|
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
|
||||||
|
|
||||||
|
container_name: bitcoin-core
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: bitcoin/bitcoin:24.0
|
image: 146.59.87.168:3000/lfg2025/bitcoin:28.4
|
||||||
image_signature: cosign://...
|
pull_policy: if-not-present
|
||||||
pull_policy: verify-signature
|
network: archy-net
|
||||||
|
entrypoint: ["sh", "-lc"]
|
||||||
|
custom_args:
|
||||||
|
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
||||||
|
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
||||||
|
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||||
|
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||||
|
# mempool + connections.
|
||||||
|
- >-
|
||||||
|
BITCOIND="$(command -v bitcoind || true)";
|
||||||
|
if [ -z "$BITCOIND" ]; then
|
||||||
|
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
|
||||||
|
fi;
|
||||||
|
if [ -z "$BITCOIND" ]; then
|
||||||
|
echo "bitcoind not found in image" >&2;
|
||||||
|
exit 127;
|
||||||
|
fi;
|
||||||
|
RPC_USER="$(printenv BITCOIN_RPC_USER)";
|
||||||
|
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
|
||||||
|
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||||
|
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||||
|
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||||
|
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
|
||||||
|
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||||
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||||
|
fi;
|
||||||
|
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||||
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||||
|
else
|
||||||
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||||
|
fi
|
||||||
|
derived_env:
|
||||||
|
- key: DISK_GB
|
||||||
|
template: "{{DISK_GB}}"
|
||||||
|
secret_env:
|
||||||
|
- key: BITCOIN_RPC_PASS
|
||||||
|
secret_file: bitcoin-rpc-password
|
||||||
|
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
|
||||||
|
secret_file: bitcoin-rpc-txrelay-rpcauth
|
||||||
|
data_uid: "100101:100101"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- storage: 500Gi # Minimum disk space for mainnet
|
- storage: 500Gi
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
cpu_limit: 2
|
cpu_limit: 0
|
||||||
memory_limit: 2Gi
|
memory_limit: 4Gi
|
||||||
disk_limit: 500Gi
|
disk_limit: 500Gi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
capabilities: [] # No special capabilities needed
|
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||||
readonly_root: true
|
readonly_root: false
|
||||||
no_new_privileges: true
|
|
||||||
user: 1000
|
|
||||||
seccomp_profile: default
|
|
||||||
network_policy: isolated
|
network_policy: isolated
|
||||||
apparmor_profile: bitcoin-core
|
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- host: 8332
|
- host: 8332
|
||||||
container: 8332
|
container: 8332
|
||||||
protocol: tcp # RPC
|
protocol: tcp
|
||||||
- host: 8333
|
- host: 8333
|
||||||
container: 8333
|
container: 8333
|
||||||
protocol: tcp # P2P
|
protocol: tcp
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /var/lib/archipelago/bitcoin
|
source: /var/lib/archipelago/bitcoin
|
||||||
target: /home/bitcoin/.bitcoin
|
target: /home/bitcoin/.bitcoin
|
||||||
options: [rw]
|
options: [rw]
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- NETWORK=mainnet
|
- BITCOIN_RPC_USER=archipelago
|
||||||
- RPC_USER=${BITCOIN_RPC_USER}
|
|
||||||
- RPC_PASSWORD=${BITCOIN_RPC_PASSWORD}
|
|
||||||
- PRUNE=0 # Full node (set to 550 for pruned)
|
|
||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: tcp
|
||||||
endpoint: http://localhost:8332
|
endpoint: localhost:8332
|
||||||
path: /
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
bitcoin_integration:
|
bitcoin_integration:
|
||||||
rpc_access: admin
|
rpc_access: admin
|
||||||
sync_required: true
|
sync_required: true
|
||||||
testnet_support: true
|
testnet_support: false
|
||||||
pruning_support: true
|
pruning_support: true
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
app:
|
||||||
|
id: bitcoin-knots
|
||||||
|
name: Bitcoin Knots
|
||||||
|
version: 28.1.0
|
||||||
|
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
|
||||||
|
|
||||||
|
container_name: bitcoin-knots
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/bitcoin-knots:latest
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
entrypoint: ["sh", "-lc"]
|
||||||
|
custom_args:
|
||||||
|
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
|
||||||
|
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
|
||||||
|
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
|
||||||
|
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
|
||||||
|
# mempool + connections.
|
||||||
|
- >-
|
||||||
|
BITCOIND="$(command -v bitcoind || true)";
|
||||||
|
if [ -z "$BITCOIND" ]; then
|
||||||
|
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
|
||||||
|
fi;
|
||||||
|
if [ -z "$BITCOIND" ]; then
|
||||||
|
echo "bitcoind not found in image" >&2;
|
||||||
|
exit 127;
|
||||||
|
fi;
|
||||||
|
RPC_USER="$(printenv BITCOIN_RPC_USER)";
|
||||||
|
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
|
||||||
|
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
|
||||||
|
DISK_GB_VALUE="$(printenv DISK_GB || true)";
|
||||||
|
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
|
||||||
|
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
|
||||||
|
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||||
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||||
|
fi;
|
||||||
|
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||||
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||||
|
else
|
||||||
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
|
||||||
|
fi
|
||||||
|
derived_env:
|
||||||
|
- key: DISK_GB
|
||||||
|
template: "{{DISK_GB}}"
|
||||||
|
secret_env:
|
||||||
|
- key: BITCOIN_RPC_PASS
|
||||||
|
secret_file: bitcoin-rpc-password
|
||||||
|
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
|
||||||
|
secret_file: bitcoin-rpc-txrelay-rpcauth
|
||||||
|
data_uid: "100101:100101"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 500Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
cpu_limit: 0
|
||||||
|
memory_limit: 8Gi
|
||||||
|
disk_limit: 500Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 8332
|
||||||
|
container: 8332
|
||||||
|
protocol: tcp
|
||||||
|
- host: 8333
|
||||||
|
container: 8333
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/bitcoin
|
||||||
|
target: /home/bitcoin/.bitcoin
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- BITCOIN_RPC_USER=archipelago
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:8332
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
bitcoin_integration:
|
||||||
|
rpc_access: admin
|
||||||
|
sync_required: true
|
||||||
|
testnet_support: false
|
||||||
|
pruning_support: true
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
app:
|
||||||
|
id: bitcoin-ui
|
||||||
|
name: Bitcoin UI
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
Archipelago-native HTTP proxy + static site for interacting with the
|
||||||
|
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
|
||||||
|
and reverse-proxies /bitcoin-rpc/ to 127.0.0.1:8332 on the host. The
|
||||||
|
upstream Authorization header is substituted from
|
||||||
|
/var/lib/archipelago/secrets/bitcoin-rpc-password by the prod
|
||||||
|
orchestrator's pre-start hook, rendered into an nginx.conf that is
|
||||||
|
bind-mounted read-only at container start.
|
||||||
|
|
||||||
|
container:
|
||||||
|
build:
|
||||||
|
context: /opt/archipelago/docker/bitcoin-ui
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
tag: localhost/bitcoin-ui:local
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: bitcoin-core
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 128Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: host
|
||||||
|
|
||||||
|
# Host networking: nginx listens on 8334 directly on the host IP, and
|
||||||
|
# proxies to 127.0.0.1:8332 which is where the bitcoin backend binds
|
||||||
|
# its RPC. `ports:` is intentionally empty because host networking
|
||||||
|
# bypasses port mapping.
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Bind-mount the rendered nginx.conf read-only. The prod orchestrator
|
||||||
|
# renders /var/lib/archipelago/bitcoin-ui/nginx.conf on every install
|
||||||
|
# and every reconcile pass, substituting the base64 RPC auth from
|
||||||
|
# the plaintext password secret. If the rendered bytes change (the
|
||||||
|
# password rotated, or the template was updated by OTA), the
|
||||||
|
# reconciler restarts this container so nginx re-reads the config.
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/bitcoin-ui/nginx.conf
|
||||||
|
target: /etc/nginx/conf.d/default.conf
|
||||||
|
options: [ro]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://127.0.0.1:8334
|
||||||
|
path: /
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
app:
|
||||||
|
id: botfights
|
||||||
|
name: BotFights
|
||||||
|
version: 1.1.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: 146.59.87.168:3000/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
|
||||||
|
repo: https://botfights.net
|
||||||
|
icon: /assets/img/app-icons/botfights.svg
|
||||||
|
license: MIT
|
||||||
|
tags:
|
||||||
|
- bitcoin
|
||||||
|
- gaming
|
||||||
|
- arcade
|
||||||
|
- fighter
|
||||||
|
- bots
|
||||||
|
- competition
|
||||||
|
- controller
|
||||||
@@ -1,66 +1,85 @@
|
|||||||
app:
|
app:
|
||||||
id: btcpay-server
|
id: btcpay-server
|
||||||
name: BTCPay Server
|
name: BTCPay Server
|
||||||
version: 1.12.0
|
version: 2.3.9
|
||||||
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
|
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: btcpayserver/btcpayserver:1.12.0
|
image: docker.io/btcpayserver/btcpayserver:2.3.9
|
||||||
image_signature: cosign://...
|
pull_policy: if-not-present
|
||||||
pull_policy: verify-signature
|
network: archy-net
|
||||||
|
secret_env:
|
||||||
|
- key: BTCPAY_BTCRPCPASSWORD
|
||||||
|
secret_file: bitcoin-rpc-password
|
||||||
|
- key: BTCPAY_DB_PASS
|
||||||
|
secret_file: btcpay-db-password
|
||||||
|
derived_env:
|
||||||
|
- key: BTCPAY_HOST
|
||||||
|
template: "{{HOST_IP}}:23000"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- app_id: bitcoin-core
|
- app_id: bitcoin-core
|
||||||
version: ">=26.0"
|
version: ">=26.0"
|
||||||
- app_id: lnd
|
- app_id: archy-btcpay-db
|
||||||
version: ">=0.18.0"
|
version: ">=15.17"
|
||||||
|
- app_id: archy-nbxplorer
|
||||||
|
version: ">=2.6.0"
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
cpu_limit: 2
|
cpu_limit: 2
|
||||||
memory_limit: 2Gi
|
memory_limit: 2Gi
|
||||||
disk_limit: 20Gi
|
disk_limit: 20Gi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
capabilities: [NET_BIND_SERVICE]
|
capabilities: []
|
||||||
readonly_root: true
|
readonly_root: false
|
||||||
no_new_privileges: true
|
|
||||||
user: 1000
|
|
||||||
seccomp_profile: default
|
|
||||||
network_policy: isolated
|
network_policy: isolated
|
||||||
apparmor_profile: btcpay
|
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- host: 80
|
- host: 23000
|
||||||
container: 80
|
container: 49392
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
- host: 443
|
|
||||||
container: 443
|
|
||||||
protocol: tcp
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /var/lib/archipelago/btcpay
|
source: /var/lib/archipelago/btcpay
|
||||||
target: /datadir
|
target: /datadir
|
||||||
options: [rw]
|
options: [rw]
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- BTCPAY_NETWORK=mainnet
|
- ASPNETCORE_URLS=http://0.0.0.0:49392
|
||||||
- BTCPAY_CHAIN=btc
|
- BTCPAY_PROTOCOL=http
|
||||||
- BTCPAY_BTCEXPLORERURL=http://bitcoin-core:8332
|
- BTCPAY_CHAINS=btc
|
||||||
- BTCPAY_LIGHTNING=type=lnd-rest;server=http://lnd:8080;allowinsecure=true
|
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
|
||||||
|
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
|
||||||
|
- BTCPAY_BTCRPCUSER=archipelago
|
||||||
|
- BTCPAY_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay
|
||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: http
|
||||||
endpoint: http://localhost
|
endpoint: http://localhost:49392
|
||||||
path: /health
|
path: /
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 30s
|
||||||
retries: 3
|
retries: 5
|
||||||
|
|
||||||
bitcoin_integration:
|
bitcoin_integration:
|
||||||
rpc_access: read-only
|
rpc_access: read-only
|
||||||
sync_required: true
|
sync_required: true
|
||||||
|
|
||||||
lightning_integration:
|
lightning_integration:
|
||||||
payment_processing: true
|
payment_processing: false
|
||||||
invoice_management: true
|
invoice_management: true
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: BTCPay Server dashboard
|
||||||
|
type: ui
|
||||||
|
port: 23000
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
launch:
|
||||||
|
open_in_new_tab: true
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ app:
|
|||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- app_id: web5-dwn
|
|
||||||
version: ">=1.0.0"
|
|
||||||
- storage: 2Gi
|
- storage: 2Gi
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
@@ -40,7 +38,6 @@ app:
|
|||||||
options: [rw]
|
options: [rw]
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- DWN_ENDPOINT=http://web5-dwn:3000
|
|
||||||
- WALLET_STORAGE=/app/wallet
|
- WALLET_STORAGE=/app/wallet
|
||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
|
|||||||
@@ -34,5 +34,4 @@ app.post('/api/wallet/did/create', async (req, res) => {
|
|||||||
// Start server
|
// Start server
|
||||||
app.listen(port, '0.0.0.0', () => {
|
app.listen(port, '0.0.0.0', () => {
|
||||||
console.log(`DID Wallet listening on port ${port}`);
|
console.log(`DID Wallet listening on port ${port}`);
|
||||||
console.log(`DWN endpoint: ${process.env.DWN_ENDPOINT || 'http://web5-dwn:3000'}`);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
app:
|
||||||
|
id: electrs-ui
|
||||||
|
name: Electrs UI
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
Archipelago-native HTTP frontend for electrs/electrumx status. Runs
|
||||||
|
nginx inside a container, serves static assets, and proxies
|
||||||
|
/electrs-status to the archipelago backend on 127.0.0.1:5678.
|
||||||
|
|
||||||
|
container:
|
||||||
|
build:
|
||||||
|
context: /opt/archipelago/docker/electrs-ui
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
tag: localhost/electrs-ui:local
|
||||||
|
|
||||||
|
dependencies: []
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 64Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: host
|
||||||
|
|
||||||
|
# Host networking: nginx listens on 50002 directly on the host IP.
|
||||||
|
ports: []
|
||||||
|
|
||||||
|
volumes: []
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://127.0.0.1:50002
|
||||||
|
path: /
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
app:
|
||||||
|
id: electrumx
|
||||||
|
name: ElectrumX
|
||||||
|
version: 1.18.0
|
||||||
|
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/electrumx:v1.18.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
data_uid: "1000:1000"
|
||||||
|
entrypoint: ["sh", "-lc"]
|
||||||
|
custom_args:
|
||||||
|
- >-
|
||||||
|
export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@bitcoin-knots:8332/";
|
||||||
|
exec electrumx_server
|
||||||
|
secret_env:
|
||||||
|
- key: BITCOIN_RPC_PASS
|
||||||
|
secret_file: bitcoin-rpc-password
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: bitcoin-knots
|
||||||
|
version: ">=26.0"
|
||||||
|
- storage: 50Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
cpu_limit: 0
|
||||||
|
memory_limit: 6Gi
|
||||||
|
disk_limit: 50Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [DAC_OVERRIDE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 50001
|
||||||
|
container: 50001
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/electrumx
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- COIN=Bitcoin
|
||||||
|
- DB_DIRECTORY=/data
|
||||||
|
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
|
||||||
|
- CACHE_MB=1024
|
||||||
|
- MAX_SEND=10000000
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:50001
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10m
|
||||||
|
|
||||||
|
bitcoin_integration:
|
||||||
|
rpc_access: read-only
|
||||||
|
sync_required: true
|
||||||
|
pruning_support: false
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
*.log
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
README.md
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci --only=production
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy built application
|
|
||||||
COPY --from=builder /app/dist ./dist
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/package.json ./
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN addgroup -g 1000 appuser && \
|
|
||||||
adduser -D -u 1000 -G appuser appuser && \
|
|
||||||
mkdir -p /app/data && \
|
|
||||||
chown -R appuser:appuser /app
|
|
||||||
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
ENV ENDURAIN_DATA_DIR=/app/data
|
|
||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
app:
|
|
||||||
id: endurain
|
|
||||||
name: Endurain
|
|
||||||
version: 1.0.0
|
|
||||||
description: Endurain application platform. Custom application runtime.
|
|
||||||
|
|
||||||
container:
|
|
||||||
image: archipelago/endurain:1.0.0
|
|
||||||
image_signature: cosign://...
|
|
||||||
pull_policy: if-not-present
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
- storage: 2Gi
|
|
||||||
|
|
||||||
resources:
|
|
||||||
cpu_limit: 2
|
|
||||||
memory_limit: 1Gi
|
|
||||||
disk_limit: 2Gi
|
|
||||||
|
|
||||||
security:
|
|
||||||
capabilities: []
|
|
||||||
readonly_root: true
|
|
||||||
no_new_privileges: true
|
|
||||||
user: 1000
|
|
||||||
seccomp_profile: default
|
|
||||||
network_policy: isolated
|
|
||||||
apparmor_profile: endurain
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- host: 8085
|
|
||||||
container: 8080
|
|
||||||
protocol: tcp # Web UI
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: /var/lib/archipelago/endurain
|
|
||||||
target: /app/data
|
|
||||||
options: [rw]
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- ENDURAIN_ENV=production
|
|
||||||
- ENDURAIN_DATA_DIR=/app/data
|
|
||||||
|
|
||||||
health_check:
|
|
||||||
type: http
|
|
||||||
endpoint: http://localhost:8085
|
|
||||||
path: /health
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
Generated
-1161
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "endurain",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Endurain application platform",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"dev": "ts-node src/index.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^4.18.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/node": "^20.10.0",
|
|
||||||
"typescript": "^5.3.3",
|
|
||||||
"ts-node": "^10.9.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const port = 8080;
|
|
||||||
|
|
||||||
// Middleware
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', service: 'endurain', version: '1.0.0' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// API endpoints
|
|
||||||
app.get('/api/info', (req, res) => {
|
|
||||||
res.json({
|
|
||||||
name: 'Endurain',
|
|
||||||
version: '1.0.0',
|
|
||||||
status: 'running'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
app.listen(port, '0.0.0.0', () => {
|
|
||||||
console.log(`Endurain listening on port ${port}`);
|
|
||||||
console.log(`Data directory: ${process.env.ENDURAIN_DATA_DIR || '/app/data'}`);
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"module": "commonjs",
|
|
||||||
"lib": ["ES2020"],
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
app:
|
||||||
|
id: fedimint-gateway
|
||||||
|
name: Fedimint Gateway
|
||||||
|
version: 0.10.0
|
||||||
|
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: git.tx1138.com/lfg2025/gatewayd:v0.10.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
entrypoint: ["sh", "-lc"]
|
||||||
|
custom_args:
|
||||||
|
- >-
|
||||||
|
if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then
|
||||||
|
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon;
|
||||||
|
else
|
||||||
|
exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url http://host.archipelago:8332 --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway;
|
||||||
|
fi
|
||||||
|
secret_env:
|
||||||
|
- key: FM_BITCOIND_PASSWORD
|
||||||
|
secret_file: bitcoin-rpc-password
|
||||||
|
- key: FEDI_HASH
|
||||||
|
secret_file: fedimint-gateway-hash
|
||||||
|
data_uid: "1000:1000"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: bitcoin-core
|
||||||
|
version: ">=26.0"
|
||||||
|
- app_id: fedimint
|
||||||
|
version: ">=0.10.0"
|
||||||
|
|
||||||
|
resources:
|
||||||
|
cpu_limit: 2
|
||||||
|
memory_limit: 2Gi
|
||||||
|
disk_limit: 10Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: true
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 8176
|
||||||
|
container: 8176
|
||||||
|
protocol: tcp
|
||||||
|
- host: 9737
|
||||||
|
container: 9737
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/fedimint-gateway
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/lnd
|
||||||
|
target: /lnd
|
||||||
|
options: [ro]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- FM_BITCOIND_USERNAME=archipelago
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://localhost:8176
|
||||||
|
path: /
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
bitcoin_integration:
|
||||||
|
rpc_access: admin
|
||||||
|
sync_required: true
|
||||||
+50
-25
@@ -3,56 +3,72 @@ app:
|
|||||||
name: Fedimint
|
name: Fedimint
|
||||||
version: 0.10.0
|
version: 0.10.0
|
||||||
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
|
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: fedimint/fedimintd:v0.10.0
|
image: 146.59.87.168:3000/lfg2025/fedimintd:v0.10.0
|
||||||
image_signature: cosign://...
|
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
entrypoint: ["sh", "-lc"]
|
||||||
|
custom_args:
|
||||||
|
- |-
|
||||||
|
until state="$(curl -sS --connect-timeout 5 -m 45 -u "$FM_BITCOIND_USERNAME:$FM_BITCOIND_PASSWORD" -H "Content-Type: application/json" --data-binary '{"jsonrpc":"1.0","id":"fedimint-wait","method":"getblockchaininfo","params":[]}' "$FM_BITCOIND_URL/")" && echo "$state" | grep -q '"initialblockdownload":false'; do
|
||||||
|
echo "Waiting for Bitcoin RPC sync at $FM_BITCOIND_URL...";
|
||||||
|
sleep 30;
|
||||||
|
done;
|
||||||
|
exec fedimintd
|
||||||
|
derived_env:
|
||||||
|
- key: FM_P2P_URL
|
||||||
|
template: fedimint://{{HOST_MDNS}}:8173
|
||||||
|
- key: FM_API_URL
|
||||||
|
template: ws://{{HOST_MDNS}}:8174
|
||||||
|
secret_env:
|
||||||
|
- key: FM_BITCOIND_PASSWORD
|
||||||
|
secret_file: bitcoin-rpc-password
|
||||||
|
data_uid: "1000:1000"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- app_id: bitcoin-core
|
- app_id: bitcoin-core
|
||||||
version: ">=24.0"
|
version: ">=26.0"
|
||||||
- storage: 20Gi
|
- storage: 20Gi
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
cpu_limit: 4
|
cpu_limit: 4
|
||||||
memory_limit: 4Gi
|
memory_limit: 4Gi
|
||||||
disk_limit: 20Gi
|
disk_limit: 20Gi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
capabilities: []
|
capabilities: []
|
||||||
readonly_root: true
|
readonly_root: true
|
||||||
no_new_privileges: true
|
|
||||||
user: 1000
|
|
||||||
seccomp_profile: default
|
|
||||||
network_policy: isolated
|
network_policy: isolated
|
||||||
apparmor_profile: fedimint
|
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- host: 8173
|
- host: 8173
|
||||||
container: 8173
|
container: 8173
|
||||||
protocol: tcp # P2P
|
protocol: tcp
|
||||||
- host: 8174
|
- host: 8174
|
||||||
container: 8174
|
container: 8174
|
||||||
protocol: tcp # API
|
protocol: tcp
|
||||||
- host: 8175
|
# Public launch port 8175 is owned by archy-fedimint-ui, which serves a
|
||||||
|
# wait page while Bitcoin syncs and proxies here after fedimintd starts.
|
||||||
|
- host: 8177
|
||||||
container: 8175
|
container: 8175
|
||||||
protocol: tcp # Built-in Guardian UI
|
protocol: tcp
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /var/lib/archipelago/fedimint
|
source: /var/lib/archipelago/fedimint
|
||||||
target: /fedimint
|
target: /data
|
||||||
options: [rw]
|
options: [rw]
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- FM_DATA_DIR=/fedimint
|
- FM_DATA_DIR=/data
|
||||||
- FM_BITCOIND_URL=http://bitcoin-core:8332
|
- FM_BITCOIND_URL=http://bitcoin-knots:8332
|
||||||
- FM_BITCOIND_USERNAME=${BITCOIN_RPC_USER}
|
- FM_BITCOIND_USERNAME=archipelago
|
||||||
- FM_BITCOIND_PASSWORD=${BITCOIN_RPC_PASSWORD}
|
|
||||||
- FM_BITCOIN_NETWORK=bitcoin
|
- FM_BITCOIN_NETWORK=bitcoin
|
||||||
|
- FM_BIND_P2P=0.0.0.0:8173
|
||||||
|
- FM_BIND_API=0.0.0.0:8174
|
||||||
- FM_BIND_UI=0.0.0.0:8175
|
- FM_BIND_UI=0.0.0.0:8175
|
||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: http
|
||||||
endpoint: http://localhost:8175
|
endpoint: http://localhost:8175
|
||||||
@@ -60,7 +76,16 @@ app:
|
|||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Guardian UI
|
||||||
|
description: Fedimint Guardian wait/proxy UI
|
||||||
|
type: ui
|
||||||
|
port: 8175
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
bitcoin_integration:
|
bitcoin_integration:
|
||||||
rpc_access: admin
|
rpc_access: admin
|
||||||
sync_required: true
|
sync_required: true
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
app:
|
||||||
|
id: filebrowser
|
||||||
|
name: File Browser
|
||||||
|
version: 2.27.0
|
||||||
|
description: Baseline Archipelago file manager service.
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: git.tx1138.com/lfg2025/filebrowser:v2.27.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
custom_args: ["--config", "/data/.filebrowser.json"]
|
||||||
|
data_uid: "100000:100000"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 10Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 256Mi
|
||||||
|
disk_limit: 10Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 8083
|
||||||
|
container: 80
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/filebrowser
|
||||||
|
target: /srv
|
||||||
|
options: [rw]
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/filebrowser-data
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://localhost:80
|
||||||
|
path: /health
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
bitcoin_integration:
|
||||||
|
rpc_access: none
|
||||||
|
sync_required: false
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
app:
|
||||||
|
id: gitea
|
||||||
|
name: Gitea
|
||||||
|
version: "1.23"
|
||||||
|
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
|
||||||
|
category: development
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: docker.io/gitea/gitea:1.23
|
||||||
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 500Mi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 256Mi
|
||||||
|
disk_limit: 500Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||||
|
readonly_root: false
|
||||||
|
no_new_privileges: false
|
||||||
|
network_policy: bridge
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 3001
|
||||||
|
container: 3000
|
||||||
|
protocol: tcp
|
||||||
|
- host: 2222
|
||||||
|
container: 22
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/gitea/data
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/gitea/config
|
||||||
|
target: /etc/gitea
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://localhost:3000
|
||||||
|
path: /
|
||||||
|
interval: 120s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Gitea web interface
|
||||||
|
type: ui
|
||||||
|
port: 3001
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
icon: /assets/img/app-icons/gitea.svg
|
||||||
|
repo: https://gitea.com
|
||||||
|
tier: optional
|
||||||
|
launch:
|
||||||
|
open_in_new_tab: true
|
||||||
|
features:
|
||||||
|
- Git repositories with web UI
|
||||||
|
- Built-in container/package registry
|
||||||
|
- Issue tracking and pull requests
|
||||||
|
- CI/CD via Gitea Actions
|
||||||
|
- Lightweight SQLite deployment
|
||||||
|
|
||||||
|
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
|
||||||
@@ -8,6 +8,7 @@ app:
|
|||||||
image: grafana/grafana:10.2.0
|
image: grafana/grafana:10.2.0
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
data_uid: "472:472"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- storage: 5Gi
|
- storage: 5Gi
|
||||||
@@ -27,7 +28,7 @@ app:
|
|||||||
apparmor_profile: grafana
|
apparmor_profile: grafana
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- host: 3001
|
- host: 3000
|
||||||
container: 3000
|
container: 3000
|
||||||
protocol: tcp # Web UI
|
protocol: tcp # Web UI
|
||||||
|
|
||||||
@@ -40,13 +41,17 @@ app:
|
|||||||
environment:
|
environment:
|
||||||
- GF_SECURITY_ADMIN_USER=admin
|
- GF_SECURITY_ADMIN_USER=admin
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
|
||||||
- GF_SERVER_ROOT_URL=http://localhost:3001
|
- GF_SERVER_ROOT_URL=http://localhost:3000
|
||||||
- GF_INSTALL_PLUGINS=
|
- GF_INSTALL_PLUGINS=
|
||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: http
|
||||||
endpoint: http://localhost:3001
|
endpoint: http://localhost:3000
|
||||||
path: /api/health
|
path: /api/health
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 30s
|
||||||
retries: 3
|
retries: 5
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
launch:
|
||||||
|
open_in_new_tab: true
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
app:
|
app:
|
||||||
id: home-assistant
|
id: homeassistant
|
||||||
name: Home Assistant
|
name: Home Assistant
|
||||||
version: 2024.1.0
|
version: 2024.1.0
|
||||||
description: Open source home automation platform. Control and monitor your smart home devices.
|
description: Open source home automation platform. Control and monitor your smart home devices.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: homeassistant/home-assistant:2024.1
|
image: 146.59.87.168:3000/lfg2025/home-assistant:2024.1
|
||||||
image_signature: cosign://...
|
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
network: pasta
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- storage: 10Gi
|
- storage: 10Gi
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
cpu_limit: 2
|
cpu_limit: 2
|
||||||
memory_limit: 2Gi
|
memory_limit: 512Mi
|
||||||
disk_limit: 10Gi
|
disk_limit: 10Gi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
capabilities: [NET_BIND_SERVICE]
|
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE, NET_RAW]
|
||||||
readonly_root: false # Home Assistant needs write access
|
readonly_root: false # Home Assistant needs write access
|
||||||
no_new_privileges: true
|
no_new_privileges: true
|
||||||
user: 1000
|
user: 1000
|
||||||
seccomp_profile: default
|
seccomp_profile: default
|
||||||
network_policy: host # Requires host network for device discovery
|
network_policy: isolated
|
||||||
apparmor_profile: home-assistant
|
apparmor_profile: home-assistant
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
@@ -36,24 +36,32 @@ app:
|
|||||||
source: /var/lib/archipelago/home-assistant
|
source: /var/lib/archipelago/home-assistant
|
||||||
target: /config
|
target: /config
|
||||||
options: [rw]
|
options: [rw]
|
||||||
- type: bind
|
|
||||||
source: /var/run/dbus
|
|
||||||
target: /var/run/dbus
|
|
||||||
options: [ro]
|
|
||||||
|
|
||||||
devices:
|
devices: []
|
||||||
- /dev/ttyUSB0 # Serial devices
|
|
||||||
- /dev/ttyACM0 # USB devices
|
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- TZ=UTC
|
- TZ=UTC
|
||||||
- PUID=1000
|
|
||||||
- PGID=1000
|
|
||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: tcp
|
||||||
endpoint: http://localhost:8123
|
endpoint: localhost:8123
|
||||||
path: /api/
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Home Assistant dashboard
|
||||||
|
type: ui
|
||||||
|
port: 8123
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
icon: /assets/img/app-icons/homeassistant.png
|
||||||
|
category: home
|
||||||
|
author: Home Assistant
|
||||||
|
repo: https://github.com/home-assistant/core
|
||||||
|
launch:
|
||||||
|
open_in_new_tab: true
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
app:
|
app:
|
||||||
id: indeedhub
|
id: indeedhub
|
||||||
name: Indeehub
|
name: IndeeHub
|
||||||
version: 0.1.0
|
version: 1.0.0
|
||||||
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
|
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
|
||||||
category: media
|
category: community
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: git.tx1138.com/lfg2025/indeedhub:latest
|
image: 146.59.87.168:3000/lfg2025/indeedhub:1.0.0
|
||||||
pull_policy: always # Pull from registry; falls back to local build
|
pull_policy: always # Pull from registry; falls back to local build
|
||||||
|
network: indeedhub-net
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- storage: 1Gi
|
- storage: 1Gi
|
||||||
@@ -27,9 +28,9 @@ app:
|
|||||||
apparmor_profile: default
|
apparmor_profile: default
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- host: 7777
|
- host: 7778
|
||||||
container: 3000
|
container: 7777
|
||||||
protocol: tcp # Web UI (Next.js)
|
protocol: tcp # Web UI. Port 7777 on the host is reserved for Nostr relay.
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- type: tmpfs
|
- type: tmpfs
|
||||||
@@ -38,6 +39,12 @@ app:
|
|||||||
- type: tmpfs
|
- type: tmpfs
|
||||||
target: /app/.next/cache
|
target: /app/.next/cache
|
||||||
options: [rw,noexec,nosuid,size=128m]
|
options: [rw,noexec,nosuid,size=128m]
|
||||||
|
- type: tmpfs
|
||||||
|
target: /run
|
||||||
|
options: [rw,nosuid,nodev,size=16m]
|
||||||
|
- type: tmpfs
|
||||||
|
target: /var/cache/nginx
|
||||||
|
options: [rw,nosuid,nodev,size=32m]
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
@@ -57,14 +64,15 @@ app:
|
|||||||
name: Web UI
|
name: Web UI
|
||||||
description: Stream Bitcoin documentaries with Nostr identity
|
description: Stream Bitcoin documentaries with Nostr identity
|
||||||
type: ui
|
type: ui
|
||||||
port: 7777
|
port: 7778
|
||||||
protocol: http
|
protocol: http
|
||||||
path: /
|
path: /
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
author: Indeehub Team
|
author: Indeehub Team
|
||||||
|
icon: /assets/img/app-icons/indeedhub.png
|
||||||
website: https://indeedhub.com
|
website: https://indeedhub.com
|
||||||
source: https://github.com/indeedhub/indeedhub
|
repo: https://github.com/indeedhub/indeedhub
|
||||||
license: MIT
|
license: MIT
|
||||||
tags:
|
tags:
|
||||||
- bitcoin
|
- bitcoin
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
app:
|
||||||
|
id: jellyfin
|
||||||
|
name: Jellyfin
|
||||||
|
version: 10.8.13
|
||||||
|
description: Free media server. Stream movies, music, and photos.
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/jellyfin:10.8.13
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: pasta
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 10Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 1Gi
|
||||||
|
disk_limit: 10Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 8096
|
||||||
|
container: 8096
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/jellyfin/config
|
||||||
|
target: /config
|
||||||
|
options: [rw]
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/jellyfin/cache
|
||||||
|
target: /cache
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:8096
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Jellyfin media dashboard
|
||||||
|
type: ui
|
||||||
|
port: 8096
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
icon: /assets/img/app-icons/jellyfin.webp
|
||||||
|
category: data
|
||||||
|
author: Jellyfin
|
||||||
|
repo: https://github.com/jellyfin/jellyfin
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
app:
|
||||||
|
id: lnd-ui
|
||||||
|
name: LND UI
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
Archipelago-native HTTP frontend for LND. Runs nginx inside a
|
||||||
|
container and serves static assets. LND connection info is fetched
|
||||||
|
via an absolute URL that the host nginx routes to the archipelago
|
||||||
|
backend on 127.0.0.1:5678, so no upstream auth is baked in.
|
||||||
|
|
||||||
|
container:
|
||||||
|
build:
|
||||||
|
context: /opt/archipelago/docker/lnd-ui
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
tag: localhost/lnd-ui:local
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: lnd
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 64Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: bridge
|
||||||
|
|
||||||
|
# Bridge networking via archy-net. Container nginx listens on 80;
|
||||||
|
# host nginx proxies /app/lnd/ -> 127.0.0.1:18083 -> container:80.
|
||||||
|
ports:
|
||||||
|
- host: 18083
|
||||||
|
container: 80
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes: []
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://127.0.0.1:18083
|
||||||
|
path: /
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
+29
-31
@@ -1,67 +1,65 @@
|
|||||||
app:
|
app:
|
||||||
id: lnd
|
id: lnd
|
||||||
name: Lightning Network Daemon
|
name: LND
|
||||||
version: 0.18.0
|
version: 0.18.4
|
||||||
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
|
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: lightninglabs/lnd:v0.18.0
|
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
|
||||||
image_signature: cosign://...
|
pull_policy: if-not-present
|
||||||
pull_policy: verify-signature
|
network: archy-net
|
||||||
|
secret_env:
|
||||||
|
- key: BITCOIND_RPCPASS
|
||||||
|
secret_file: bitcoin-rpc-password
|
||||||
|
data_uid: "100000:100000"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- app_id: bitcoin-core
|
- app_id: bitcoin-core
|
||||||
version: ">=26.0"
|
version: ">=26.0"
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
cpu_limit: 2
|
cpu_limit: 2
|
||||||
memory_limit: 1Gi
|
memory_limit: 1Gi
|
||||||
disk_limit: 10Gi
|
disk_limit: 10Gi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
capabilities: [NET_BIND_SERVICE]
|
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_RAW]
|
||||||
readonly_root: true
|
readonly_root: false
|
||||||
no_new_privileges: true
|
|
||||||
user: 1000
|
|
||||||
seccomp_profile: default
|
|
||||||
network_policy: isolated
|
network_policy: isolated
|
||||||
apparmor_profile: lnd
|
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- host: 9735
|
- host: 9735
|
||||||
container: 9735
|
container: 9735
|
||||||
protocol: tcp # P2P
|
protocol: tcp
|
||||||
- host: 10009
|
- host: 10009
|
||||||
container: 10009
|
container: 10009
|
||||||
protocol: tcp # gRPC
|
protocol: tcp
|
||||||
- host: 8080
|
- host: 18080
|
||||||
container: 8080
|
container: 8080
|
||||||
protocol: tcp # REST
|
protocol: tcp
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /var/lib/archipelago/lnd
|
source: /var/lib/archipelago/lnd
|
||||||
target: /root/.lnd
|
target: /root/.lnd
|
||||||
options: [rw]
|
options: [rw]
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- BITCOIND_HOST=bitcoin-core
|
- BITCOIND_HOST=bitcoin-knots
|
||||||
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
|
- BITCOIND_RPCUSER=archipelago
|
||||||
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
|
|
||||||
- NETWORK=mainnet
|
- NETWORK=mainnet
|
||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: tcp
|
||||||
endpoint: http://localhost:8080
|
endpoint: localhost:10009
|
||||||
path: /v1/getinfo
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
bitcoin_integration:
|
bitcoin_integration:
|
||||||
rpc_access: admin
|
rpc_access: admin
|
||||||
sync_required: true
|
sync_required: true
|
||||||
|
|
||||||
lightning_integration:
|
lightning_integration:
|
||||||
channel_management: true
|
channel_management: true
|
||||||
payment_routing: true
|
payment_routing: true
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
app:
|
||||||
|
id: mempool-api
|
||||||
|
name: Mempool API
|
||||||
|
version: 3.0.0
|
||||||
|
description: Backend API for mempool explorer.
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: git.tx1138.com/lfg2025/mempool-backend:v3.0.0
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: archy-net
|
||||||
|
secret_env:
|
||||||
|
- key: CORE_RPC_PASSWORD
|
||||||
|
secret_file: bitcoin-rpc-password
|
||||||
|
- key: DATABASE_PASSWORD
|
||||||
|
secret_file: mempool-db-password
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: bitcoin-knots
|
||||||
|
version: ">=26.0"
|
||||||
|
- app_id: electrumx
|
||||||
|
version: ">=1.18.0"
|
||||||
|
- app_id: archy-mempool-db
|
||||||
|
version: ">=11.4.10"
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 2Gi
|
||||||
|
disk_limit: 20Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 8999
|
||||||
|
container: 8999
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/mempool
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- MEMPOOL_BACKEND=electrum
|
||||||
|
- ELECTRUM_HOST=electrumx
|
||||||
|
- ELECTRUM_PORT=50001
|
||||||
|
- ELECTRUM_TLS_ENABLED=false
|
||||||
|
- CORE_RPC_HOST=bitcoin-knots
|
||||||
|
- CORE_RPC_PORT=8332
|
||||||
|
- CORE_RPC_USERNAME=archipelago
|
||||||
|
- DATABASE_ENABLED=true
|
||||||
|
- DATABASE_HOST=archy-mempool-db
|
||||||
|
- DATABASE_DATABASE=mempool
|
||||||
|
- DATABASE_USERNAME=mempool
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://localhost:8999
|
||||||
|
path: /api/v1/backend-info
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
bitcoin_integration:
|
||||||
|
rpc_access: read-only
|
||||||
|
sync_required: true
|
||||||
|
pruning_support: false
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
app:
|
app:
|
||||||
id: mempool
|
id: mempool
|
||||||
name: Mempool
|
name: Mempool Explorer
|
||||||
version: 2.5.0
|
version: 3.0.0
|
||||||
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: mempool/mempool:v2.5.0
|
image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
app:
|
app:
|
||||||
id: meshtastic
|
id: meshtastic
|
||||||
name: Meshtastic
|
name: Meshtastic
|
||||||
version: 2.5.0
|
version: 2-daily-alpine
|
||||||
description: Open-source mesh networking for LoRa radios. Create decentralized communication networks.
|
description: Open-source mesh networking for LoRa radios. Create decentralized communication networks.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: meshtastic/meshtasticd:2.5.6
|
image: docker.io/meshtastic/meshtasticd:daily-alpine
|
||||||
image_signature: cosign://...
|
pull_policy: if-not-present
|
||||||
pull_policy: verify-signature
|
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- storage: 1Gi
|
- storage: 1Gi
|
||||||
@@ -29,33 +28,42 @@ app:
|
|||||||
ports:
|
ports:
|
||||||
- host: 4403
|
- host: 4403
|
||||||
container: 4403
|
container: 4403
|
||||||
protocol: tcp # HTTP API
|
protocol: tcp # Meshtastic TCP API
|
||||||
- host: 1883
|
|
||||||
container: 1883
|
|
||||||
protocol: tcp # MQTT (optional)
|
|
||||||
|
|
||||||
devices:
|
devices:
|
||||||
- /dev/ttyUSB0 # LoRa radio device (if connected)
|
- /dev/ttyUSB0 # LoRa radio device (if connected)
|
||||||
- /dev/ttyACM0 # Alternative device path
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /var/lib/archipelago/meshtastic
|
source: /var/lib/archipelago/meshtastic
|
||||||
target: /app/data
|
target: /var/lib/meshtasticd
|
||||||
options: [rw]
|
options: [rw]
|
||||||
|
|
||||||
|
files:
|
||||||
|
- path: /var/lib/archipelago/meshtastic/config.yaml
|
||||||
|
content: |
|
||||||
|
General:
|
||||||
|
MACAddress: AA:BB:CC:DD:EE:01
|
||||||
|
Webserver:
|
||||||
|
Port: 4403
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- MESHTASTIC_PORT=/dev/ttyUSB0
|
- MESHTASTIC_PORT=/dev/ttyUSB0
|
||||||
- MESHTASTIC_SERIAL=true
|
- MESHTASTIC_SERIAL=true
|
||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: cmd
|
||||||
endpoint: http://localhost:4403
|
endpoint: test -f /var/lib/meshtasticd/config.yaml
|
||||||
path: /health
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 30s
|
||||||
retries: 3
|
retries: 5
|
||||||
|
|
||||||
networking:
|
networking:
|
||||||
mesh_enabled: true
|
mesh_enabled: true
|
||||||
local_network_access: true
|
local_network_access: true
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
icon: /assets/img/app-icons/meshcore.svg
|
||||||
|
category: networking
|
||||||
|
tier: recommended
|
||||||
|
repo: https://github.com/meshtastic/firmware
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
app:
|
||||||
|
id: nextcloud
|
||||||
|
name: Nextcloud
|
||||||
|
version: "29"
|
||||||
|
description: Your own private cloud. File sync, calendars, contacts.
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/nextcloud:29
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: pasta
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 10Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 1Gi
|
||||||
|
disk_limit: 10Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 8085
|
||||||
|
container: 80
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/nextcloud
|
||||||
|
target: /var/www/html
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:80
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Nextcloud file and collaboration dashboard
|
||||||
|
type: ui
|
||||||
|
port: 8085
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
icon: /assets/img/app-icons/nextcloud.webp
|
||||||
|
category: data
|
||||||
|
author: Nextcloud
|
||||||
|
repo: https://github.com/nextcloud/server
|
||||||
|
launch:
|
||||||
|
open_in_new_tab: true
|
||||||
@@ -8,6 +8,7 @@ app:
|
|||||||
image: scsibug/nostr-rs-relay:0.8.9
|
image: scsibug/nostr-rs-relay:0.8.9
|
||||||
image_signature: cosign://...
|
image_signature: cosign://...
|
||||||
pull_policy: verify-signature
|
pull_policy: verify-signature
|
||||||
|
data_uid: "1000:1000"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- storage: 10Gi # For event storage
|
- storage: 10Gi # For event storage
|
||||||
@@ -27,14 +28,14 @@ app:
|
|||||||
apparmor_profile: nostr-relay
|
apparmor_profile: nostr-relay
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- host: 8081
|
- host: 18081
|
||||||
container: 8080
|
container: 8080
|
||||||
protocol: tcp # HTTP/WebSocket
|
protocol: tcp # HTTP/WebSocket
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /var/lib/archipelago/nostr-relay
|
source: /var/lib/archipelago/nostr-relay
|
||||||
target: /app/db
|
target: /usr/src/app/db
|
||||||
options: [rw]
|
options: [rw]
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
@@ -45,11 +46,11 @@ app:
|
|||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: http
|
||||||
endpoint: http://localhost:8081
|
endpoint: http://localhost:8080
|
||||||
path: /health
|
path: /
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 30s
|
||||||
retries: 3
|
retries: 5
|
||||||
|
|
||||||
nostr_integration:
|
nostr_integration:
|
||||||
relay_type: public
|
relay_type: public
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# Ollama - uses official image
|
|
||||||
FROM ollama/ollama:latest
|
|
||||||
|
|
||||||
# Default configuration is in the image
|
|
||||||
# No additional setup needed
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
app:
|
|
||||||
id: ollama
|
|
||||||
name: Ollama
|
|
||||||
version: 0.1.0
|
|
||||||
description: Run large language models locally. Privacy-preserving AI on your node.
|
|
||||||
|
|
||||||
container:
|
|
||||||
image: ollama/ollama:0.6.2
|
|
||||||
image_signature: cosign://...
|
|
||||||
pull_policy: if-not-present
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
- storage: 50Gi # Models can be large
|
|
||||||
|
|
||||||
resources:
|
|
||||||
cpu_limit: 4
|
|
||||||
memory_limit: 8Gi # LLMs need lots of RAM
|
|
||||||
disk_limit: 50Gi
|
|
||||||
|
|
||||||
security:
|
|
||||||
capabilities: []
|
|
||||||
readonly_root: false # Ollama needs write access for models
|
|
||||||
no_new_privileges: true
|
|
||||||
user: 1000
|
|
||||||
seccomp_profile: default
|
|
||||||
network_policy: isolated
|
|
||||||
apparmor_profile: ollama
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- host: 11434
|
|
||||||
container: 11434
|
|
||||||
protocol: tcp # API
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: /var/lib/archipelago/ollama
|
|
||||||
target: /root/.ollama
|
|
||||||
options: [rw]
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- OLLAMA_HOST=0.0.0.0:11434
|
|
||||||
- OLLAMA_KEEP_ALIVE=24h
|
|
||||||
|
|
||||||
health_check:
|
|
||||||
type: http
|
|
||||||
endpoint: http://localhost:11434
|
|
||||||
path: /api/tags
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# OnlyOffice - uses official image
|
|
||||||
FROM onlyoffice/documentserver:7.5.0
|
|
||||||
|
|
||||||
# Default configuration is in the image
|
|
||||||
# No additional setup needed
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
app:
|
|
||||||
id: onlyoffice
|
|
||||||
name: OnlyOffice
|
|
||||||
version: 7.5.0
|
|
||||||
description: Office suite and document collaboration. Edit documents, spreadsheets, and presentations.
|
|
||||||
|
|
||||||
container:
|
|
||||||
image: onlyoffice/documentserver:7.5.0
|
|
||||||
image_signature: cosign://...
|
|
||||||
pull_policy: if-not-present
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
- storage: 10Gi
|
|
||||||
|
|
||||||
resources:
|
|
||||||
cpu_limit: 4
|
|
||||||
memory_limit: 4Gi
|
|
||||||
disk_limit: 10Gi
|
|
||||||
|
|
||||||
security:
|
|
||||||
capabilities: []
|
|
||||||
readonly_root: false # OnlyOffice needs write access
|
|
||||||
no_new_privileges: true
|
|
||||||
user: 1000
|
|
||||||
seccomp_profile: default
|
|
||||||
network_policy: isolated
|
|
||||||
apparmor_profile: onlyoffice
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- host: 8088
|
|
||||||
container: 80
|
|
||||||
protocol: tcp # Web UI
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: /var/lib/archipelago/onlyoffice
|
|
||||||
target: /var/www/onlyoffice/Data
|
|
||||||
options: [rw]
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- JWT_ENABLED=false
|
|
||||||
- JWT_SECRET=${ONLYOFFICE_JWT_SECRET}
|
|
||||||
|
|
||||||
health_check:
|
|
||||||
type: http
|
|
||||||
endpoint: http://localhost:8088
|
|
||||||
path: /healthcheck
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Penpot - uses official image
|
|
||||||
FROM penpot/penpot:latest
|
|
||||||
|
|
||||||
# Default configuration is in the image
|
|
||||||
# No additional setup needed
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
app:
|
|
||||||
id: penpot
|
|
||||||
name: Penpot
|
|
||||||
version: 2.0.0
|
|
||||||
description: Open-source design and prototyping platform. Design tools for teams.
|
|
||||||
|
|
||||||
container:
|
|
||||||
image: penpotapp/frontend:2.13.3
|
|
||||||
image_signature: cosign://...
|
|
||||||
pull_policy: if-not-present
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
- storage: 10Gi
|
|
||||||
|
|
||||||
resources:
|
|
||||||
cpu_limit: 4
|
|
||||||
memory_limit: 4Gi
|
|
||||||
disk_limit: 10Gi
|
|
||||||
|
|
||||||
security:
|
|
||||||
capabilities: []
|
|
||||||
readonly_root: true
|
|
||||||
no_new_privileges: true
|
|
||||||
user: 1000
|
|
||||||
seccomp_profile: default
|
|
||||||
network_policy: isolated
|
|
||||||
apparmor_profile: penpot
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- host: 8089
|
|
||||||
container: 80
|
|
||||||
protocol: tcp # Web UI
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: /var/lib/archipelago/penpot
|
|
||||||
target: /app/data
|
|
||||||
options: [rw]
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- PENPOT_PUBLIC_URI=http://localhost:8089
|
|
||||||
- PENPOT_DATABASE_URI=postgresql://penpot:penpot@penpot-db:5432/penpot
|
|
||||||
- PENPOT_REDIS_URI=redis://penpot-redis:6379
|
|
||||||
|
|
||||||
health_check:
|
|
||||||
type: http
|
|
||||||
endpoint: http://localhost:8089
|
|
||||||
path: /api/health
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
app:
|
||||||
|
id: photoprism
|
||||||
|
name: PhotoPrism
|
||||||
|
version: "240915"
|
||||||
|
description: AI-powered photo management with facial recognition.
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/photoprism:240915
|
||||||
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 10Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 1Gi
|
||||||
|
disk_limit: 10Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, SETUID, SETGID]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 2342
|
||||||
|
container: 2342
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/photoprism
|
||||||
|
target: /photoprism/storage
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- PHOTOPRISM_ADMIN_PASSWORD=archipelago
|
||||||
|
- PHOTOPRISM_DEFAULT_LOCALE=en
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:2342
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: PhotoPrism photo library
|
||||||
|
type: ui
|
||||||
|
port: 2342
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
icon: /assets/img/app-icons/photoprism.svg
|
||||||
|
category: data
|
||||||
|
author: PhotoPrism
|
||||||
|
repo: https://github.com/photoprism/photoprism
|
||||||
|
launch:
|
||||||
|
open_in_new_tab: true
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
app:
|
||||||
|
id: portainer
|
||||||
|
name: Portainer
|
||||||
|
version: 2.19.4
|
||||||
|
description: Container management web UI for the local Podman socket.
|
||||||
|
category: development
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
|
||||||
|
pull_policy: if-not-present
|
||||||
|
data_uid: "1000:1000"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 1Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 256Mi
|
||||||
|
disk_limit: 1Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE]
|
||||||
|
readonly_root: false
|
||||||
|
no_new_privileges: true
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 9000
|
||||||
|
container: 9000
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/portainer
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/portainer/compose
|
||||||
|
target: /data/compose
|
||||||
|
options: [rw]
|
||||||
|
- type: bind
|
||||||
|
source: /run/user/1000/podman/podman.sock
|
||||||
|
target: /var/run/docker.sock
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Portainer web interface
|
||||||
|
type: ui
|
||||||
|
port: 9000
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
icon: /assets/img/app-icons/portainer.webp
|
||||||
|
tier: optional
|
||||||
|
launch:
|
||||||
|
open_in_new_tab: true
|
||||||
|
features:
|
||||||
|
- Container management dashboard
|
||||||
|
- Local Podman socket access
|
||||||
|
- Compose stack storage
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
app:
|
app:
|
||||||
id: searxng
|
id: searxng
|
||||||
name: SearXNG
|
name: SearXNG
|
||||||
version: 2024.1.0
|
version: 1.0.0
|
||||||
description: Privacy-respecting metasearch engine. Search the web without tracking.
|
description: Privacy-respecting metasearch engine. Search the web without tracking.
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: searxng/searxng:2024.1.0
|
image: 146.59.87.168:3000/lfg2025/searxng:latest
|
||||||
image_signature: cosign://...
|
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -43,8 +42,8 @@ app:
|
|||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: http
|
||||||
endpoint: http://localhost:8888
|
endpoint: http://localhost:8080
|
||||||
path: /
|
path: /
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 30s
|
||||||
retries: 3
|
retries: 5
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
app:
|
||||||
|
id: uptime-kuma
|
||||||
|
name: Uptime Kuma
|
||||||
|
version: 1.23.0
|
||||||
|
description: Self-hosted uptime monitoring.
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/uptime-kuma:1
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: pasta
|
||||||
|
custom_args: ["--", "node", "server/server.js"]
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 1Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 256Mi
|
||||||
|
disk_limit: 1Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, FOWNER, SETUID, SETGID]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 3002
|
||||||
|
container: 3001
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/uptime-kuma
|
||||||
|
target: /app/data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- TZ=UTC
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: localhost:3001
|
||||||
|
path: /
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
icon: /assets/img/app-icons/uptime-kuma.webp
|
||||||
|
category: data
|
||||||
|
tier: recommended
|
||||||
|
author: Uptime Kuma
|
||||||
|
repo: https://github.com/louislam/uptime-kuma
|
||||||
|
launch:
|
||||||
|
open_in_new_tab: true
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
app:
|
||||||
|
id: vaultwarden
|
||||||
|
name: Vaultwarden
|
||||||
|
version: 1.30.0
|
||||||
|
description: Self-hosted password vault with zero-knowledge encryption.
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: 146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine
|
||||||
|
pull_policy: if-not-present
|
||||||
|
network: pasta
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 1Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 256Mi
|
||||||
|
disk_limit: 1Gi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: [CHOWN, SETUID, SETGID, NET_BIND_SERVICE]
|
||||||
|
readonly_root: false
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 8082
|
||||||
|
container: 80
|
||||||
|
protocol: tcp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/vaultwarden
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment: []
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: tcp
|
||||||
|
endpoint: localhost:80
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Vaultwarden web vault
|
||||||
|
type: ui
|
||||||
|
port: 8082
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
icon: /assets/img/app-icons/vaultwarden.webp
|
||||||
|
category: data
|
||||||
|
tier: recommended
|
||||||
|
author: Vaultwarden
|
||||||
|
repo: https://github.com/dani-garcia/vaultwarden
|
||||||
|
launch:
|
||||||
|
open_in_new_tab: true
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
*.log
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
README.md
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy built application
|
|
||||||
COPY --from=builder /app/dist ./dist
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/package.json ./
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN addgroup -g 1000 appuser && \
|
|
||||||
adduser -D -u 1000 -G appuser appuser && \
|
|
||||||
mkdir -p /app/data && \
|
|
||||||
chown -R appuser:appuser /app
|
|
||||||
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
ENV DWN_STORAGE_PATH=/app/data
|
|
||||||
ENV DID_METHOD=key
|
|
||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Web5 DWN (Decentralized Web Node)
|
|
||||||
|
|
||||||
Personal data store for Web5. Store and sync your decentralized data across devices.
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From the apps directory
|
|
||||||
./build.sh web5-dwn
|
|
||||||
|
|
||||||
# Or manually
|
|
||||||
cd web5-dwn
|
|
||||||
docker build -t archipelago/web5-dwn:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web5-dwn
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
|
|
||||||
- **3000**: HTTP API (dev: 13000)
|
|
||||||
|
|
||||||
## Running Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -p 3000:3000 \
|
|
||||||
-v /tmp/archipelago-dev/web5-dwn:/app/data \
|
|
||||||
-e DWN_STORAGE_PATH=/app/data \
|
|
||||||
archipelago/web5-dwn:latest
|
|
||||||
```
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
app:
|
|
||||||
id: web5-dwn
|
|
||||||
name: Decentralized Web Node
|
|
||||||
version: 1.0.0
|
|
||||||
description: Personal data store for Web5. Store and sync your decentralized data across devices.
|
|
||||||
|
|
||||||
container:
|
|
||||||
image: archipelago/web5-dwn:1.0.0
|
|
||||||
image_signature: cosign://...
|
|
||||||
pull_policy: if-not-present
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
- storage: 5Gi
|
|
||||||
|
|
||||||
resources:
|
|
||||||
cpu_limit: 1
|
|
||||||
memory_limit: 512Mi
|
|
||||||
disk_limit: 5Gi
|
|
||||||
|
|
||||||
security:
|
|
||||||
capabilities: []
|
|
||||||
readonly_root: true
|
|
||||||
no_new_privileges: true
|
|
||||||
user: 1000
|
|
||||||
seccomp_profile: default
|
|
||||||
network_policy: isolated
|
|
||||||
apparmor_profile: web5-dwn
|
|
||||||
|
|
||||||
ports:
|
|
||||||
- host: 3000
|
|
||||||
container: 3000
|
|
||||||
protocol: tcp # HTTP API
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: /var/lib/archipelago/web5-dwn
|
|
||||||
target: /app/data
|
|
||||||
options: [rw]
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- DWN_STORAGE_PATH=/app/data
|
|
||||||
- DID_METHOD=key
|
|
||||||
|
|
||||||
health_check:
|
|
||||||
type: http
|
|
||||||
endpoint: http://localhost:3000
|
|
||||||
path: /health
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
web5_integration:
|
|
||||||
did_support: true
|
|
||||||
dwn_protocol: true
|
|
||||||
sync_enabled: true
|
|
||||||
Generated
-2747
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "web5-dwn",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Decentralized Web Node for Web5",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"dev": "ts-node src/index.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^4.18.2",
|
|
||||||
"@web5/api": "^0.9.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/node": "^20.10.0",
|
|
||||||
"typescript": "^5.3.3",
|
|
||||||
"ts-node": "^10.9.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const port = 3000;
|
|
||||||
|
|
||||||
// Middleware
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Health check endpoint
|
|
||||||
app.get('/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', service: 'web5-dwn' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// DWN API endpoints
|
|
||||||
app.post('/dwn', async (req, res) => {
|
|
||||||
// Placeholder for DWN protocol implementation
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
message: 'DWN protocol endpoint (placeholder)'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/dwn', async (req, res) => {
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
message: 'DWN query endpoint (placeholder)'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
app.listen(port, '0.0.0.0', () => {
|
|
||||||
console.log(`Web5 DWN listening on port ${port}`);
|
|
||||||
console.log(`Storage path: ${process.env.DWN_STORAGE_PATH || '/app/data'}`);
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"module": "commonjs",
|
|
||||||
"lib": ["ES2020"],
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
Generated
+2
-1
@@ -80,13 +80,14 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.3.1"
|
version = "1.7.86-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
"archipelago-performance",
|
"archipelago-performance",
|
||||||
"archipelago-security",
|
"archipelago-security",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
"async-trait",
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"bip39",
|
"bip39",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.3.1"
|
version = "1.7.87-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"]
|
||||||
@@ -103,6 +103,9 @@ mdns-sd = "0.18"
|
|||||||
# Systemd watchdog notification
|
# Systemd watchdog notification
|
||||||
sd-notify = "0.4"
|
sd-notify = "0.4"
|
||||||
|
|
||||||
|
# Trait objects for async methods (container orchestrator trait, Step 4)
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
tempfile = "3.10"
|
tempfile = "3.10"
|
||||||
|
|||||||
@@ -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,15 @@ 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::container::{ContainerOrchestrator, DevContainerOrchestrator};
|
||||||
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 +24,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 +44,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 {
|
||||||
@@ -43,6 +55,8 @@ impl ApiHandler {
|
|||||||
config: Config,
|
config: Config,
|
||||||
state_manager: Arc<StateManager>,
|
state_manager: Arc<StateManager>,
|
||||||
metrics_store: Arc<MetricsStore>,
|
metrics_store: Arc<MetricsStore>,
|
||||||
|
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
|
||||||
|
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let session_store = SessionStore::new().await;
|
let session_store = SessionStore::new().await;
|
||||||
let rpc_handler = Arc::new(
|
let rpc_handler = Arc::new(
|
||||||
@@ -51,11 +65,34 @@ impl ApiHandler {
|
|||||||
state_manager.clone(),
|
state_manager.clone(),
|
||||||
metrics_store.clone(),
|
metrics_store.clone(),
|
||||||
session_store.clone(),
|
session_store.clone(),
|
||||||
|
orchestrator,
|
||||||
|
dev_orchestrator,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
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 +100,8 @@ impl ApiHandler {
|
|||||||
metrics_store,
|
metrics_store,
|
||||||
session_store,
|
session_store,
|
||||||
input_relay_tx,
|
input_relay_tx,
|
||||||
|
blob_store,
|
||||||
|
self_pubkey_hex,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +118,79 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Server-side fetch of the upstream app catalog so the browser can
|
||||||
|
/// load it without fighting CORS (git.tx1138.com emits no ACAO) or
|
||||||
|
/// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream
|
||||||
|
/// list is derived from the operator's configured container registries
|
||||||
|
/// so switching mirrors in Settings changes the App Store source too —
|
||||||
|
/// each active registry contributes one Gitea `raw/branch/main/catalog.json`
|
||||||
|
/// URL (http or https per `tls_verify`), tried in priority order.
|
||||||
|
/// If registry config can't be loaded, falls back to the legacy
|
||||||
|
/// hardcoded pair so the App Store still renders on nodes that haven't
|
||||||
|
/// persisted a registry config yet. 15s total timeout.
|
||||||
|
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
|
||||||
|
let mut upstreams: Vec<String> = Vec::new();
|
||||||
|
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
|
||||||
|
{
|
||||||
|
for reg in config.active_registries() {
|
||||||
|
let scheme = if reg.tls_verify { "https" } else { "http" };
|
||||||
|
// Gitea raw URL: <scheme>://<host>/<namespace>/app-catalog/raw/branch/main/catalog.json.
|
||||||
|
// reg.url already includes the namespace (e.g. "host/lfg2025"),
|
||||||
|
// so we just tack on the repo + raw path.
|
||||||
|
upstreams.push(format!(
|
||||||
|
"{}://{}/app-catalog/raw/branch/main/catalog.json",
|
||||||
|
scheme, reg.url
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if upstreams.is_empty() {
|
||||||
|
upstreams.push(
|
||||||
|
"http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
upstreams.push(
|
||||||
|
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(build_response(
|
||||||
|
hyper::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from(format!("client build failed: {}", e)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for url in &upstreams {
|
||||||
|
match client.get(url).send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
if let Ok(bytes) = resp.bytes().await {
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(hyper::StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Cache-Control", "public, max-age=3600")
|
||||||
|
.body(hyper::Body::from(bytes))
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
Response::new(hyper::Body::from("proxy response build failed"))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(build_response(
|
||||||
|
hyper::StatusCode::BAD_GATEWAY,
|
||||||
|
"text/plain",
|
||||||
|
hyper::Body::from("all upstream catalog URLs failed"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a 401 Unauthorized JSON response.
|
/// Build a 401 Unauthorized JSON response.
|
||||||
fn unauthorized() -> Response<hyper::Body> {
|
fn unauthorized() -> Response<hyper::Body> {
|
||||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||||
@@ -105,9 +217,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 +226,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 +281,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 +309,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()));
|
||||||
|
|
||||||
@@ -176,7 +320,7 @@ impl ApiHandler {
|
|||||||
|
|
||||||
match (method, path.as_str()) {
|
match (method, path.as_str()) {
|
||||||
// RPC — auth is handled inside rpc handler per-method
|
// RPC — auth is handled inside rpc handler per-method
|
||||||
(Method::POST, "/rpc/v1") => self.rpc_handler.handle(req_with_bytes).await,
|
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
|
||||||
|
|
||||||
// Health — unauthenticated, returns JSON with service status
|
// Health — unauthenticated, returns JSON with service status
|
||||||
(Method::GET, "/health") => {
|
(Method::GET, "/health") => {
|
||||||
@@ -196,7 +340,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,18 +351,97 @@ 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,
|
||||||
|
(Method::GET, "/bitcoin-status") => Self::handle_bitcoin_status().await,
|
||||||
|
|
||||||
|
// App-catalog proxy — fetches catalog.json from the configured
|
||||||
|
// upstream URLs server-side so the browser doesn't hit CORS
|
||||||
|
// (git.tx1138.com has no ACAO header) or CSP (IP-port upstream
|
||||||
|
// falls outside `connect-src`). Session-authenticated so only
|
||||||
|
// the logged-in node owner can spin up fetches.
|
||||||
|
(Method::GET, "/api/app-catalog") => {
|
||||||
|
if !self.is_authenticated(&headers).await {
|
||||||
|
return Ok(Self::unauthorized());
|
||||||
|
}
|
||||||
|
self.handle_app_catalog_proxy().await
|
||||||
|
}
|
||||||
|
|
||||||
// LND connect info — nginx validates session cookie (presence check),
|
// LND connect info — nginx validates session cookie (presence check),
|
||||||
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
// backend is bound to 127.0.0.1 so only nginx can reach it.
|
||||||
@@ -245,14 +470,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 +487,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,168 @@ 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"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(handled) =
|
||||||
|
crate::api::rpc::bitcoin_relay::record_incoming_relay_message(
|
||||||
|
std::path::Path::new("/var/lib/archipelago"),
|
||||||
|
from,
|
||||||
|
incoming.from_name.as_deref(),
|
||||||
|
&val,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(format!(r#"{{"ok":true,"handled":"{}"}}"#, handled)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,13 @@
|
|||||||
|
use super::build_response;
|
||||||
|
use crate::api::rpc::lnd::LND_REST_BASE_URL;
|
||||||
use crate::api::rpc::RpcHandler;
|
use crate::api::rpc::RpcHandler;
|
||||||
use super::build_response;use crate::electrs_status;
|
use crate::bitcoin_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 +19,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 +35,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 +78,23 @@ 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(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Cache-Control", "no-store")
|
||||||
|
.body(hyper::Body::from(body))
|
||||||
|
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_bitcoin_status() -> Result<Response<hyper::Body>> {
|
||||||
|
let status = bitcoin_status::get_bitcoin_status().await;
|
||||||
|
let body = serde_json::to_vec(&status).unwrap_or_default();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Cache-Control", "no-store")
|
||||||
|
.body(hyper::Body::from(body))
|
||||||
|
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_lnd_connect_info(
|
pub(super) async fn handle_lnd_connect_info(
|
||||||
@@ -81,7 +103,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,9 +119,12 @@ 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!("{LND_REST_BASE_URL}{suffix}");
|
||||||
match reqwest::get(&url).await {
|
match reqwest::get(&url).await {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
let status = resp.status().as_u16();
|
let status = resp.status().as_u16();
|
||||||
|
|||||||
@@ -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,54 +142,101 @@ 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);
|
||||||
|
|
||||||
|
let latest = self.metrics_store.latest().await;
|
||||||
|
let (cpu_pct, mem_pct, disk_pct): (f64, f64, f64) = latest
|
||||||
|
.map(|s| {
|
||||||
|
let mem_total = s.system.mem_total_bytes as f64;
|
||||||
|
let disk_total = s.system.disk_total_bytes as f64;
|
||||||
|
(
|
||||||
|
s.system.cpu_percent,
|
||||||
|
if mem_total > 0.0 {
|
||||||
|
(s.system.mem_used_bytes as f64 / mem_total) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
if disk_total > 0.0 {
|
||||||
|
(s.system.disk_used_bytes as f64 / disk_total) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or((0.0, 0.0, 0.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!({
|
||||||
"node_id": node_id,
|
"node_id": node_id,
|
||||||
|
"node_name": data.server_info.name.clone().filter(|n| !n.trim().is_empty()),
|
||||||
|
"hostname": system_hostname().await,
|
||||||
|
"server_url": local_server_url(&self.config.host_ip),
|
||||||
"version": data.server_info.version,
|
"version": data.server_info.version,
|
||||||
"uptime_secs": uptime_secs,
|
"uptime_secs": uptime_secs,
|
||||||
"cpu_cores": cpu_cores,
|
"cpu_cores": cpu_cores,
|
||||||
"ram_mb": total_ram_mb,
|
"ram_mb": total_ram_mb,
|
||||||
|
"cpu_pct": (cpu_pct * 10.0).round() / 10.0,
|
||||||
|
"mem_pct": (mem_pct * 10.0).round() / 10.0,
|
||||||
|
"disk_pct": (disk_pct * 10.0).round() / 10.0,
|
||||||
"containers": containers,
|
"containers": containers,
|
||||||
"container_count": data.package_data.len(),
|
"container_count": data.package_data.len(),
|
||||||
"running_count": data.package_data.values()
|
"running_count": data.package_data.values()
|
||||||
@@ -208,11 +258,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 +276,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 +334,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 +351,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 +362,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 +412,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 +459,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 +480,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();
|
||||||
@@ -436,3 +510,24 @@ impl RpcHandler {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn system_hostname() -> Option<String> {
|
||||||
|
let output = tokio::process::Command::new("hostname")
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
(!hostname.is_empty()).then_some(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_server_url(host_ip: &str) -> Option<String> {
|
||||||
|
let host_ip = host_ip.trim();
|
||||||
|
if host_ip.is_empty() || host_ip == "127.0.0.1" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(format!("https://{host_ip}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +79,8 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
|
|
||||||
self.auth_manager
|
let outcome = self
|
||||||
|
.auth_manager
|
||||||
.change_password(current_password, new_password, also_change_ssh)
|
.change_password(current_password, new_password, also_change_ssh)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -68,7 +89,12 @@ impl RpcHandler {
|
|||||||
self.session_store.invalidate_all_except(token).await;
|
self.session_store.invalidate_all_except(token).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(serde_json::json!({ "success": true, "session_rotated": true }))
|
Ok(serde_json::json!({
|
||||||
|
"success": true,
|
||||||
|
"session_rotated": true,
|
||||||
|
"ssh_updated": outcome.ssh_updated,
|
||||||
|
"ssh_error": outcome.ssh_error,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_auth_is_setup(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_auth_is_setup(&self) -> Result<serde_json::Value> {
|
||||||
@@ -84,7 +110,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 +134,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");
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,55 @@ use anyhow::{Context, Result};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
|
/// Retry configuration for [`bitcoin_rpc_post_with_retry`].
|
||||||
|
///
|
||||||
|
/// Exposed as a struct (rather than hard-coded constants inside the function)
|
||||||
|
/// so tests can dial down timeouts to keep the suite fast while still
|
||||||
|
/// exercising real retry/backoff behavior.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct RetryConfig {
|
||||||
|
max_attempts: u32,
|
||||||
|
attempt_timeout: std::time::Duration,
|
||||||
|
/// Length must equal `max_attempts - 1` (one backoff between each
|
||||||
|
/// successive attempt). The last attempt is not followed by a backoff.
|
||||||
|
backoffs: Vec<std::time::Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetryConfig {
|
||||||
|
/// Production retry policy: 3 attempts, 15s each, 500ms + 1500ms backoffs.
|
||||||
|
/// Total worst-case wall time: 3 * 15 + 0.5 + 1.5 = 47s.
|
||||||
|
fn production() -> Self {
|
||||||
|
Self {
|
||||||
|
max_attempts: BITCOIN_RPC_MAX_ATTEMPTS,
|
||||||
|
attempt_timeout: BITCOIN_RPC_ATTEMPT_TIMEOUT,
|
||||||
|
backoffs: BITCOIN_RPC_BACKOFFS.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max retry attempts for a single bitcoin_rpc_call invocation.
|
||||||
|
/// First attempt + 2 retries = 3 total.
|
||||||
|
const BITCOIN_RPC_MAX_ATTEMPTS: u32 = 3;
|
||||||
|
|
||||||
|
/// Per-attempt deadline. Must be >= the reqwest client's own timeout (we
|
||||||
|
/// build it at 15s in handle_bitcoin_getinfo) — this is the outer safety net.
|
||||||
|
const BITCOIN_RPC_ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// Backoff between attempts. Index 0 = after first failure, 1 = after second, etc.
|
||||||
|
/// Chosen to absorb bitcoind's typical block-validation stall (2-5s) without
|
||||||
|
/// adding noticeable latency on the happy path (first attempt succeeds in ~30ms).
|
||||||
|
const BITCOIN_RPC_BACKOFFS: [std::time::Duration; 2] = [
|
||||||
|
std::time::Duration::from_millis(500),
|
||||||
|
std::time::Duration::from_millis(1500),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Classify a reqwest error as transient (retryable) or fatal.
|
||||||
|
/// Transient: timeout, connect refused, request/response body IO errors.
|
||||||
|
/// Fatal: TLS errors, URL parse errors, redirect loops, builder errors.
|
||||||
|
fn is_transient_transport_error(e: &reqwest::Error) -> bool {
|
||||||
|
e.is_timeout() || e.is_connect() || e.is_request() || e.is_body()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct BitcoinInfo {
|
struct BitcoinInfo {
|
||||||
block_height: u64,
|
block_height: u64,
|
||||||
@@ -37,8 +86,15 @@ struct MempoolInfo {
|
|||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_bitcoin_getinfo(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_bitcoin_getinfo(&self) -> Result<serde_json::Value> {
|
||||||
|
// Per-attempt timeout (see bitcoin_rpc_call for retry semantics).
|
||||||
|
// 15s is enough room for bitcoind to answer getblockchaininfo even
|
||||||
|
// during block validation; bitcoin_rpc_call wraps each attempt in a
|
||||||
|
// separate tokio::time::timeout too, so this is belt-and-suspenders.
|
||||||
|
// connect_timeout is tighter so a dead bitcoind doesn't steal the
|
||||||
|
// whole attempt budget on TCP connect alone.
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(3))
|
||||||
.build()
|
.build()
|
||||||
.context("Failed to create HTTP client")?;
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
@@ -57,21 +113,30 @@ 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)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Call a Bitcoin Core JSON-RPC method.
|
||||||
|
///
|
||||||
|
/// Retries up to [`BITCOIN_RPC_MAX_ATTEMPTS`] times on transient
|
||||||
|
/// transport errors (timeout / connection refused / send/recv IO).
|
||||||
|
/// Does **not** retry when bitcoind responds with a well-formed
|
||||||
|
/// `{"error": ...}` body — those are real RPC errors and surfacing
|
||||||
|
/// them quickly is the right behavior.
|
||||||
|
///
|
||||||
|
/// Motivation: on a syncing pruned node, bitcoind's RPC thread can block
|
||||||
|
/// for 5-10 seconds during block validation. A single 10s timeout means
|
||||||
|
/// ~30% of UI calls error out even though the node is perfectly healthy.
|
||||||
|
/// With retry + backoff, the UI sees a uniform slow-but-successful
|
||||||
|
/// response instead of intermittent failures.
|
||||||
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
@@ -79,33 +144,15 @@ impl RpcHandler {
|
|||||||
params: &[serde_json::Value],
|
params: &[serde_json::Value],
|
||||||
) -> Result<T> {
|
) -> Result<T> {
|
||||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||||
let body = serde_json::json!({
|
bitcoin_rpc_post_with_retry(
|
||||||
"jsonrpc": "1.0",
|
client,
|
||||||
"id": "archy",
|
crate::constants::BITCOIN_RPC_URL,
|
||||||
"method": method,
|
&rpc_user,
|
||||||
"params": params,
|
&rpc_pass,
|
||||||
});
|
method,
|
||||||
|
params,
|
||||||
let resp = client
|
)
|
||||||
.post(crate::constants::BITCOIN_RPC_URL)
|
.await
|
||||||
.basic_auth(&rpc_user, Some(&rpc_pass))
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Bitcoin RPC connection failed")?;
|
|
||||||
|
|
||||||
let rpc_resp: BitcoinRpcResponse<T> = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.context("Failed to parse Bitcoin RPC response")?;
|
|
||||||
|
|
||||||
if let Some(err) = rpc_resp.error {
|
|
||||||
anyhow::bail!("Bitcoin RPC error: {}", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
rpc_resp
|
|
||||||
.result
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize a Bitcoin Core descriptor wallet with keys derived from the master seed.
|
/// Initialize a Bitcoin Core descriptor wallet with keys derived from the master seed.
|
||||||
@@ -116,19 +163,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 +194,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 +231,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 +275,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,
|
||||||
@@ -221,3 +294,351 @@ impl RpcHandler {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Free-function counterpart to `RpcHandler::bitcoin_rpc_call`.
|
||||||
|
///
|
||||||
|
/// Takes the URL + credentials as parameters so it can be exercised by unit
|
||||||
|
/// tests against a mock HTTP server without constructing a full `RpcHandler`.
|
||||||
|
///
|
||||||
|
/// Production callers go through `RpcHandler::bitcoin_rpc_call`, which loads
|
||||||
|
/// credentials from the secrets file and points at `BITCOIN_RPC_URL`.
|
||||||
|
async fn bitcoin_rpc_post_with_retry<T: serde::de::DeserializeOwned>(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
rpc_user: &str,
|
||||||
|
rpc_pass: &str,
|
||||||
|
method: &str,
|
||||||
|
params: &[serde_json::Value],
|
||||||
|
) -> Result<T> {
|
||||||
|
bitcoin_rpc_post_with_retry_cfg(
|
||||||
|
client,
|
||||||
|
url,
|
||||||
|
rpc_user,
|
||||||
|
rpc_pass,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
&RetryConfig::production(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inner implementation with configurable retry policy (for tests).
|
||||||
|
async fn bitcoin_rpc_post_with_retry_cfg<T: serde::de::DeserializeOwned>(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
rpc_user: &str,
|
||||||
|
rpc_pass: &str,
|
||||||
|
method: &str,
|
||||||
|
params: &[serde_json::Value],
|
||||||
|
cfg: &RetryConfig,
|
||||||
|
) -> Result<T> {
|
||||||
|
debug_assert_eq!(
|
||||||
|
cfg.backoffs.len(),
|
||||||
|
(cfg.max_attempts - 1) as usize,
|
||||||
|
"RetryConfig: backoffs.len() must equal max_attempts - 1"
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"jsonrpc": "1.0",
|
||||||
|
"id": "archy",
|
||||||
|
"method": method,
|
||||||
|
"params": params,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut last_err: Option<anyhow::Error> = None;
|
||||||
|
for attempt in 0..cfg.max_attempts {
|
||||||
|
if attempt > 0 {
|
||||||
|
let backoff = cfg
|
||||||
|
.backoffs
|
||||||
|
.get(attempt as usize - 1)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_else(|| std::time::Duration::from_secs(2));
|
||||||
|
tracing::warn!(
|
||||||
|
"bitcoin_rpc({}): attempt {} failed, backing off {:?}",
|
||||||
|
method,
|
||||||
|
attempt,
|
||||||
|
backoff
|
||||||
|
);
|
||||||
|
tokio::time::sleep(backoff).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-attempt hard deadline. Independent of reqwest's built-in timeout
|
||||||
|
// so we always cap total time even if reqwest blocks on something
|
||||||
|
// weird (e.g., DNS starvation).
|
||||||
|
let fut = client
|
||||||
|
.post(url)
|
||||||
|
.basic_auth(rpc_user, Some(rpc_pass))
|
||||||
|
.json(&body)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
let send_result = match tokio::time::timeout(cfg.attempt_timeout, fut).await {
|
||||||
|
Err(_elapsed) => {
|
||||||
|
last_err = Some(anyhow::anyhow!(
|
||||||
|
"Bitcoin RPC send timed out after {:?}",
|
||||||
|
cfg.attempt_timeout
|
||||||
|
));
|
||||||
|
continue; // transient: retry
|
||||||
|
}
|
||||||
|
Ok(r) => r,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resp = match send_result {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) if is_transient_transport_error(&e) => {
|
||||||
|
last_err = Some(anyhow::Error::from(e).context("Bitcoin RPC connection failed"));
|
||||||
|
continue; // transient: retry
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(anyhow::Error::from(e).context("Bitcoin RPC connection failed"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let rpc_resp: BitcoinRpcResponse<T> = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Failed to parse Bitcoin RPC response")?;
|
||||||
|
|
||||||
|
if let Some(err) = rpc_resp.error {
|
||||||
|
// RPC-level error: this is a real bitcoind response, not transient.
|
||||||
|
anyhow::bail!("Bitcoin RPC error: {}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rpc_resp
|
||||||
|
.result
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC returned null result"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_err
|
||||||
|
.unwrap_or_else(|| anyhow::anyhow!("Bitcoin RPC exhausted retries with no error captured")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use hyper::service::{make_service_fn, service_fn};
|
||||||
|
use hyper::{Body, Request, Response, Server, StatusCode};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Spin up a mock bitcoind HTTP server that behaves according to `handler`.
|
||||||
|
/// Returns the bound URL and a JoinHandle (dropped = server shutdown via the
|
||||||
|
/// oneshot cancel channel).
|
||||||
|
async fn spawn_mock<F, Fut>(
|
||||||
|
handler: F,
|
||||||
|
) -> (
|
||||||
|
String,
|
||||||
|
tokio::task::JoinHandle<()>,
|
||||||
|
tokio::sync::oneshot::Sender<()>,
|
||||||
|
)
|
||||||
|
where
|
||||||
|
F: Fn(Request<Body>) -> Fut + Send + Sync + Clone + 'static,
|
||||||
|
Fut: std::future::Future<Output = Response<Body>> + Send + 'static,
|
||||||
|
{
|
||||||
|
let addr = SocketAddr::from(([127, 0, 0, 1], 0));
|
||||||
|
let make_svc = make_service_fn(move |_| {
|
||||||
|
let handler = handler.clone();
|
||||||
|
async move {
|
||||||
|
Ok::<_, Infallible>(service_fn(move |req| {
|
||||||
|
let handler = handler.clone();
|
||||||
|
async move { Ok::<_, Infallible>(handler(req).await) }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let server = Server::bind(&addr).serve(make_svc);
|
||||||
|
let url = format!("http://{}", server.local_addr());
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let graceful = server.with_graceful_shutdown(async {
|
||||||
|
let _ = rx.await;
|
||||||
|
});
|
||||||
|
let _ = graceful.await;
|
||||||
|
});
|
||||||
|
(url, handle, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reply body bitcoind would send for a successful getblockcount.
|
||||||
|
fn ok_reply() -> Body {
|
||||||
|
Body::from(r#"{"result":42,"error":null,"id":"archy"}"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err_reply() -> Body {
|
||||||
|
Body::from(r#"{"result":null,"error":{"code":-8,"message":"nope"},"id":"archy"}"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Succeeds on first attempt — should not retry.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn happy_path_first_attempt() {
|
||||||
|
let count = Arc::new(AtomicU32::new(0));
|
||||||
|
let c = count.clone();
|
||||||
|
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||||
|
let c = c.clone();
|
||||||
|
async move {
|
||||||
|
c.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Response::new(ok_reply())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder().build().unwrap();
|
||||||
|
let v: u64 =
|
||||||
|
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[])
|
||||||
|
.await
|
||||||
|
.expect("should succeed");
|
||||||
|
assert_eq!(v, 42);
|
||||||
|
assert_eq!(count.load(Ordering::SeqCst), 1, "should not have retried");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTTP 503 with non-JSON body: produces a JSON-parse error which is NOT
|
||||||
|
/// classified as transient. Must fail after first attempt.
|
||||||
|
/// This guards against the tempting mistake of blanket-retrying every
|
||||||
|
/// non-2xx response — which would mask real bitcoind misconfig.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn does_not_retry_parse_errors() {
|
||||||
|
let count = Arc::new(AtomicU32::new(0));
|
||||||
|
let c = count.clone();
|
||||||
|
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||||
|
let c = c.clone();
|
||||||
|
async move {
|
||||||
|
c.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||||
|
.body(Body::from("busy"))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder().build().unwrap();
|
||||||
|
let result: Result<u64> =
|
||||||
|
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[]).await;
|
||||||
|
assert!(result.is_err(), "non-JSON response should error out");
|
||||||
|
assert_eq!(
|
||||||
|
count.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"parse errors are not retryable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect-refused (port closed) is the canonical transient transport
|
||||||
|
/// error. Must exhaust BITCOIN_RPC_MAX_ATTEMPTS and the total elapsed
|
||||||
|
/// time must include at least the sum of the backoffs.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn retries_exhausted_on_persistent_connect_refused() {
|
||||||
|
// Bind a port then immediately drop the listener so the port is closed.
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let closed_url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.connect_timeout(std::time::Duration::from_millis(500))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let result: Result<u64> =
|
||||||
|
bitcoin_rpc_post_with_retry(&client, &closed_url, "user", "pass", "getblockcount", &[])
|
||||||
|
.await;
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
assert!(result.is_err(), "connect-refused should exhaust retries");
|
||||||
|
let min_backoff: std::time::Duration = BITCOIN_RPC_BACKOFFS.iter().sum();
|
||||||
|
assert!(
|
||||||
|
elapsed >= min_backoff,
|
||||||
|
"should have backed off between retries (elapsed={:?}, expected at least {:?})",
|
||||||
|
elapsed,
|
||||||
|
min_backoff
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The motivating scenario: first attempt times out (bitcoind busy),
|
||||||
|
/// subsequent attempt succeeds. Uses a short test-only RetryConfig so
|
||||||
|
/// the test runs in <1s instead of 15s.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn retries_on_timeout_then_succeeds() {
|
||||||
|
let count = Arc::new(AtomicU32::new(0));
|
||||||
|
let c = count.clone();
|
||||||
|
// Mock server: first request hangs for 500ms, subsequent requests reply OK.
|
||||||
|
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||||
|
let c = c.clone();
|
||||||
|
async move {
|
||||||
|
let n = c.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if n == 0 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
Response::new(ok_reply())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder().build().unwrap();
|
||||||
|
// Attempt timeout 100ms < server's 500ms sleep => first attempt times out.
|
||||||
|
// Backoff 20ms between attempts.
|
||||||
|
let cfg = RetryConfig {
|
||||||
|
max_attempts: 3,
|
||||||
|
attempt_timeout: std::time::Duration::from_millis(100),
|
||||||
|
backoffs: vec![
|
||||||
|
std::time::Duration::from_millis(20),
|
||||||
|
std::time::Duration::from_millis(20),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let v: u64 = bitcoin_rpc_post_with_retry_cfg(
|
||||||
|
&client,
|
||||||
|
&url,
|
||||||
|
"user",
|
||||||
|
"pass",
|
||||||
|
"getblockcount",
|
||||||
|
&[],
|
||||||
|
&cfg,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("second attempt should succeed");
|
||||||
|
assert_eq!(v, 42);
|
||||||
|
assert!(
|
||||||
|
count.load(Ordering::SeqCst) >= 2,
|
||||||
|
"expected at least 2 attempts (got {})",
|
||||||
|
count.load(Ordering::SeqCst)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// bitcoind returned a well-formed `{"error": ...}` body. Must NOT retry.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn does_not_retry_on_rpc_level_error() {
|
||||||
|
let count = Arc::new(AtomicU32::new(0));
|
||||||
|
let c = count.clone();
|
||||||
|
let (url, _h, _tx) = spawn_mock(move |_req| {
|
||||||
|
let c = c.clone();
|
||||||
|
async move {
|
||||||
|
c.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Response::new(err_reply())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder().build().unwrap();
|
||||||
|
let result: Result<u64> =
|
||||||
|
bitcoin_rpc_post_with_retry(&client, &url, "user", "pass", "getblockcount", &[]).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
count.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"RPC-level errors are not transient"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanity: retry budget invariants. Chosen to catch regressions where
|
||||||
|
/// someone bumps these constants without realizing the total worst-case
|
||||||
|
/// wall time implications.
|
||||||
|
#[test]
|
||||||
|
fn retry_budget_invariants() {
|
||||||
|
assert_eq!(BITCOIN_RPC_MAX_ATTEMPTS, 3);
|
||||||
|
assert_eq!(
|
||||||
|
BITCOIN_RPC_BACKOFFS.len(),
|
||||||
|
(BITCOIN_RPC_MAX_ATTEMPTS - 1) as usize
|
||||||
|
);
|
||||||
|
// Total wall-time ceiling:
|
||||||
|
// 3 attempts * 15s + (0.5s + 1.5s) backoff = 47s
|
||||||
|
let total: std::time::Duration = BITCOIN_RPC_ATTEMPT_TIMEOUT * BITCOIN_RPC_MAX_ATTEMPTS
|
||||||
|
+ BITCOIN_RPC_BACKOFFS.iter().sum::<std::time::Duration>();
|
||||||
|
assert!(total < std::time::Duration::from_secs(60));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,963 @@
|
|||||||
|
use super::RpcHandler;
|
||||||
|
use crate::container::docker_packages;
|
||||||
|
use crate::data_model::{Notification, NotificationLevel};
|
||||||
|
use crate::{bitcoin_status, identity, peers};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use archipelago_container::ContainerState;
|
||||||
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use rand::RngCore;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
const RELAY_DIR: &str = "bitcoin-relay";
|
||||||
|
const RELAY_STATE_FILE: &str = "state.json";
|
||||||
|
const TXRELAY_USER: &str = "txrelay";
|
||||||
|
const TXRELAY_PASSWORD_FILE: &str = "bitcoin-rpc-txrelay-password";
|
||||||
|
const TXRELAY_RPCAUTH_FILE: &str = "bitcoin-rpc-txrelay-rpcauth";
|
||||||
|
const TXRELAY_CLIENT_ENV_FILE: &str = "bitcoin-rpc-txrelay-client.env";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct BitcoinRelayState {
|
||||||
|
settings: BitcoinRelaySettings,
|
||||||
|
requests: Vec<BitcoinRelayRequest>,
|
||||||
|
updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BitcoinRelayState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
settings: BitcoinRelaySettings::default(),
|
||||||
|
requests: Vec::new(),
|
||||||
|
updated_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct BitcoinRelaySettings {
|
||||||
|
enabled_for_peers: bool,
|
||||||
|
allow_peer_requests: bool,
|
||||||
|
allow_http: bool,
|
||||||
|
allow_https: bool,
|
||||||
|
allow_tor: bool,
|
||||||
|
selected_peer_pubkey: Option<String>,
|
||||||
|
http_endpoint: Option<String>,
|
||||||
|
https_endpoint: Option<String>,
|
||||||
|
tor_endpoint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BitcoinRelaySettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled_for_peers: false,
|
||||||
|
allow_peer_requests: false,
|
||||||
|
allow_http: false,
|
||||||
|
allow_https: true,
|
||||||
|
allow_tor: false,
|
||||||
|
selected_peer_pubkey: None,
|
||||||
|
http_endpoint: None,
|
||||||
|
https_endpoint: None,
|
||||||
|
tor_endpoint: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct BitcoinRelayRequest {
|
||||||
|
id: String,
|
||||||
|
direction: RelayRequestDirection,
|
||||||
|
status: RelayRequestStatus,
|
||||||
|
peer_pubkey: String,
|
||||||
|
peer_onion: String,
|
||||||
|
peer_name: Option<String>,
|
||||||
|
message: Option<String>,
|
||||||
|
approved_endpoint: Option<String>,
|
||||||
|
credential_secret_path: Option<String>,
|
||||||
|
created_at: String,
|
||||||
|
updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
enum RelayRequestDirection {
|
||||||
|
Incoming,
|
||||||
|
Outbound,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
enum RelayRequestStatus {
|
||||||
|
Pending,
|
||||||
|
Approved,
|
||||||
|
Rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct TrustedRelayPeer {
|
||||||
|
pubkey: String,
|
||||||
|
onion: String,
|
||||||
|
name: Option<String>,
|
||||||
|
relay_approved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct TxRelayCredentials {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpcHandler {
|
||||||
|
pub(super) async fn handle_bitcoin_relay_status(&self) -> Result<serde_json::Value> {
|
||||||
|
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||||
|
hydrate_tor_endpoint(&self.config.data_dir, &mut state).await;
|
||||||
|
let known_peers = peers::load_peers(&self.config.data_dir)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let trusted_nodes = trusted_relay_peers(&known_peers, &state);
|
||||||
|
let local_node = local_sync_status().await;
|
||||||
|
let credential_status = txrelay_credential_status(&self.config.data_dir).await;
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"settings": state.settings,
|
||||||
|
"trusted_nodes": trusted_nodes,
|
||||||
|
"requests": state.requests,
|
||||||
|
"local_node": local_node,
|
||||||
|
"credentials": credential_status,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_bitcoin_relay_update_settings(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.unwrap_or_default();
|
||||||
|
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||||
|
let known_peers = peers::load_peers(&self.config.data_dir)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
update_bool(
|
||||||
|
¶ms,
|
||||||
|
"enabled_for_peers",
|
||||||
|
&mut state.settings.enabled_for_peers,
|
||||||
|
);
|
||||||
|
update_bool(
|
||||||
|
¶ms,
|
||||||
|
"allow_peer_requests",
|
||||||
|
&mut state.settings.allow_peer_requests,
|
||||||
|
);
|
||||||
|
update_bool(¶ms, "allow_http", &mut state.settings.allow_http);
|
||||||
|
update_bool(¶ms, "allow_https", &mut state.settings.allow_https);
|
||||||
|
update_bool(¶ms, "allow_tor", &mut state.settings.allow_tor);
|
||||||
|
|
||||||
|
update_endpoint(¶ms, "http_endpoint", &mut state.settings.http_endpoint)?;
|
||||||
|
update_endpoint(
|
||||||
|
¶ms,
|
||||||
|
"https_endpoint",
|
||||||
|
&mut state.settings.https_endpoint,
|
||||||
|
)?;
|
||||||
|
update_endpoint(¶ms, "tor_endpoint", &mut state.settings.tor_endpoint)?;
|
||||||
|
|
||||||
|
if state.settings.enabled_for_peers {
|
||||||
|
let credentials_were_ready = txrelay_credentials_available(&self.config.data_dir).await;
|
||||||
|
ensure_txrelay_credentials(&self.config.data_dir).await?;
|
||||||
|
if !credentials_were_ready {
|
||||||
|
self.restart_bitcoin_backends_for_txrelay().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.get("selected_peer_pubkey").is_some() {
|
||||||
|
let selected = params
|
||||||
|
.get("selected_peer_pubkey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
if let Some(pubkey) = selected {
|
||||||
|
if !known_peers.iter().any(|p| p.pubkey == pubkey) {
|
||||||
|
anyhow::bail!("Selected relay peer is not in trusted nodes");
|
||||||
|
}
|
||||||
|
state.settings.selected_peer_pubkey = Some(pubkey.to_string());
|
||||||
|
} else {
|
||||||
|
state.settings.selected_peer_pubkey = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.updated_at = Some(now());
|
||||||
|
save_relay_state(&self.config.data_dir, &state).await?;
|
||||||
|
self.notify(
|
||||||
|
"Bitcoin relay settings updated",
|
||||||
|
"Transaction relay sharing preferences were saved.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.handle_bitcoin_relay_status().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_bitcoin_relay_request_peer(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.unwrap_or_default();
|
||||||
|
let peer_pubkey = params
|
||||||
|
.get("peer_pubkey")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: peer_pubkey"))?;
|
||||||
|
let message = params
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(sanitize_optional_text)
|
||||||
|
.transpose()?;
|
||||||
|
let peer = peers::load_peers(&self.config.data_dir)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.pubkey == peer_pubkey)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Peer is not in trusted nodes"))?;
|
||||||
|
|
||||||
|
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||||
|
let existing = state.requests.iter_mut().find(|r| {
|
||||||
|
r.direction == RelayRequestDirection::Outbound
|
||||||
|
&& r.peer_pubkey == peer.pubkey
|
||||||
|
&& r.status == RelayRequestStatus::Pending
|
||||||
|
});
|
||||||
|
let request_id = if let Some(req) = existing {
|
||||||
|
req.message = message.clone();
|
||||||
|
req.updated_at = now();
|
||||||
|
req.id.clone()
|
||||||
|
} else {
|
||||||
|
let timestamp = now();
|
||||||
|
let req = BitcoinRelayRequest {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
direction: RelayRequestDirection::Outbound,
|
||||||
|
status: RelayRequestStatus::Pending,
|
||||||
|
peer_pubkey: peer.pubkey.clone(),
|
||||||
|
peer_onion: peer.onion.clone(),
|
||||||
|
peer_name: peer.name.clone(),
|
||||||
|
message: message.clone(),
|
||||||
|
approved_endpoint: None,
|
||||||
|
credential_secret_path: None,
|
||||||
|
created_at: timestamp.clone(),
|
||||||
|
updated_at: timestamp,
|
||||||
|
};
|
||||||
|
let id = req.id.clone();
|
||||||
|
state.requests.push(req);
|
||||||
|
id
|
||||||
|
};
|
||||||
|
state.updated_at = Some(now());
|
||||||
|
save_relay_state(&self.config.data_dir, &state).await?;
|
||||||
|
|
||||||
|
if let Err(e) = self
|
||||||
|
.send_relay_peer_message(
|
||||||
|
&peer,
|
||||||
|
json!({
|
||||||
|
"type": "bitcoin_relay_request",
|
||||||
|
"request_id": request_id,
|
||||||
|
"message": message,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(peer = %peer.onion, error = %e, "Failed to send Bitcoin relay request");
|
||||||
|
}
|
||||||
|
|
||||||
|
self.notify(
|
||||||
|
"Bitcoin relay request sent",
|
||||||
|
"A trusted peer was asked to approve transaction relay access.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(json!({ "ok": true, "request_id": request_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_bitcoin_relay_approve_request(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
self.update_relay_request_status(params, RelayRequestStatus::Approved)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_bitcoin_relay_reject_request(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
self.update_relay_request_status(params, RelayRequestStatus::Rejected)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_bitcoin_relay_create_tor_service(
|
||||||
|
&self,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = json!({
|
||||||
|
"name": "bitcoin-rpc",
|
||||||
|
"local_port": 80,
|
||||||
|
"remote_port": 80,
|
||||||
|
});
|
||||||
|
let created = match self.handle_tor_create_service(Some(params)).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) if e.to_string().contains("already exists") => {
|
||||||
|
self.handle_tor_get_onion_address(Some(json!({ "name": "bitcoin-rpc" })))
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
let onion = created
|
||||||
|
.get("onion_address")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
if let Some(onion) = onion {
|
||||||
|
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||||
|
state.settings.allow_tor = true;
|
||||||
|
state.settings.tor_endpoint = Some(format!("http://{onion}/"));
|
||||||
|
state.updated_at = Some(now());
|
||||||
|
save_relay_state(&self.config.data_dir, &state).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.notify(
|
||||||
|
"Bitcoin relay Tor service enabled",
|
||||||
|
"A Tor endpoint was created for Bitcoin transaction relay access.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_relay_request_status(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
status: RelayRequestStatus,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.unwrap_or_default();
|
||||||
|
let request_id = params
|
||||||
|
.get("id")
|
||||||
|
.or_else(|| params.get("request_id"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
|
||||||
|
let mut state = load_relay_state(&self.config.data_dir).await?;
|
||||||
|
let serving_endpoint = if status == RelayRequestStatus::Approved {
|
||||||
|
preferred_endpoint(&state.settings)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let request_direction = state
|
||||||
|
.requests
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.id == request_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?
|
||||||
|
.direction;
|
||||||
|
if status == RelayRequestStatus::Approved
|
||||||
|
&& request_direction == RelayRequestDirection::Incoming
|
||||||
|
&& serving_endpoint.is_none()
|
||||||
|
{
|
||||||
|
anyhow::bail!(
|
||||||
|
"Configure an HTTP, HTTPS, or Tor relay endpoint before approving access"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let credentials = if status == RelayRequestStatus::Approved {
|
||||||
|
let credentials = ensure_txrelay_credentials(&self.config.data_dir).await?;
|
||||||
|
if request_direction == RelayRequestDirection::Incoming {
|
||||||
|
self.restart_bitcoin_backends_for_txrelay().await;
|
||||||
|
}
|
||||||
|
Some(credentials)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let (peer_pubkey, peer_onion, peer_name, direction) = {
|
||||||
|
let req = state
|
||||||
|
.requests
|
||||||
|
.iter_mut()
|
||||||
|
.find(|r| r.id == request_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Request not found: {}", request_id))?;
|
||||||
|
req.status = status;
|
||||||
|
req.updated_at = now();
|
||||||
|
if let Some(endpoint) = &serving_endpoint {
|
||||||
|
req.approved_endpoint = Some(endpoint.clone());
|
||||||
|
}
|
||||||
|
(
|
||||||
|
req.peer_pubkey.clone(),
|
||||||
|
req.peer_onion.clone(),
|
||||||
|
req.peer_name.clone(),
|
||||||
|
req.direction,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let peer = peers::load_peers(&self.config.data_dir)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.pubkey == peer_pubkey);
|
||||||
|
let peer_name = peer_name.unwrap_or_else(|| peer_onion.clone());
|
||||||
|
state.updated_at = Some(now());
|
||||||
|
save_relay_state(&self.config.data_dir, &state).await?;
|
||||||
|
|
||||||
|
if let Some(peer) = peer {
|
||||||
|
let message_type = match status {
|
||||||
|
RelayRequestStatus::Approved => "bitcoin_relay_approved",
|
||||||
|
RelayRequestStatus::Rejected => "bitcoin_relay_rejected",
|
||||||
|
RelayRequestStatus::Pending => "bitcoin_relay_pending",
|
||||||
|
};
|
||||||
|
if let Err(e) = self
|
||||||
|
.send_relay_peer_message(
|
||||||
|
&peer,
|
||||||
|
relay_response_payload(
|
||||||
|
message_type,
|
||||||
|
request_id,
|
||||||
|
direction,
|
||||||
|
serving_endpoint.as_deref(),
|
||||||
|
credentials.as_ref(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(peer = %peer.onion, error = %e, "Failed to send Bitcoin relay response");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = match status {
|
||||||
|
RelayRequestStatus::Approved => "Bitcoin relay request approved",
|
||||||
|
RelayRequestStatus::Rejected => "Bitcoin relay request rejected",
|
||||||
|
RelayRequestStatus::Pending => "Bitcoin relay request updated",
|
||||||
|
};
|
||||||
|
self.notify(
|
||||||
|
title,
|
||||||
|
&format!("Relay access request for {peer_name} was updated."),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
Ok(json!({ "ok": true, "request_id": request_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_relay_peer_message(
|
||||||
|
&self,
|
||||||
|
peer: &peers::KnownPeer,
|
||||||
|
mut payload: serde_json::Value,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let my_pubkey = data.server_info.pubkey.clone();
|
||||||
|
let my_did = identity::did_key_from_pubkey_hex(&my_pubkey).ok();
|
||||||
|
let my_onion = docker_packages::read_tor_address("archipelago")
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
payload["from_did"] = my_did.map(serde_json::Value::String).unwrap_or_default();
|
||||||
|
payload["from_pubkey"] = serde_json::Value::String(my_pubkey.clone());
|
||||||
|
payload["from_onion"] = serde_json::Value::String(my_onion);
|
||||||
|
payload["from_name"] = data
|
||||||
|
.server_info
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.map(serde_json::Value::String)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let to_fips_npub =
|
||||||
|
crate::federation::fips_npub_for_onion(&self.config.data_dir, &peer.onion).await;
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let signing_key = crate::identity::NodeIdentity::load_or_create(&identity_dir)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
crate::node_message::send_to_peer(
|
||||||
|
&peer.onion,
|
||||||
|
to_fips_npub.as_deref(),
|
||||||
|
&my_pubkey,
|
||||||
|
&payload.to_string(),
|
||||||
|
signing_key.as_ref().map(|i| i.signing_key()),
|
||||||
|
Some(&peer.pubkey),
|
||||||
|
data.server_info.name.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn notify(&self, title: &str, message: &str) {
|
||||||
|
let (mut data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
data.notifications.push(Notification {
|
||||||
|
id: format!("bitcoin-relay-{}", uuid::Uuid::new_v4()),
|
||||||
|
level: NotificationLevel::Info,
|
||||||
|
title: title.to_string(),
|
||||||
|
message: message.to_string(),
|
||||||
|
timestamp: now(),
|
||||||
|
app_id: Some("bitcoin-knots".to_string()),
|
||||||
|
});
|
||||||
|
let len = data.notifications.len();
|
||||||
|
if len > 30 {
|
||||||
|
data.notifications.drain(0..len - 30);
|
||||||
|
}
|
||||||
|
self.state_manager.update_data(data).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restart_bitcoin_backends_for_txrelay(&self) {
|
||||||
|
let Some(orchestrator) = self.orchestrator.as_ref().cloned() else {
|
||||||
|
tracing::debug!("Skipping txrelay backend restart; orchestrator unavailable");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
for app_id in ["bitcoin-knots", "bitcoin-core"] {
|
||||||
|
let Ok(status) = orchestrator.status(app_id).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if status.state != ContainerState::Running {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match orchestrator.restart(app_id).await {
|
||||||
|
Ok(()) => tracing::info!(
|
||||||
|
app_id,
|
||||||
|
"Restarted Bitcoin backend to load txrelay RPC credentials"
|
||||||
|
),
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
app_id,
|
||||||
|
error = %e,
|
||||||
|
"Failed to restart Bitcoin backend after txrelay credential update"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn record_incoming_relay_message(
|
||||||
|
data_dir: &Path,
|
||||||
|
from_pubkey: &str,
|
||||||
|
from_name: Option<&str>,
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
) -> Result<Option<&'static str>> {
|
||||||
|
let msg_type = payload.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
match msg_type {
|
||||||
|
"bitcoin_relay_request" => {
|
||||||
|
let from_onion = payload
|
||||||
|
.get("from_onion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let message = payload
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(sanitize_optional_text)
|
||||||
|
.transpose()?;
|
||||||
|
let remote_request_id = payload
|
||||||
|
.get("request_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut state = load_relay_state(data_dir).await?;
|
||||||
|
if !state.settings.allow_peer_requests {
|
||||||
|
return Ok(Some("bitcoin_relay_request_disabled"));
|
||||||
|
}
|
||||||
|
if !state.requests.iter().any(|r| {
|
||||||
|
r.direction == RelayRequestDirection::Incoming
|
||||||
|
&& r.peer_pubkey == from_pubkey
|
||||||
|
&& r.status == RelayRequestStatus::Pending
|
||||||
|
}) {
|
||||||
|
let timestamp = now();
|
||||||
|
state.requests.push(BitcoinRelayRequest {
|
||||||
|
id: if remote_request_id.is_empty() {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
} else {
|
||||||
|
remote_request_id.to_string()
|
||||||
|
},
|
||||||
|
direction: RelayRequestDirection::Incoming,
|
||||||
|
status: RelayRequestStatus::Pending,
|
||||||
|
peer_pubkey: from_pubkey.to_string(),
|
||||||
|
peer_onion: from_onion,
|
||||||
|
peer_name: from_name.map(String::from),
|
||||||
|
message,
|
||||||
|
approved_endpoint: None,
|
||||||
|
credential_secret_path: None,
|
||||||
|
created_at: timestamp.clone(),
|
||||||
|
updated_at: timestamp,
|
||||||
|
});
|
||||||
|
state.updated_at = Some(now());
|
||||||
|
save_relay_state(data_dir, &state).await?;
|
||||||
|
}
|
||||||
|
Ok(Some("bitcoin_relay_request"))
|
||||||
|
}
|
||||||
|
"bitcoin_relay_approved" | "bitcoin_relay_rejected" => {
|
||||||
|
let request_id = payload.get("request_id").and_then(|v| v.as_str());
|
||||||
|
let mut state = load_relay_state(data_dir).await?;
|
||||||
|
let status = if msg_type == "bitcoin_relay_approved" {
|
||||||
|
RelayRequestStatus::Approved
|
||||||
|
} else {
|
||||||
|
RelayRequestStatus::Rejected
|
||||||
|
};
|
||||||
|
let approved_access = if status == RelayRequestStatus::Approved {
|
||||||
|
save_peer_relay_access(data_dir, from_pubkey, payload).await?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(req) = state.requests.iter_mut().find(|r| {
|
||||||
|
r.direction == RelayRequestDirection::Outbound
|
||||||
|
&& r.peer_pubkey == from_pubkey
|
||||||
|
&& request_id.map(|id| id == r.id).unwrap_or(true)
|
||||||
|
}) {
|
||||||
|
req.status = status;
|
||||||
|
req.updated_at = now();
|
||||||
|
if let Some((endpoint, secret_path)) = approved_access {
|
||||||
|
req.approved_endpoint = Some(endpoint);
|
||||||
|
req.credential_secret_path = Some(secret_path);
|
||||||
|
}
|
||||||
|
state.updated_at = Some(now());
|
||||||
|
save_relay_state(data_dir, &state).await?;
|
||||||
|
}
|
||||||
|
Ok(Some(if msg_type == "bitcoin_relay_approved" {
|
||||||
|
"bitcoin_relay_approved"
|
||||||
|
} else {
|
||||||
|
"bitcoin_relay_rejected"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trusted_relay_peers(
|
||||||
|
known_peers: &[peers::KnownPeer],
|
||||||
|
state: &BitcoinRelayState,
|
||||||
|
) -> Vec<TrustedRelayPeer> {
|
||||||
|
known_peers
|
||||||
|
.iter()
|
||||||
|
.map(|peer| TrustedRelayPeer {
|
||||||
|
pubkey: peer.pubkey.clone(),
|
||||||
|
onion: peer.onion.clone(),
|
||||||
|
name: peer.name.clone(),
|
||||||
|
relay_approved: state.requests.iter().any(|req| {
|
||||||
|
req.peer_pubkey == peer.pubkey && req.status == RelayRequestStatus::Approved
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn txrelay_credential_status(data_dir: &Path) -> serde_json::Value {
|
||||||
|
let credentials_available = txrelay_credentials_available(data_dir).await;
|
||||||
|
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||||
|
let password_available = fs::metadata(&password_path).await.is_ok();
|
||||||
|
let rpcauth_available = fs::metadata(&rpcauth_path).await.is_ok();
|
||||||
|
let client_env_available = fs::metadata(&client_env_path).await.is_ok();
|
||||||
|
json!({
|
||||||
|
"username": TXRELAY_USER,
|
||||||
|
"available": credentials_available,
|
||||||
|
"password_available": password_available,
|
||||||
|
"rpcauth_available": rpcauth_available,
|
||||||
|
"client_env_available": client_env_available,
|
||||||
|
"client_env_path": client_env_path.display().to_string(),
|
||||||
|
"restart_hint": "Archipelago restarts the active Bitcoin backend after generating txrelay credentials so bitcoind loads the restricted rpcauth whitelist.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn txrelay_credentials_available(data_dir: &Path) -> bool {
|
||||||
|
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||||
|
fs::metadata(&password_path).await.is_ok()
|
||||||
|
&& fs::metadata(&rpcauth_path).await.is_ok()
|
||||||
|
&& fs::metadata(&client_env_path).await.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
|
||||||
|
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
|
||||||
|
let password = match read_trimmed(&password_path).await {
|
||||||
|
Some(value) => value,
|
||||||
|
None => {
|
||||||
|
let generated = generate_random_password();
|
||||||
|
write_secret_file(&password_path, &generated).await?;
|
||||||
|
generated
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rpcauth = match read_trimmed(&rpcauth_path).await {
|
||||||
|
Some(value) if rpcauth_matches_password(&value, TXRELAY_USER, &password) => value,
|
||||||
|
_ => {
|
||||||
|
let generated = generate_rpcauth(TXRELAY_USER, &password);
|
||||||
|
write_secret_file(&rpcauth_path, &generated).await?;
|
||||||
|
generated
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let client_env = format!(
|
||||||
|
"BITCOIN_RPC_TXRELAY_USER={}\nBITCOIN_RPC_TXRELAY_PASSWORD={}\nBITCOIN_RPC_TXRELAY_RPCAUTH={}\n",
|
||||||
|
TXRELAY_USER, password, rpcauth
|
||||||
|
);
|
||||||
|
write_secret_file(&client_env_path, &client_env).await?;
|
||||||
|
|
||||||
|
Ok(TxRelayCredentials {
|
||||||
|
username: TXRELAY_USER.to_string(),
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn txrelay_secret_paths(data_dir: &Path) -> (PathBuf, PathBuf, PathBuf) {
|
||||||
|
let secrets_dir = data_dir.join("secrets");
|
||||||
|
(
|
||||||
|
secrets_dir.join(TXRELAY_PASSWORD_FILE),
|
||||||
|
secrets_dir.join(TXRELAY_RPCAUTH_FILE),
|
||||||
|
secrets_dir.join(TXRELAY_CLIENT_ENV_FILE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_trimmed(path: &Path) -> Option<String> {
|
||||||
|
fs::read_to_string(path)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_secret_file(path: &Path, contents: &str) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
fs::write(path, contents).await?;
|
||||||
|
set_private_permissions(path).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_private_permissions(path: &Path) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_random_password() -> String {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||||
|
BASE64.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_rpcauth(username: &str, password: &str) -> String {
|
||||||
|
let mut salt_bytes = [0u8; 16];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut salt_bytes);
|
||||||
|
let salt_hex = hex::encode(salt_bytes);
|
||||||
|
let mut mac =
|
||||||
|
Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()).expect("HMAC accepts any key length");
|
||||||
|
mac.update(password.as_bytes());
|
||||||
|
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||||
|
format!("{username}:{salt_hex}${hash_hex}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rpcauth_matches_password(rpcauth: &str, username: &str, password: &str) -> bool {
|
||||||
|
let Some(rest) = rpcauth.strip_prefix(&format!("{username}:")) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some((salt_hex, expected_hash)) = rest.split_once('$') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if salt_hex.is_empty() || expected_hash.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
mac.update(password.as_bytes());
|
||||||
|
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||||
|
hash_hex.eq_ignore_ascii_case(expected_hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preferred_endpoint(settings: &BitcoinRelaySettings) -> Option<String> {
|
||||||
|
if settings.allow_https {
|
||||||
|
if let Some(endpoint) = settings.https_endpoint.clone() {
|
||||||
|
return Some(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settings.allow_tor {
|
||||||
|
if let Some(endpoint) = settings.tor_endpoint.clone() {
|
||||||
|
return Some(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settings.allow_http {
|
||||||
|
if let Some(endpoint) = settings.http_endpoint.clone() {
|
||||||
|
return Some(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings
|
||||||
|
.https_endpoint
|
||||||
|
.clone()
|
||||||
|
.or_else(|| settings.tor_endpoint.clone())
|
||||||
|
.or_else(|| settings.http_endpoint.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relay_response_payload(
|
||||||
|
message_type: &str,
|
||||||
|
request_id: &str,
|
||||||
|
request_direction: RelayRequestDirection,
|
||||||
|
endpoint: Option<&str>,
|
||||||
|
credentials: Option<&TxRelayCredentials>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let mut payload = json!({
|
||||||
|
"type": message_type,
|
||||||
|
"request_id": request_id,
|
||||||
|
});
|
||||||
|
if message_type == "bitcoin_relay_approved"
|
||||||
|
&& request_direction == RelayRequestDirection::Incoming
|
||||||
|
{
|
||||||
|
if let (Some(endpoint), Some(credentials)) = (endpoint, credentials) {
|
||||||
|
payload["relay_access"] = json!({
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"username": &credentials.username,
|
||||||
|
"password": &credentials.password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_peer_relay_access(
|
||||||
|
data_dir: &Path,
|
||||||
|
from_pubkey: &str,
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
) -> Result<Option<(String, String)>> {
|
||||||
|
let Some(access) = payload.get("relay_access") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let endpoint = access
|
||||||
|
.get("endpoint")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(validate_endpoint)
|
||||||
|
.transpose()?;
|
||||||
|
let username = access.get("username").and_then(|v| v.as_str());
|
||||||
|
let password = access.get("password").and_then(|v| v.as_str());
|
||||||
|
let (Some(endpoint), Some(username), Some(password)) = (endpoint, username, password) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
validate_env_value(username)?;
|
||||||
|
validate_env_value(password)?;
|
||||||
|
|
||||||
|
let secret_path = data_dir.join("secrets").join(format!(
|
||||||
|
"bitcoin-relay-peer-{}.env",
|
||||||
|
safe_pubkey_fragment(from_pubkey)
|
||||||
|
));
|
||||||
|
let contents = format!(
|
||||||
|
"BITCOIN_RELAY_PEER_PUBKEY={}\nBITCOIN_RELAY_ENDPOINT={}\nBITCOIN_RELAY_USERNAME={}\nBITCOIN_RELAY_PASSWORD={}\n",
|
||||||
|
from_pubkey, endpoint, username, password
|
||||||
|
);
|
||||||
|
write_secret_file(&secret_path, &contents).await?;
|
||||||
|
Ok(Some((endpoint, secret_path.display().to_string())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_env_value(value: &str) -> Result<()> {
|
||||||
|
if value.is_empty() || value.len() > 1024 || value.contains('\n') || value.contains('\r') {
|
||||||
|
anyhow::bail!("Invalid relay credential value");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_pubkey_fragment(pubkey: &str) -> String {
|
||||||
|
let fragment = pubkey
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_hexdigit())
|
||||||
|
.take(24)
|
||||||
|
.collect::<String>();
|
||||||
|
if fragment.is_empty() {
|
||||||
|
"unknown".to_string()
|
||||||
|
} else {
|
||||||
|
fragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hydrate_tor_endpoint(data_dir: &Path, state: &mut BitcoinRelayState) {
|
||||||
|
if state.settings.tor_endpoint.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(onion) = docker_packages::read_tor_address("bitcoin-rpc").await {
|
||||||
|
let onion = onion.trim().trim_end_matches('/').to_string();
|
||||||
|
if !onion.is_empty() {
|
||||||
|
state.settings.tor_endpoint = Some(format!("http://{onion}/"));
|
||||||
|
let _ = save_relay_state(data_dir, state).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn local_sync_status() -> serde_json::Value {
|
||||||
|
let status = bitcoin_status::get_bitcoin_status().await;
|
||||||
|
let blockchain = status.blockchain_info.as_ref();
|
||||||
|
let blocks = blockchain
|
||||||
|
.and_then(|v| v.get("blocks"))
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let headers = blockchain
|
||||||
|
.and_then(|v| v.get("headers"))
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let initial_block_download = blockchain
|
||||||
|
.and_then(|v| v.get("initialblockdownload"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
let synced =
|
||||||
|
status.ok && headers > 0 && blocks >= headers.saturating_sub(1) && !initial_block_download;
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"synced": synced,
|
||||||
|
"blocks": blocks,
|
||||||
|
"headers": headers,
|
||||||
|
"chain": blockchain
|
||||||
|
.and_then(|v| v.get("chain"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown"),
|
||||||
|
"status_ok": status.ok,
|
||||||
|
"status_stale": status.stale,
|
||||||
|
"error": status.error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_relay_state(data_dir: &Path) -> Result<BitcoinRelayState> {
|
||||||
|
let path = state_path(data_dir);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(BitcoinRelayState::default());
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||||
|
Ok(serde_json::from_str(&content).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_relay_state(data_dir: &Path, state: &BitcoinRelayState) -> Result<()> {
|
||||||
|
let dir = data_dir.join(RELAY_DIR);
|
||||||
|
fs::create_dir_all(&dir).await?;
|
||||||
|
let content = serde_json::to_string_pretty(state)?;
|
||||||
|
fs::write(dir.join(RELAY_STATE_FILE), content).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_path(data_dir: &Path) -> PathBuf {
|
||||||
|
data_dir.join(RELAY_DIR).join(RELAY_STATE_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_bool(params: &serde_json::Value, key: &str, target: &mut bool) {
|
||||||
|
if let Some(value) = params.get(key).and_then(|v| v.as_bool()) {
|
||||||
|
*target = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_endpoint(
|
||||||
|
params: &serde_json::Value,
|
||||||
|
key: &str,
|
||||||
|
target: &mut Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !params.get(key).is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let endpoint = params
|
||||||
|
.get(key)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
*target = endpoint.map(validate_endpoint).transpose()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_endpoint(endpoint: &str) -> Result<String> {
|
||||||
|
if endpoint.len() > 512 || endpoint.contains('\n') || endpoint.contains('\r') {
|
||||||
|
anyhow::bail!("Invalid endpoint");
|
||||||
|
}
|
||||||
|
let lower = endpoint.to_ascii_lowercase();
|
||||||
|
if !(lower.starts_with("http://") || lower.starts_with("https://")) {
|
||||||
|
anyhow::bail!("Endpoint must start with http:// or https://");
|
||||||
|
}
|
||||||
|
Ok(endpoint.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_optional_text(value: &str) -> Result<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.len() > 500 || value.contains('\0') {
|
||||||
|
anyhow::bail!("Invalid message");
|
||||||
|
}
|
||||||
|
Ok(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now() -> String {
|
||||||
|
chrono::Utc::now().to_rfc3339()
|
||||||
|
}
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
use super::RpcHandler;
|
|
||||||
use super::package::validate_app_id;
|
use super::package::validate_app_id;
|
||||||
|
use super::transitional::Op;
|
||||||
|
use super::RpcHandler;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const PODMAN_INSPECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const PODMAN_PS_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const ORCHESTRATOR_HEALTH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
pub(super) async fn handle_container_install(
|
pub(super) async fn handle_container_install(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let orchestrator = self
|
// The `container-install { manifest_path }` RPC is a dev-mode convenience
|
||||||
.orchestrator
|
// that points at an arbitrary YAML on disk. Production install happens via
|
||||||
.as_ref()
|
// the reconciler (BootReconciler, Step 5) and via the unified
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
// ContainerOrchestrator::install(app_id) trait call, which can be exposed
|
||||||
|
// through a separate `container-install-by-id` RPC when needed.
|
||||||
|
let dev = self.dev_orchestrator.as_ref().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("container-install with manifest_path is only available in dev mode")
|
||||||
|
})?;
|
||||||
|
|
||||||
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,10 +53,10 @@ 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 = dev
|
||||||
.install_container(&manifest, manifest_path)
|
.install_container(&manifest, manifest_path)
|
||||||
.await
|
.await
|
||||||
.context("Failed to install container")?;
|
.context("Failed to install container")?;
|
||||||
@@ -58,11 +68,6 @@ 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
|
|
||||||
.orchestrator
|
|
||||||
.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
|
||||||
.get("app_id")
|
.get("app_id")
|
||||||
@@ -70,23 +75,24 @@ impl RpcHandler {
|
|||||||
.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)?;
|
||||||
|
|
||||||
orchestrator
|
// User explicitly started the app — clear the user-stopped marker so
|
||||||
.start_container(app_id)
|
// crash recovery / health monitor won't second-guess it. Must happen
|
||||||
.await
|
// BEFORE the spawn (see runtime.rs:145-148 for the symmetric stop
|
||||||
.context("Failed to start container")?;
|
// side and the ordering contract crash recovery depends on).
|
||||||
|
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, app_id).await;
|
||||||
|
|
||||||
Ok(serde_json::json!({ "status": "started" }))
|
// spawn_transitional returns as soon as the background task is
|
||||||
|
// launched (<1s). The UI sees Starting… immediately via WebSocket.
|
||||||
|
self.spawn_transitional(Op::Start, app_id.to_string())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "status": "starting" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_container_stop(
|
pub(super) async fn handle_container_stop(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let orchestrator = self
|
|
||||||
.orchestrator
|
|
||||||
.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
|
||||||
.get("app_id")
|
.get("app_id")
|
||||||
@@ -94,12 +100,41 @@ impl RpcHandler {
|
|||||||
.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)?;
|
||||||
|
|
||||||
orchestrator
|
// Mark as user-stopped BEFORE the spawn — ordering is load-bearing
|
||||||
.stop_container(app_id)
|
// (crash recovery / health monitor inspect this flag concurrently
|
||||||
.await
|
// with the in-flight stop; see runtime.rs:145-148 for the package
|
||||||
.context("Failed to stop container")?;
|
// path that also writes this in the same order).
|
||||||
|
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, app_id).await;
|
||||||
|
|
||||||
Ok(serde_json::json!({ "status": "stopped" }))
|
// podman stop -t 600 (bitcoin-core) / -t 330 (lnd) runs in the
|
||||||
|
// background; the RPC returns now with "stopping".
|
||||||
|
self.spawn_transitional(Op::Stop, app_id.to_string())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "status": "stopping" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_container_restart(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let app_id = params
|
||||||
|
.get("app_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
|
||||||
|
validate_app_id(app_id)?;
|
||||||
|
|
||||||
|
// Restart does not mark user-stopped (the user wants the app to
|
||||||
|
// keep running). Clear the marker as a defensive measure in case a
|
||||||
|
// prior stop left it set and the restart is intended to revive the
|
||||||
|
// normal running state.
|
||||||
|
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, app_id).await;
|
||||||
|
|
||||||
|
self.spawn_transitional(Op::Restart, app_id.to_string())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({ "status": "restarting" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_container_remove(
|
pub(super) async fn handle_container_remove(
|
||||||
@@ -109,7 +144,7 @@ impl RpcHandler {
|
|||||||
let orchestrator = self
|
let orchestrator = self
|
||||||
.orchestrator
|
.orchestrator
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||||
|
|
||||||
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
|
||||||
@@ -123,7 +158,7 @@ impl RpcHandler {
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
orchestrator
|
orchestrator
|
||||||
.remove_container(app_id, preserve_data)
|
.remove(app_id, preserve_data)
|
||||||
.await
|
.await
|
||||||
.context("Failed to remove container")?;
|
.context("Failed to remove container")?;
|
||||||
|
|
||||||
@@ -137,18 +172,60 @@ 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 mut containers = Vec::with_capacity(data.package_data.len());
|
||||||
let state = match &pkg.state {
|
for (id, pkg) in &data.package_data {
|
||||||
crate::data_model::PackageState::Running => "running",
|
// Keep this mapping in sync with the UI's
|
||||||
crate::data_model::PackageState::Stopped => "stopped",
|
// ContainerStatus.state union in
|
||||||
crate::data_model::PackageState::Exited => "exited",
|
// neode-ui/src/api/container-client.ts. The UI maps
|
||||||
crate::data_model::PackageState::Starting => "created",
|
// transitional variants to single-button labels
|
||||||
_ => "unknown",
|
// (Stopping… / Starting… / Restarting…).
|
||||||
|
let mut state = match &pkg.state {
|
||||||
|
crate::data_model::PackageState::Running => "running".to_string(),
|
||||||
|
crate::data_model::PackageState::Stopped => "stopped".to_string(),
|
||||||
|
crate::data_model::PackageState::Exited => "exited".to_string(),
|
||||||
|
crate::data_model::PackageState::Starting => "starting".to_string(),
|
||||||
|
crate::data_model::PackageState::Stopping => "stopping".to_string(),
|
||||||
|
crate::data_model::PackageState::Restarting => "restarting".to_string(),
|
||||||
|
crate::data_model::PackageState::Installing => "installing".to_string(),
|
||||||
|
crate::data_model::PackageState::Installed => "installed".to_string(),
|
||||||
|
crate::data_model::PackageState::Updating => "updating".to_string(),
|
||||||
|
crate::data_model::PackageState::Removing => "removing".to_string(),
|
||||||
|
crate::data_model::PackageState::CreatingBackup => {
|
||||||
|
"creating-backup".to_string()
|
||||||
|
}
|
||||||
|
crate::data_model::PackageState::RestoringBackup => {
|
||||||
|
"restoring-backup".to_string()
|
||||||
|
}
|
||||||
|
crate::data_model::PackageState::BackingUp => "backing-up".to_string(),
|
||||||
};
|
};
|
||||||
let lan = pkg.installed.as_ref()
|
|
||||||
|
// Scanner backoff preserves cached package_data. Refresh stable
|
||||||
|
// states so callers do not see stale `running`/`exited` after
|
||||||
|
// health-monitor recovery or Quadlet --rm container removal.
|
||||||
|
if state == "running" && requires_launch_port_for_health(id) {
|
||||||
|
if !self.cached_reachable_health(id).await?.is_some() {
|
||||||
|
state = live_state_for_app(id)
|
||||||
|
.await
|
||||||
|
.unwrap_or("starting".to_string());
|
||||||
|
}
|
||||||
|
} else if should_refresh_cached_state(&state) {
|
||||||
|
if launch_port_reachable(id).await {
|
||||||
|
state = "running".to_string();
|
||||||
|
} else {
|
||||||
|
if let Some(live) = live_state_for_app(id).await {
|
||||||
|
state = live;
|
||||||
|
} else if quadlet_service_active(id).await {
|
||||||
|
state = "starting".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lan = pkg
|
||||||
|
.installed
|
||||||
|
.as_ref()
|
||||||
.and_then(|i| i.interface_addresses.get("main"))
|
.and_then(|i| i.interface_addresses.get("main"))
|
||||||
.and_then(|a| a.lan_address.as_deref());
|
.and_then(|a| a.lan_address.as_deref());
|
||||||
serde_json::json!({
|
containers.push(serde_json::json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"name": id,
|
"name": id,
|
||||||
"state": state,
|
"state": state,
|
||||||
@@ -156,14 +233,14 @@ impl RpcHandler {
|
|||||||
"created": "",
|
"created": "",
|
||||||
"ports": [],
|
"ports": [],
|
||||||
"lan_address": lan,
|
"lan_address": lan,
|
||||||
})
|
}));
|
||||||
}).collect();
|
}
|
||||||
return Ok(serde_json::json!(containers));
|
return Ok(serde_json::json!(containers));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: scanner hasn't run yet, query podman directly
|
// Fallback: scanner hasn't run yet, query the orchestrator directly.
|
||||||
if let Some(orchestrator) = &self.orchestrator {
|
if let Some(orchestrator) = &self.orchestrator {
|
||||||
if let Ok(containers) = orchestrator.list_containers().await {
|
if let Ok(containers) = orchestrator.list().await {
|
||||||
if !containers.is_empty() {
|
if !containers.is_empty() {
|
||||||
return Ok(serde_json::to_value(containers)?);
|
return Ok(serde_json::to_value(containers)?);
|
||||||
}
|
}
|
||||||
@@ -185,8 +262,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 +277,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!({
|
||||||
@@ -234,7 +320,7 @@ impl RpcHandler {
|
|||||||
let orchestrator = self
|
let orchestrator = self
|
||||||
.orchestrator
|
.orchestrator
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||||
|
|
||||||
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
|
||||||
@@ -243,12 +329,26 @@ impl RpcHandler {
|
|||||||
.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 status = orchestrator
|
let mut last_err: Option<anyhow::Error> = None;
|
||||||
.get_container_status(app_id)
|
for candidate in status_app_id_candidates(app_id) {
|
||||||
.await
|
match orchestrator.status(&candidate).await {
|
||||||
.context("Failed to get container status")?;
|
Ok(status) => return Ok(serde_json::to_value(status)?),
|
||||||
|
Err(e) => last_err = Some(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(serde_json::to_value(status)?)
|
// Fallback for alias drift: query podman directly by likely container
|
||||||
|
// names so status checks stay useful during migration.
|
||||||
|
for name in status_container_name_candidates(app_id) {
|
||||||
|
if let Some(v) = inspect_container_state_value(&name).await {
|
||||||
|
return Ok(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(e) = last_err {
|
||||||
|
return Err(e.context("Failed to get container status"));
|
||||||
|
}
|
||||||
|
Err(anyhow::anyhow!("Failed to get container status"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_container_logs(
|
pub(super) async fn handle_container_logs(
|
||||||
@@ -258,7 +358,7 @@ impl RpcHandler {
|
|||||||
let orchestrator = self
|
let orchestrator = self
|
||||||
.orchestrator
|
.orchestrator
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||||
|
|
||||||
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,13 +366,10 @@ 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)
|
.logs(app_id, lines)
|
||||||
.await
|
.await
|
||||||
.context("Failed to get container logs")?;
|
.context("Failed to get container logs")?;
|
||||||
|
|
||||||
@@ -288,10 +385,10 @@ impl RpcHandler {
|
|||||||
let orchestrator = self
|
let orchestrator = self
|
||||||
.orchestrator
|
.orchestrator
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||||
|
|
||||||
let logs = orchestrator
|
let logs = orchestrator
|
||||||
.get_container_logs(app_id, lines)
|
.logs(app_id, lines)
|
||||||
.await
|
.await
|
||||||
.context("Failed to get container logs")?;
|
.context("Failed to get container logs")?;
|
||||||
|
|
||||||
@@ -305,41 +402,520 @@ impl RpcHandler {
|
|||||||
let orchestrator = self
|
let orchestrator = self
|
||||||
.orchestrator
|
.orchestrator
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||||
|
|
||||||
// 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 {
|
||||||
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
|
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
|
||||||
let health = orchestrator
|
if let Some(health) = self.cached_reachable_health(app_id).await? {
|
||||||
.get_health_status(app_id)
|
return Ok(serde_json::json!({ app_id: health }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(health) = self.cached_state_health(app_id).await {
|
||||||
|
return Ok(serde_json::json!({ app_id: health }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if requires_launch_port_for_health(app_id) {
|
||||||
|
return Ok(serde_json::json!({ app_id: "starting" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(health) = self.stack_health(app_id).await? {
|
||||||
|
return Ok(serde_json::json!({ app_id: health }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last_err: Option<anyhow::Error> = None;
|
||||||
|
for candidate in status_app_id_candidates(app_id) {
|
||||||
|
match tokio::time::timeout(
|
||||||
|
ORCHESTRATOR_HEALTH_TIMEOUT,
|
||||||
|
orchestrator.health(&candidate),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.context("Failed to get container health")?;
|
{
|
||||||
return Ok(serde_json::json!({ app_id: health }));
|
Ok(Ok(health)) => return Ok(serde_json::json!({ app_id: health })),
|
||||||
|
Ok(Err(e)) => last_err = Some(e),
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name in status_container_name_candidates(app_id) {
|
||||||
|
if let Some(health) = inspect_container_health_value(&name).await {
|
||||||
|
return Ok(serde_json::json!({ app_id: health }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(e) = last_err {
|
||||||
|
return Err(e.context("Failed to get container health"));
|
||||||
|
}
|
||||||
|
return Err(anyhow::anyhow!("Failed to get container health"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, get health for all containers
|
// Otherwise, get health for all containers.
|
||||||
let containers = orchestrator
|
let containers = orchestrator
|
||||||
.list_containers()
|
.list()
|
||||||
.await
|
.await
|
||||||
.context("Failed to list containers")?;
|
.context("Failed to list containers")?;
|
||||||
|
|
||||||
let mut health_map = serde_json::Map::new();
|
let mut health_map = serde_json::Map::new();
|
||||||
for container in containers {
|
for container in containers {
|
||||||
if let Some(app_id) = container.name.strip_prefix("archipelago-") {
|
// Map the runtime container name back to the app_id the orchestrator
|
||||||
if let Some(app_id) = app_id.strip_suffix("-dev") {
|
// knows about. Dev orchestrator uses `archipelago-<id>-dev`; Prod
|
||||||
match orchestrator.get_health_status(app_id).await {
|
// uses bare `<id>` (or `archy-<id>` for UIs — health() accepts the
|
||||||
Ok(health) => {
|
// app_id either way since UI_APP_IDS is centralised).
|
||||||
health_map.insert(app_id.to_string(), serde_json::Value::String(health));
|
let app_id_candidate = container
|
||||||
}
|
.name
|
||||||
Err(_) => {
|
.strip_prefix("archipelago-")
|
||||||
health_map.insert(app_id.to_string(), serde_json::Value::String("unknown".to_string()));
|
.and_then(|s| s.strip_suffix("-dev"))
|
||||||
}
|
.or_else(|| container.name.strip_prefix("archy-"))
|
||||||
}
|
.unwrap_or(container.name.as_str());
|
||||||
|
match tokio::time::timeout(
|
||||||
|
ORCHESTRATOR_HEALTH_TIMEOUT,
|
||||||
|
orchestrator.health(app_id_candidate),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(health)) => {
|
||||||
|
health_map.insert(
|
||||||
|
app_id_candidate.to_string(),
|
||||||
|
serde_json::Value::String(health),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Err(_)) | Err(_) => {
|
||||||
|
health_map.insert(
|
||||||
|
app_id_candidate.to_string(),
|
||||||
|
serde_json::Value::String("unknown".to_string()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(serde_json::Value::Object(health_map))
|
Ok(serde_json::Value::Object(health_map))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cached_state_health(&self, app_id: &str) -> Option<&'static str> {
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let Some(pkg) = data.package_data.get(app_id) else {
|
||||||
|
if data.server_info.status_info.containers_scanned {
|
||||||
|
return Some("stopped");
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
match pkg.state {
|
||||||
|
crate::data_model::PackageState::Running => None,
|
||||||
|
crate::data_model::PackageState::Installing
|
||||||
|
| crate::data_model::PackageState::Installed
|
||||||
|
| crate::data_model::PackageState::Starting => Some("starting"),
|
||||||
|
crate::data_model::PackageState::Stopping
|
||||||
|
| crate::data_model::PackageState::Stopped
|
||||||
|
| crate::data_model::PackageState::Exited => Some("stopped"),
|
||||||
|
crate::data_model::PackageState::Removing => Some("removing"),
|
||||||
|
crate::data_model::PackageState::Restarting
|
||||||
|
| crate::data_model::PackageState::Updating
|
||||||
|
| crate::data_model::PackageState::CreatingBackup
|
||||||
|
| crate::data_model::PackageState::RestoringBackup
|
||||||
|
| crate::data_model::PackageState::BackingUp => Some("starting"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cached_reachable_health(&self, app_id: &str) -> Result<Option<String>> {
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let pkg = data.package_data.get(app_id);
|
||||||
|
if matches!(
|
||||||
|
pkg.map(|pkg| &pkg.state),
|
||||||
|
Some(crate::data_model::PackageState::Removing)
|
||||||
|
) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = pkg
|
||||||
|
.and_then(|pkg| pkg.installed.as_ref())
|
||||||
|
.and_then(|i| i.interface_addresses.get("main"))
|
||||||
|
.and_then(|a| a.lan_address.as_deref())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| health_probe_url_for_app(app_id));
|
||||||
|
|
||||||
|
let Some(url) = url else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if url.starts_with("http://") || url.starts_with("https://") {
|
||||||
|
return Ok(http_launch_url_reachable(&url)
|
||||||
|
.await
|
||||||
|
.then(|| "healthy".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(port) = port_from_url(&url) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(launch_port_reachable_by_port(port)
|
||||||
|
.await
|
||||||
|
.then(|| "healthy".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stack_health(&self, app_id: &str) -> Result<Option<String>> {
|
||||||
|
let Some(members) = stack_health_members(app_id) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let orchestrator = self
|
||||||
|
.orchestrator
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
|
||||||
|
|
||||||
|
let mut saw_starting = false;
|
||||||
|
let mut saw_unknown = false;
|
||||||
|
for member in members {
|
||||||
|
match member_health(orchestrator.as_ref(), member)
|
||||||
|
.await
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Ok(health) if health == "healthy" => {}
|
||||||
|
Ok(health) if health == "starting" => saw_starting = true,
|
||||||
|
Ok(health) if health == "unknown" => saw_unknown = true,
|
||||||
|
Ok(_) => return Ok(Some("unhealthy".to_string())),
|
||||||
|
Err(_) => saw_unknown = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if saw_unknown {
|
||||||
|
if let Some(health) = self.cached_reachable_health(app_id).await? {
|
||||||
|
return Ok(Some(health));
|
||||||
|
}
|
||||||
|
Ok(Some("unknown".to_string()))
|
||||||
|
} else if saw_starting {
|
||||||
|
if let Some(health) = self.cached_reachable_health(app_id).await? {
|
||||||
|
return Ok(Some(health));
|
||||||
|
}
|
||||||
|
Ok(Some("starting".to_string()))
|
||||||
|
} else {
|
||||||
|
Ok(Some("healthy".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn member_health(
|
||||||
|
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||||
|
app_id: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
if let Ok(Ok(health)) =
|
||||||
|
tokio::time::timeout(ORCHESTRATOR_HEALTH_TIMEOUT, orchestrator.health(app_id)).await
|
||||||
|
{
|
||||||
|
return Ok(health);
|
||||||
|
}
|
||||||
|
for name in status_container_name_candidates(app_id) {
|
||||||
|
if let Some(health) = inspect_container_health_value(&name).await {
|
||||||
|
return Ok(health);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok("unknown".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stack_health_members(app_id: &str) -> Option<&'static [&'static str]> {
|
||||||
|
match app_id {
|
||||||
|
"mempool" | "mempool-web" => {
|
||||||
|
Some(&["archy-mempool-db", "mempool-api", "archy-mempool-web"])
|
||||||
|
}
|
||||||
|
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||||
|
Some(&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"])
|
||||||
|
}
|
||||||
|
"immich" => Some(&["immich_postgres", "immich_redis", "immich_server"]),
|
||||||
|
"indeedhub" => Some(&[
|
||||||
|
"indeedhub-postgres",
|
||||||
|
"indeedhub-redis",
|
||||||
|
"indeedhub-minio",
|
||||||
|
"indeedhub-relay",
|
||||||
|
"indeedhub-api",
|
||||||
|
"indeedhub",
|
||||||
|
]),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_app_id_candidates(app_id: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut push = |s: &str| {
|
||||||
|
if !out.iter().any(|e: &String| e == s) {
|
||||||
|
out.push(s.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match app_id {
|
||||||
|
"bitcoin-knots" => {
|
||||||
|
push("bitcoin-knots");
|
||||||
|
push("bitcoin-core");
|
||||||
|
push("bitcoin");
|
||||||
|
}
|
||||||
|
"bitcoin-core" | "bitcoin" => {
|
||||||
|
push("bitcoin-core");
|
||||||
|
push("bitcoin-knots");
|
||||||
|
push("bitcoin");
|
||||||
|
}
|
||||||
|
"electrs" | "mempool-electrs" => {
|
||||||
|
push("electrs");
|
||||||
|
push("mempool-electrs");
|
||||||
|
push("electrumx");
|
||||||
|
}
|
||||||
|
"mempool" | "mempool-web" => {
|
||||||
|
push("mempool");
|
||||||
|
push("archy-mempool-web");
|
||||||
|
}
|
||||||
|
"immich" => {
|
||||||
|
push("immich");
|
||||||
|
push("immich_server");
|
||||||
|
}
|
||||||
|
_ => push(app_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_container_name_candidates(app_id: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut push = |s: &str| {
|
||||||
|
if !out.iter().any(|e: &String| e == s) {
|
||||||
|
out.push(s.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match app_id {
|
||||||
|
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => push("bitcoin-knots"),
|
||||||
|
"bitcoin-ui" => push("archy-bitcoin-ui"),
|
||||||
|
"lnd-ui" => push("archy-lnd-ui"),
|
||||||
|
"electrs-ui" => push("archy-electrs-ui"),
|
||||||
|
"electrs" | "mempool-electrs" => push("electrumx"),
|
||||||
|
"mempool" | "mempool-web" | "archy-mempool-web" => push("mempool"),
|
||||||
|
"immich" => push("immich_server"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
push(app_id);
|
||||||
|
if let Some(stripped) = app_id.strip_prefix("archy-") {
|
||||||
|
push(stripped);
|
||||||
|
} else {
|
||||||
|
push(&format!("archy-{}", app_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_refresh_cached_state(state: &str) -> bool {
|
||||||
|
matches!(state, "exited" | "stopped" | "stopping")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn live_state_for_app(app_id: &str) -> Option<String> {
|
||||||
|
for name in status_container_name_candidates(app_id) {
|
||||||
|
if let Some(live) = inspect_container_state_value(&name).await {
|
||||||
|
if let Some(live_state) = live.get("state").and_then(|v| v.as_str()) {
|
||||||
|
return Some(live_state.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn quadlet_service_active(app_id: &str) -> bool {
|
||||||
|
for name in status_container_name_candidates(app_id) {
|
||||||
|
let service = format!("{name}.service");
|
||||||
|
let mut cmd = tokio::process::Command::new("systemctl");
|
||||||
|
cmd.args(["--user", "is-active", "--quiet", &service]);
|
||||||
|
cmd.kill_on_drop(true);
|
||||||
|
if matches!(
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), cmd.status()).await,
|
||||||
|
Ok(Ok(status)) if status.success()
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn health_probe_url_for_app(app_id: &str) -> Option<String> {
|
||||||
|
let port = match app_id {
|
||||||
|
"bitcoin-ui" => 8334,
|
||||||
|
"botfights" => 9100,
|
||||||
|
"btcpay-server" | "btcpay" | "btcpayserver" => 23000,
|
||||||
|
"electrumx" | "electrs" | "mempool-electrs" | "electrs-ui" => 50002,
|
||||||
|
"fedimint" | "fedimintd" => 8175,
|
||||||
|
"filebrowser" => 8083,
|
||||||
|
"gitea" => 3001,
|
||||||
|
"grafana" => 3000,
|
||||||
|
"homeassistant" | "home-assistant" => 8123,
|
||||||
|
"immich" | "immich_server" => 2283,
|
||||||
|
"indeedhub" => 7778,
|
||||||
|
"jellyfin" => 8096,
|
||||||
|
"lnd" | "lnd-ui" => 18083,
|
||||||
|
"mempool" | "mempool-web" => 4080,
|
||||||
|
"nginx-proxy-manager" => 8081,
|
||||||
|
"ollama" => 11434,
|
||||||
|
"photoprism" => 2342,
|
||||||
|
"portainer" => 9000,
|
||||||
|
"searxng" => 8888,
|
||||||
|
"tailscale" => 8240,
|
||||||
|
"uptime-kuma" => 3002,
|
||||||
|
"vaultwarden" => 8082,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(format!("http://localhost:{port}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_launch_port_for_health(app_id: &str) -> bool {
|
||||||
|
matches!(app_id, "fedimint" | "fedimintd" | "fedimint-gateway")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn launch_port_reachable(app_id: &str) -> bool {
|
||||||
|
let Some(port) = health_probe_url_for_app(app_id).and_then(|url| port_from_url(&url)) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
launch_port_reachable_by_port(port).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn launch_port_reachable_by_port(port: u16) -> bool {
|
||||||
|
matches!(
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
Ok(Ok(_))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn http_launch_url_reachable(url: &str) -> bool {
|
||||||
|
let Ok(client) = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(2))
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match client.get(url).send().await {
|
||||||
|
Ok(response) => {
|
||||||
|
let status = response.status();
|
||||||
|
status.is_success() || status.is_redirection()
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn port_from_url(url: &str) -> Option<u16> {
|
||||||
|
let after_colon = url.rsplit_once(':')?.1;
|
||||||
|
let port = after_colon
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_ascii_digit())
|
||||||
|
.collect::<String>();
|
||||||
|
port.parse::<u16>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value> {
|
||||||
|
if let Some(v) = ps_container_state_value(name).await {
|
||||||
|
return Some(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = tokio::process::Command::new("podman");
|
||||||
|
cmd.args([
|
||||||
|
"inspect",
|
||||||
|
name,
|
||||||
|
"--format",
|
||||||
|
"{{.State.Status}} {{.State.Running}} {{if .State.Healthcheck}}{{.State.Healthcheck.Status}}{{else}}none{{end}}",
|
||||||
|
]);
|
||||||
|
cmd.kill_on_drop(true);
|
||||||
|
let out = tokio::time::timeout(PODMAN_INSPECT_TIMEOUT, cmd.output())
|
||||||
|
.await
|
||||||
|
.ok()?
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
if line.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let status = parts.next().unwrap_or("unknown");
|
||||||
|
let running = parts.next().unwrap_or("false") == "true";
|
||||||
|
let health = parts.next().unwrap_or("none");
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"status": status,
|
||||||
|
"state": status,
|
||||||
|
"running": running,
|
||||||
|
"health": health,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ps_container_state_value(name: &str) -> Option<serde_json::Value> {
|
||||||
|
let mut cmd = tokio::process::Command::new("podman");
|
||||||
|
cmd.args([
|
||||||
|
"ps",
|
||||||
|
"-a",
|
||||||
|
"--filter",
|
||||||
|
&format!("name={name}"),
|
||||||
|
"--format",
|
||||||
|
"{{.Names}}|{{.Status}}",
|
||||||
|
]);
|
||||||
|
cmd.kill_on_drop(true);
|
||||||
|
let out = tokio::time::timeout(PODMAN_PS_TIMEOUT, cmd.output())
|
||||||
|
.await
|
||||||
|
.ok()?
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
for line in stdout.lines() {
|
||||||
|
let mut parts = line.splitn(2, '|');
|
||||||
|
let container_name = parts.next().unwrap_or_default();
|
||||||
|
if container_name != name {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let status = parts.next().unwrap_or_default();
|
||||||
|
let state = state_from_podman_status(status);
|
||||||
|
let health = parse_health_from_status(status).unwrap_or("none");
|
||||||
|
return Some(serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"status": state,
|
||||||
|
"state": state,
|
||||||
|
"running": state.eq_ignore_ascii_case("running"),
|
||||||
|
"health": health,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_from_podman_status(status: &str) -> &str {
|
||||||
|
if status.starts_with("Up ") {
|
||||||
|
"running"
|
||||||
|
} else if status.starts_with("Exited ") {
|
||||||
|
"exited"
|
||||||
|
} else if status.starts_with("Created") {
|
||||||
|
"created"
|
||||||
|
} else if status.starts_with("Stopping") {
|
||||||
|
"stopping"
|
||||||
|
} else if status.starts_with("Removing") {
|
||||||
|
"removing"
|
||||||
|
} else {
|
||||||
|
"unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_health_from_status(status: &str) -> Option<&str> {
|
||||||
|
let start = status.rfind('(')?;
|
||||||
|
let end = status.rfind(')')?;
|
||||||
|
(start < end).then(|| &status[start + 1..end])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn inspect_container_health_value(name: &str) -> Option<String> {
|
||||||
|
let v = inspect_container_state_value(name).await?;
|
||||||
|
if let Some(health) = v.get("health").and_then(|s| s.as_str()) {
|
||||||
|
if health != "none" {
|
||||||
|
return Some(health.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown") {
|
||||||
|
"running" => Some("healthy".to_string()),
|
||||||
|
"created" => Some("starting".to_string()),
|
||||||
|
"paused" => Some("paused".to_string()),
|
||||||
|
"stopping" => Some("unhealthy".to_string()),
|
||||||
|
"exited" | "stopped" => Some("unhealthy".to_string()),
|
||||||
|
other => Some(format!("unknown:{other}")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 @@ 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 +269,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 +286,21 @@ 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 = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
let socks_proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
|
||||||
.context("Failed to create SOCKS proxy")?;
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
debug!(
|
||||||
.proxy(socks_proxy)
|
"Browsing peer content at {} (fips={})",
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
onion,
|
||||||
.build()
|
fips_npub.is_some()
|
||||||
.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 +316,150 @@ 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| {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use super::RpcHandler;
|
use super::RpcHandler;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// Route an RPC method name to its handler, returning the result value.
|
/// Route an RPC method name to its handler, returning the result value.
|
||||||
pub(super) async fn dispatch(
|
pub(super) async fn dispatch(
|
||||||
&self,
|
self: &Arc<Self>,
|
||||||
method: &str,
|
method: &str,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
session_token: &Option<String>,
|
session_token: &Option<String>,
|
||||||
@@ -12,10 +13,14 @@ impl RpcHandler {
|
|||||||
match method {
|
match method {
|
||||||
"echo" => self.handle_echo(params).await,
|
"echo" => self.handle_echo(params).await,
|
||||||
"server.echo" => self.handle_echo(params).await,
|
"server.echo" => self.handle_echo(params).await,
|
||||||
|
"server.get-state" => self.handle_server_get_state().await,
|
||||||
"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,
|
||||||
@@ -33,18 +38,24 @@ impl RpcHandler {
|
|||||||
"container-install" => self.handle_container_install(params).await,
|
"container-install" => self.handle_container_install(params).await,
|
||||||
"container-start" => self.handle_container_start(params).await,
|
"container-start" => self.handle_container_start(params).await,
|
||||||
"container-stop" => self.handle_container_stop(params).await,
|
"container-stop" => self.handle_container_stop(params).await,
|
||||||
|
"container-restart" => self.handle_container_restart(params).await,
|
||||||
"container-remove" => self.handle_container_remove(params).await,
|
"container-remove" => self.handle_container_remove(params).await,
|
||||||
"container-list" => self.handle_container_list().await,
|
"container-list" => self.handle_container_list().await,
|
||||||
"container-status" => self.handle_container_status(params).await,
|
"container-status" => self.handle_container_status(params).await,
|
||||||
"container-logs" => self.handle_container_logs(params).await,
|
"container-logs" => self.handle_container_logs(params).await,
|
||||||
"container-health" => self.handle_container_health(params).await,
|
"container-health" => self.handle_container_health(params).await,
|
||||||
|
|
||||||
// Package management (for docker-compose apps)
|
// Package management (for docker-compose apps).
|
||||||
"package.install" => self.handle_package_install(params).await,
|
// install/uninstall/update return immediately with a
|
||||||
|
// transitional status; the actual work runs in a background
|
||||||
|
// tokio::spawn so the HTTP request doesn't block for minutes.
|
||||||
|
"package.install" => self.clone().spawn_package_install(params).await,
|
||||||
"package.start" => self.handle_package_start(params).await,
|
"package.start" => self.handle_package_start(params).await,
|
||||||
"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.clone().spawn_package_uninstall(params).await,
|
||||||
|
"package.update" => self.clone().spawn_package_update(params).await,
|
||||||
|
"package.credentials" => self.handle_package_credentials(params).await,
|
||||||
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
"app.filebrowser-token" => self.handle_filebrowser_token().await,
|
||||||
|
|
||||||
// Bundled app management (for pre-loaded container images)
|
// Bundled app management (for pre-loaded container images)
|
||||||
@@ -74,6 +85,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 +98,23 @@ 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.relay-status" => self.handle_bitcoin_relay_status().await,
|
||||||
|
"bitcoin.relay-update-settings" => {
|
||||||
|
self.handle_bitcoin_relay_update_settings(params).await
|
||||||
|
}
|
||||||
|
"bitcoin.relay-request-peer" => self.handle_bitcoin_relay_request_peer(params).await,
|
||||||
|
"bitcoin.relay-approve-request" => {
|
||||||
|
self.handle_bitcoin_relay_approve_request(params).await
|
||||||
|
}
|
||||||
|
"bitcoin.relay-reject-request" => {
|
||||||
|
self.handle_bitcoin_relay_reject_request(params).await
|
||||||
|
}
|
||||||
|
"bitcoin.relay-create-tor-service" => {
|
||||||
|
self.handle_bitcoin_relay_create_tor_service().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 +141,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 +153,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 +178,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 +229,36 @@ 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.set-primary" => self.handle_registry_set_primary(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 +267,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 +301,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 +340,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 +386,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 +401,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 +412,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,14 +424,52 @@ 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,
|
||||||
|
"fips.reconnect" => self.handle_fips_reconnect().await,
|
||||||
|
"fips.list-seed-anchors" => self.handle_fips_list_seed_anchors().await,
|
||||||
|
"fips.add-seed-anchor" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_fips_add_seed_anchor(&p).await
|
||||||
|
}
|
||||||
|
"fips.remove-seed-anchor" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_fips_remove_seed_anchor(&p).await
|
||||||
|
}
|
||||||
|
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().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,
|
||||||
"update.dismiss" => self.handle_update_dismiss().await,
|
"update.dismiss" => self.handle_update_dismiss().await,
|
||||||
"update.download" => self.handle_update_download().await,
|
"update.download" => self.handle_update_download().await,
|
||||||
|
"update.cancel-download" => self.handle_update_cancel_download().await,
|
||||||
|
"update.list-mirrors" => self.handle_update_list_mirrors().await,
|
||||||
|
"update.add-mirror" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_update_add_mirror(&p).await
|
||||||
|
}
|
||||||
|
"update.remove-mirror" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_update_remove_mirror(&p).await
|
||||||
|
}
|
||||||
|
"update.set-primary-mirror" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_update_set_primary_mirror(&p).await
|
||||||
|
}
|
||||||
|
"update.test-mirror" => {
|
||||||
|
let p = params.unwrap_or(serde_json::json!({}));
|
||||||
|
self.handle_update_test_mirror(&p).await
|
||||||
|
}
|
||||||
"update.apply" => self.handle_update_apply().await,
|
"update.apply" => self.handle_update_apply().await,
|
||||||
"update.git-apply" => self.handle_update_git_apply().await,
|
"update.git-apply" => self.handle_update_git_apply().await,
|
||||||
"update.rollback" => self.handle_update_rollback().await,
|
"update.rollback" => self.handle_update_rollback().await,
|
||||||
@@ -379,13 +530,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 }));
|
||||||
@@ -394,6 +546,11 @@ impl RpcHandler {
|
|||||||
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_server_get_state(&self) -> Result<serde_json::Value> {
|
||||||
|
let (data, rev) = self.state_manager.get_snapshot().await;
|
||||||
|
Ok(serde_json::json!({ "data": data, "rev": rev }))
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_health(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_health(&self) -> Result<serde_json::Value> {
|
||||||
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||||
let uptime = crate::crash_recovery::uptime_seconds();
|
let uptime = crate::crash_recovery::uptime_seconds();
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -50,21 +79,31 @@ impl RpcHandler {
|
|||||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||||
let local_pubkey = data.server_info.pubkey.clone();
|
let local_pubkey = data.server_info.pubkey.clone();
|
||||||
|
let local_name = data.server_info.name.clone();
|
||||||
|
|
||||||
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(),
|
||||||
|
local_name.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 +149,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 +159,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 +179,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 +272,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 +288,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 +317,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 +349,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 +368,60 @@ 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 +446,46 @@ 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());
|
||||||
|
// Optional, unsigned: peer's display name. Display-only — identity
|
||||||
|
// claims are anchored on the signed did/pubkey below.
|
||||||
|
let incoming_name = params
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
// Reject self-peering. If somehow our own did / onion / pubkey
|
||||||
|
// comes back at us (misconfigured invite, gossip loop), adding
|
||||||
|
// the entry causes sync loops where the node syncs with itself
|
||||||
|
// forever. Drop it quietly — no useful recovery path.
|
||||||
|
let (own_data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
let own_did_result = identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
|
||||||
|
let own_onion_trim = own_data
|
||||||
|
.server_info
|
||||||
|
.tor_address
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_end_matches(".onion")
|
||||||
|
.to_string();
|
||||||
|
let incoming_onion_trim = onion.trim_end_matches(".onion");
|
||||||
|
if own_did_result.as_deref() == Some(did)
|
||||||
|
|| pubkey == own_data.server_info.pubkey
|
||||||
|
|| (!own_onion_trim.is_empty() && own_onion_trim == incoming_onion_trim)
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
peer_did = %did,
|
||||||
|
"Rejected peer-joined: inbound identity matches this node"
|
||||||
|
);
|
||||||
|
anyhow::bail!("Refusing to peer with self");
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +499,36 @@ 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/name, 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();
|
||||||
|
let needs_name = existing.name.is_none() && incoming_name.is_some();
|
||||||
|
if needs_onion || needs_pubkey || needs_fips || needs_name {
|
||||||
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();
|
||||||
|
}
|
||||||
|
if needs_name {
|
||||||
|
updated.name = incoming_name.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 }));
|
||||||
}
|
}
|
||||||
@@ -385,16 +537,49 @@ impl RpcHandler {
|
|||||||
did: did.to_string(),
|
did: did.to_string(),
|
||||||
pubkey: pubkey.to_string(),
|
pubkey: pubkey.to_string(),
|
||||||
onion: onion.to_string(),
|
onion: onion.to_string(),
|
||||||
name: None,
|
name: incoming_name.clone(),
|
||||||
trust_level: TrustLevel::Trusted,
|
trust_level: TrustLevel::Trusted,
|
||||||
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, incoming_name.as_deref())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Bump the data-model revision so any Federation view with an
|
||||||
|
// open WebSocket reloads its node list without waiting for the
|
||||||
|
// user to click Sync.
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
self.state_manager.update_data(data).await;
|
||||||
|
|
||||||
|
// Transitive discovery: spawn a task that pulls the new peer's
|
||||||
|
// state (its own federated peers end up as Observer entries on
|
||||||
|
// our side) so after a join every existing peer in our list is
|
||||||
|
// aware of the newcomer via the next pair of syncs, without the
|
||||||
|
// user clicking anything. Best-effort; errors are logged only.
|
||||||
|
let data_dir = self.config.data_dir.clone();
|
||||||
|
let new_peer_did = did.to_string();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
if let Err(e) = crate::federation::sync_with_peer_by_did(&data_dir, &new_peer_did).await
|
||||||
|
{
|
||||||
|
tracing::debug!(
|
||||||
|
peer_did = %new_peer_did,
|
||||||
|
error = %e,
|
||||||
|
"Transitive sync on peer-joined failed (non-fatal)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(serde_json::json!({ "accepted": true }))
|
Ok(serde_json::json!({ "accepted": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +661,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 +724,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 +734,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 +745,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 +847,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 +865,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 +880,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 +927,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 }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user