Compare commits
14 Commits
777d54ea94
...
24be2e9e69
| Author | SHA1 | Date | |
|---|---|---|---|
| 24be2e9e69 | |||
|
|
768c358546 | ||
|
|
128c13e965 | ||
|
|
5642aae530 | ||
|
|
6fa0aa46a9 | ||
|
|
bdb9826aba | ||
|
|
c3a4745d78 | ||
|
|
93c3aad06a | ||
|
|
db4de0ac96 | ||
|
|
412251ac9a | ||
|
|
69dc9ee27e | ||
|
|
bf3c38c7a1 | ||
|
|
79564486d3 | ||
|
|
ad3dae9983 |
74
.gitea/workflows/demo-images.yml
Normal file
74
.gitea/workflows/demo-images.yml
Normal file
@ -0,0 +1,74 @@
|
||||
name: Demo images
|
||||
|
||||
# Builds and pushes the public-demo images on every change to the UI / mock
|
||||
# backend, so the separated `archy-demo` Portainer stack auto-tracks the real
|
||||
# code (see demo-deploy/ and docs/demo-deployment-design.md).
|
||||
#
|
||||
# Required repo configuration:
|
||||
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
|
||||
# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix)
|
||||
# secrets.DEMO_REGISTRY_USER
|
||||
# secrets.DEMO_REGISTRY_TOKEN
|
||||
# Optional:
|
||||
# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.github/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & push demo images
|
||||
runs-on: ubuntu-latest
|
||||
# Skip cleanly on forks / before registry config is set.
|
||||
if: ${{ vars.DEMO_REGISTRY != '' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# The demo registry is plain HTTP — teach buildkit to push without TLS
|
||||
# (the host docker daemon needs it in insecure-registries for login too).
|
||||
buildkitd-config-inline: |
|
||||
[registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"]
|
||||
http = true
|
||||
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}
|
||||
username: ${{ secrets.DEMO_REGISTRY_USER }}
|
||||
password: ${{ secrets.DEMO_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build & push backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: neode-ui/Dockerfile.backend
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }}
|
||||
|
||||
- name: Build & push web
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: neode-ui/Dockerfile.web
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_DEMO=1
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||
|
||||
- name: Trigger Portainer redeploy
|
||||
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
|
||||
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
|
||||
7
.github/workflows/demo-images.yml
vendored
7
.github/workflows/demo-images.yml
vendored
@ -6,6 +6,7 @@ name: Demo images
|
||||
#
|
||||
# Required repo configuration:
|
||||
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
|
||||
# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix)
|
||||
# secrets.DEMO_REGISTRY_USER
|
||||
# secrets.DEMO_REGISTRY_TOKEN
|
||||
# Optional:
|
||||
@ -32,6 +33,12 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
# The demo registry is plain HTTP — teach buildkit to push without TLS
|
||||
# (the host docker daemon needs it in insecure-registries for login too).
|
||||
buildkitd-config-inline: |
|
||||
[registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"]
|
||||
http = true
|
||||
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@ -1,33 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.00-alpha (2026-06-18)
|
||||
## v1.7.100-alpha (2026-07-14)
|
||||
|
||||
Polishes the mesh AI assistant and Fedimint, on top of all the v1.7.99 features (kept listed below so you can still see what's new).
|
||||
- Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs.
|
||||
- Lightning grew up: your LND wallet's recovery seed is captured at setup and kept as an encrypted backup you can reveal from Settings, there's a new Channels tab with a fee control when opening channels, and on-chain and Lightning balances now show side by side.
|
||||
- Installing Lightning (and other Bitcoin-dependent apps) on a fresh node no longer fails repeatedly — the node now waits until Bitcoin is genuinely ready to answer before starting them, and Bitcoin sizes its storage to your actual disk and its memory cache to your RAM, so small machines stop swapping and stalling.
|
||||
- The wallet understands more money: Cashu v4 tokens are supported, you can pay for a peer's files from either your Cashu or Fedimint ecash, and the Transactions view now shows your Lightning, Cashu, and Fedimint activity together — with a payment confirmation screen and an automatic refund if a purchase fails.
|
||||
- Mesh radios got a major upgrade: Meshtastic direct messages are now true end-to-end-encrypted radio messages that interoperate with off-the-shelf Meshtastic phone apps, your radio's region and a shared channel are provisioned automatically, and a new setup window appears when a radio is plugged in — with board pictures, full radio settings, and signal-strength indicators.
|
||||
- Reticulum joins as a third mesh radio protocol with RNode LoRa hardware support, including sending images and voice messages over the radio — and every chat message now carries a small pill showing how it travelled (Mesh, FIPS, or Tor).
|
||||
- Your node can manage an OpenWrt router: set up its internet uplink from the UI with a Wi-Fi network scan, turn it into a TollGate pay-for-Wi-Fi hotspot with a real captive portal, and sweep the router's earnings into your node's wallet. The gateway's status appears on the Home screen's Network tile.
|
||||
- Peering is now trust-aware: "Invite a Peer" grants view-only Observer access while "Link Your Nodes" grants Trusted access, incoming requests ask for your confirmation with an optional message, Node Visibility is a single clear switch plus a list of discoverable nodes you can peer with, and the Fleet view shows your trusted nodes' health.
|
||||
- Updates and apps are verified end-to-end: release updates are cryptographically signed and checked against a key baked into your node, app definitions arrive via the signed catalog, and container images are checked against trusted sources before anything installs or runs.
|
||||
- Dozens of reliability fixes: failed installs no longer leave phantom app cards, uninstalling can't hang forever, apps you stopped stay stopped, crashed apps heal themselves (even "running" containers whose process actually died), the login page no longer refresh-loops, and the mobile layout fits real phone screens instead of hiding the last row behind the browser bar.
|
||||
|
||||
- The off-grid mesh radio no longer posts cryptic identity codes to the shared public channel. Your node was announcing a line starting with "ARCHY:" to the public channel about once a minute, which everyone else on that channel saw as spam; that broadcast has been removed.
|
||||
- You can now use your node's AI assistant straight from a normal chat. Send "!ai <your question>" in a direct message to an AI-enabled node and the answer comes right back in the same conversation — whether your message travelled over the internet or the LoRa radio. Before, the reply could be sent on the wrong path and never arrive.
|
||||
- The Mesh AI Assistant panel is easier to set up: pick the Claude model from a dropdown (Haiku, Sonnet, or Opus) instead of typing it, and add specific contacts to an "always allow" list so chosen people can use "!ai" even when the assistant is set to trusted-nodes-only.
|
||||
- Fedimint federations show up in Wallet Settings again. The Fedimint client app wasn't starting because of a configuration error, so the federation your node auto-joins never appeared; the client is fixed and runs again.
|
||||
- In Settings, "App Updates" and "App Registry" now sit directly under your Account section for quicker access.
|
||||
- In Mesh chat, scrolling the conversation no longer also scrolls the contact list behind it.
|
||||
- Mesh direct messages are now private and end-to-end encrypted to the recipient — they're sent as real radio DMs instead of being broadcast on the public channel, so other people on the mesh no longer see them, and the answer arrives intact (even on standard meshcore phone apps).
|
||||
- You can now message standard meshcore apps (like the phone companion) and they can message you — text shows up readable on both sides, and your node's AI answers come back as a private reply rather than on the public channel.
|
||||
- New contacts you hear on the radio are added automatically, so people show up in your Peers list without any extra steps.
|
||||
- "Clear All" now actually removes contacts (rather than hiding them forever); a contact comes back on its own the next time it's in range. Each contact also shows a reachability dot so you can see who's currently reachable.
|
||||
- The Peers list has a search box (with a clear button) to quickly filter your contacts by name, DID, npub, or key.
|
||||
### Also in this release
|
||||
|
||||
All the v1.7.99-alpha features are included as well:
|
||||
|
||||
- Your node can now hold Fedimint ecash as well as Cashu, with tabbed Wallet Settings for each and both balances shown side by side on the home wallet card.
|
||||
- You can buy files shared by another node right from their cloud, paying from this node's ecash, your Lightning wallet, on-chain, or by scanning a Lightning QR with any outside wallet.
|
||||
- Your node can act as an AI assistant on the off-grid mesh: peers ask by starting a message with "!ai" and get an answer back over the radio, with a panel to turn it on or off.
|
||||
- You can view your node's 24-word recovery phrase any time from Settings, behind a password (and 2FA) confirmation and a tap-to-show blur.
|
||||
- Setting up a brand-new node is smoother: it waits and retries quietly instead of flashing errors, and shows a gentle "securing your private connection…" status that turns to "ready" on its own.
|
||||
- The NetBird VPN app now logs in (it's served over HTTPS and opens in a browser tab).
|
||||
- Phone remote-control of a node's screen now supports two-finger scrolling inside apps, and external-browser apps open on your phone.
|
||||
- You can choose whether your node shares Bitcoin block headers over the mesh, and your choices are remembered.
|
||||
- Version numbers display cleanly everywhere (no more doubled "v"), and "Back" buttons look and behave consistently across desktop and mobile.
|
||||
- For advanced testing, Settings includes an optional update & app source choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode, with the trusted origin remaining the default.
|
||||
- Ask your node things over the radio: send "!archy" for node status with no AI involved, or "!ai <your question>" in a direct message for an AI answer that comes back on the same path it arrived — with a model dropdown (Haiku, Sonnet, or Opus) and an "always allow" list in the Mesh AI Assistant panel.
|
||||
- The off-grid mesh radio no longer posts cryptic identity codes ("ARCHY:") to the shared public channel every minute.
|
||||
- Mesh contacts take care of themselves: new radios you hear are added automatically, "Clear All" really removes contacts (they return when in range), each contact shows a reachability dot, and the Peers list has a search box.
|
||||
- You can message standard meshcore phone apps and they can message you — readable text both ways, private replies instead of public-channel broadcasts.
|
||||
- Federated Archipelago nodes now appear on the Mesh Map.
|
||||
- Apps open as an overlay on top of whatever page you're on, in every display mode, instead of yanking you to a different screen; the Services tab groups apps by category with proper icons.
|
||||
- BTCPay Server keeps its plugins across restarts, connects to your node's own LND out of the box, and its invoices stay payable over private Lightning channels.
|
||||
- Fedimint federations show up in Wallet Settings again (the client app's configuration error is fixed), and Wallet Settings has tabbed sections for Cashu and Fedimint.
|
||||
- The phone companion app can upload and download files, edit saved server entries, opens non-embeddable apps in an in-app browser, and got a proper round launcher icon.
|
||||
- Six placeholder "apps" that were just web bookmarks (484.kitchen, arch-presentation, call-the-operator, nwnn, syntropy-institute, t-zero) are gone from the store.
|
||||
- The Bitcoin dashboard works fully offline (no more loading its styling from the internet), Gitea opens on the right port, and mempool, strfry, and Electrum stopped their restart/health-check loops.
|
||||
- Kiosk displays: HDMI audio no longer stutters, and a bad display-clone state no longer sticks after reboot.
|
||||
- Consistent dropdowns, toggles, tabs, and modal styling across Settings, Federation, and the rest of the UI; in Mesh chat, scrolling the conversation no longer also scrolls the contact list; "App Updates" and "App Registry" sit directly under Account in Settings.
|
||||
- A fresh node no longer reinstalls apps just because their definition file exists on disk — only apps you actually installed come back.
|
||||
|
||||
## v1.7.99-alpha (2026-06-17)
|
||||
|
||||
|
||||
@ -298,6 +298,25 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "barkd",
|
||||
"title": "Ark Wallet",
|
||||
"version": "0.3.0",
|
||||
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
|
||||
"icon": "/assets/img/app-icons/bark.png",
|
||||
"author": "Second",
|
||||
"category": "money",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
|
||||
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"3535:3535"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/barkd:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
|
||||
32
apps/barkd/Dockerfile
Normal file
32
apps/barkd/Dockerfile
Normal file
@ -0,0 +1,32 @@
|
||||
# barkd — Ark protocol wallet daemon (https://gitlab.com/ark-bitcoin/bark).
|
||||
# No official upstream image exists (their GitLab registry is empty), so we
|
||||
# package the pinned, checksum-verified release binary ourselves and push to
|
||||
# the node registry — same approach as fmcd. Keep the version in lockstep with
|
||||
# the REST shapes coded in core/archipelago/src/wallet/ark_client.rs (0.3.0).
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG BARKD_VERSION=0.3.0
|
||||
ARG BARKD_SHA256=8562fa27386bae666ed62fa95c92d40f7bdb20d22525f75799adfc16adaaedb3
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates curl && \
|
||||
curl -fsSL "https://gitlab.com/api/v4/projects/ark-bitcoin%2Fbark/packages/generic/release-assets/bark-${BARKD_VERSION}/barkd-${BARKD_VERSION}-linux-x86_64" \
|
||||
-o /usr/local/bin/barkd && \
|
||||
echo "${BARKD_SHA256} /usr/local/bin/barkd" | sha256sum -c - && \
|
||||
chmod a+x /usr/local/bin/barkd && \
|
||||
apt-get purge -y curl && apt-get autoremove -y && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod a+x /entrypoint.sh
|
||||
|
||||
# The wallet itself is created over REST by the node's Ark bridge
|
||||
# (wallet.ark-* RPCs) — the container just runs the daemon.
|
||||
ENV BARKD_DATADIR=/data \
|
||||
BARKD_BIND_HOST=0.0.0.0 \
|
||||
BARKD_BIND_PORT=3535
|
||||
|
||||
EXPOSE 3535
|
||||
VOLUME /data
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
15
apps/barkd/entrypoint.sh
Normal file
15
apps/barkd/entrypoint.sh
Normal file
@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Install the node-provided auth secret (64-char hex from the manifest's
|
||||
# generated barkd-secret) so the wallet bridge can derive the matching Bearer
|
||||
# token, then start the daemon. Without BARKD_SECRET, barkd generates its own
|
||||
# random token in the datadir and the bridge won't authenticate — so treat a
|
||||
# failed refresh as fatal rather than starting an unreachable daemon.
|
||||
set -eu
|
||||
|
||||
if [ -n "${BARKD_SECRET:-}" ]; then
|
||||
# `secret refresh` prints the Bearer token on stdout — never log it.
|
||||
barkd secret refresh --secret "$BARKD_SECRET" >/dev/null
|
||||
unset BARKD_SECRET
|
||||
fi
|
||||
|
||||
exec barkd
|
||||
76
apps/barkd/manifest.yml
Normal file
76
apps/barkd/manifest.yml
Normal file
@ -0,0 +1,76 @@
|
||||
app:
|
||||
id: barkd
|
||||
name: Ark Wallet
|
||||
version: 0.3.0
|
||||
description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.
|
||||
|
||||
container:
|
||||
# barkd packaged from the pinned upstream release binary (no usable
|
||||
# upstream image exists — their registry is empty). Built from
|
||||
# apps/barkd/Dockerfile and pushed to the node registry. Pin the tag to
|
||||
# match the REST shapes coded in core/archipelago/src/wallet/ark_client.rs
|
||||
# (validated against barkd 0.3.0 on signet, 2026-07-14).
|
||||
image: 146.59.87.168:3000/lfg2025/barkd:0.3.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# The entrypoint installs the shared secret below via `barkd secret
|
||||
# refresh` (so the wallet bridge can derive the matching Bearer token) and
|
||||
# execs the daemon. The Ark wallet itself is created over REST by the
|
||||
# bridge on first use (wallet.ark-* RPCs) with the node's ark_config
|
||||
# (default: Second's public signet server) — no host provisioning needed.
|
||||
generated_secrets:
|
||||
- name: barkd-secret
|
||||
kind: hex32
|
||||
secret_env:
|
||||
- key: BARKD_SECRET
|
||||
secret_file: barkd-secret
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
# barkd is a single wallet daemon (SQLite + a gRPC conn to the Ark server
|
||||
# + esplora polling); steady state is tiny. Cap it so a stuck sync can't
|
||||
# starve the node.
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
readonly_root: true
|
||||
# Needs outbound HTTPS to the Ark server (ark.signet.2nd.dev) and the
|
||||
# esplora chain source, plus the published REST port for the wallet
|
||||
# bridge. No inbound requirements beyond that.
|
||||
network_policy: bridge
|
||||
|
||||
ports:
|
||||
# barkd REST bound to 3535 in-container (BARKD_BIND_PORT); 3535 is free on
|
||||
# the host (see port_allocator.rs). The Rust bridge targets
|
||||
# http://127.0.0.1:3535.
|
||||
- host: 3535
|
||||
container: 3535
|
||||
protocol: tcp
|
||||
|
||||
volumes:
|
||||
# Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable
|
||||
# on-chain from this datadir (unilateral exit) — include it in backups.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/barkd
|
||||
target: /data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- BARKD_DATADIR=/data
|
||||
- BARKD_BIND_HOST=0.0.0.0
|
||||
- BARKD_BIND_PORT=3535
|
||||
|
||||
# All /api/v1/* routes require the Bearer token, so an HTTP probe would 401
|
||||
# forever — use a TCP probe like fmcd (the host-side lifecycle layer
|
||||
# verifies reachability).
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:3535
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
223
core/archipelago/src/api/rpc/ark.rs
Normal file
223
core/archipelago/src/api/rpc/ark.rs
Normal file
@ -0,0 +1,223 @@
|
||||
//! Ark protocol RPCs — bridge to the `barkd` sidecar.
|
||||
//!
|
||||
//! Companion to the Cashu RPCs in [`super::wallet`] and the Fedimint RPCs in
|
||||
//! [`super::fedimint`]. Holding VTXOs, joining rounds and unilateral exits are
|
||||
//! delegated to the barkd container via [`crate::wallet::ark_client::ArkClient`];
|
||||
//! here we expose the node's JSON-RPC surface. barkd keeps its own movement
|
||||
//! history, so unlike Fedimint there is no local transaction log.
|
||||
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::ark_client::{self, ArkClient};
|
||||
use anyhow::Result;
|
||||
|
||||
impl RpcHandler {
|
||||
/// `wallet.ark-status` — sidecar reachability, wallet fingerprint, network
|
||||
/// and Ark server parameters. Soft-fails into `available: false` so the
|
||||
/// settings UI can render an install/enable hint instead of an error.
|
||||
pub(super) async fn handle_wallet_ark_status(&self) -> Result<serde_json::Value> {
|
||||
let config = ark_client::load_config(&self.config.data_dir).await;
|
||||
let client = match ArkClient::from_node(&self.config.data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"wallet_ready": false,
|
||||
"config": config,
|
||||
}))
|
||||
}
|
||||
};
|
||||
// Make sure the wallet exists before reporting (idempotent, cheap once
|
||||
// created).
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
|
||||
let wallet = client.wallet_info().await.ok();
|
||||
let info = client.ark_info().await.ok();
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"wallet_ready": wallet.is_some(),
|
||||
"wallet": wallet,
|
||||
"ark_info": info,
|
||||
"config": config,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-balance` — off-chain (spendable + pending) and on-chain
|
||||
/// sats. Soft-fails to zeros so unified balances still render.
|
||||
pub(super) async fn handle_wallet_ark_balance(&self) -> Result<serde_json::Value> {
|
||||
let client = match ArkClient::from_node(&self.config.data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Ok(serde_json::json!({
|
||||
"balance_sats": 0,
|
||||
"spendable_sats": 0,
|
||||
"pending_sats": 0,
|
||||
"onchain_sats": 0,
|
||||
}))
|
||||
}
|
||||
};
|
||||
let bal = client.balance().await.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let sat = |key: &str| bal.get(key).and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let spendable = sat("spendable_sat");
|
||||
let pending = sat("pending_in_round_sat")
|
||||
+ sat("pending_board_sat")
|
||||
+ sat("pending_lightning_send_sat")
|
||||
+ sat("claimable_lightning_receive_sat")
|
||||
+ bal.get("pending_exit_sat").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let onchain = client
|
||||
.onchain_balance()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|b| {
|
||||
b.get("total_sat")
|
||||
.or_else(|| b.get("confirmed_sat"))
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
Ok(serde_json::json!({
|
||||
"balance_sats": spendable,
|
||||
"spendable_sats": spendable,
|
||||
"pending_sats": pending,
|
||||
"onchain_sats": onchain,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-address` — fresh Ark (`tark1…`) receive address; pass
|
||||
/// `{"onchain": true}` for an on-chain boarding address instead.
|
||||
pub(super) async fn handle_wallet_ark_address(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let onchain = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("onchain"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let address = if onchain {
|
||||
client.onchain_address().await?
|
||||
} else {
|
||||
client.ark_address().await?
|
||||
};
|
||||
Ok(serde_json::json!({ "address": address, "onchain": onchain }))
|
||||
}
|
||||
|
||||
/// `wallet.ark-send` — pay an Ark address, BOLT11 invoice, LNURL or
|
||||
/// lightning address from Ark funds.
|
||||
pub(super) async fn handle_wallet_ark_send(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let destination = params
|
||||
.get("destination")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing destination"))?;
|
||||
// Optional for BOLT11 invoices that carry their own amount.
|
||||
let amount_sats = params.get("amount_sats").and_then(|v| v.as_u64());
|
||||
if amount_sats == Some(0) {
|
||||
return Err(anyhow::anyhow!("Amount must be greater than zero"));
|
||||
}
|
||||
let comment = params.get("comment").and_then(|v| v.as_str());
|
||||
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let movement = client.send(destination, amount_sats, comment).await?;
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"movement": movement,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `wallet.ark-invoice` — BOLT11 invoice that lands as Ark funds when paid.
|
||||
pub(super) async fn handle_wallet_ark_invoice(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let amount_sats = params
|
||||
.get("amount_sats")
|
||||
.and_then(|v| v.as_u64())
|
||||
.filter(|&v| v > 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
|
||||
|
||||
let _ = ark_client::ensure_wallet(&self.config.data_dir).await;
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.lightning_invoice(amount_sats).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-board` — lift on-chain funds into Ark VTXOs. Omitting
|
||||
/// `amount_sats` boards everything.
|
||||
pub(super) async fn handle_wallet_ark_board(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let amount_sats = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("amount_sats"))
|
||||
.and_then(|v| v.as_u64());
|
||||
if amount_sats == Some(0) {
|
||||
return Err(anyhow::anyhow!("Amount must be greater than zero"));
|
||||
}
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.board(amount_sats).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-offboard` — collaboratively move all VTXOs back on-chain,
|
||||
/// optionally to a provided address (defaults to the wallet's own).
|
||||
pub(super) async fn handle_wallet_ark_offboard(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let address = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("address"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let client = ArkClient::from_node(&self.config.data_dir).await?;
|
||||
let res = client.offboard_all(address).await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// `wallet.ark-history` — barkd movements mapped to the unified
|
||||
/// transaction shape (kind = "ark"), newest first.
|
||||
pub(super) async fn handle_wallet_ark_history(&self) -> Result<serde_json::Value> {
|
||||
let mut transactions = ark_client::load_ark_txs(&self.config.data_dir).await;
|
||||
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
Ok(serde_json::json!({ "transactions": transactions }))
|
||||
}
|
||||
|
||||
/// `wallet.ark-configure` — set the Ark server / esplora / network used
|
||||
/// when the barkd wallet is (re)created. Does NOT migrate an existing
|
||||
/// wallet: barkd binds a wallet to its Ark server at creation.
|
||||
pub(super) async fn handle_wallet_ark_configure(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let mut config = ark_client::load_config(&self.config.data_dir).await;
|
||||
for (key, field) in [
|
||||
("network", &mut config.network as &mut String),
|
||||
("ark_server", &mut config.ark_server),
|
||||
("esplora", &mut config.esplora),
|
||||
] {
|
||||
if let Some(v) = params.get(key).and_then(|v| v.as_str()) {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() {
|
||||
*field = v.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matches!(config.network.as_str(), "signet" | "mainnet" | "regtest") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"network must be one of: signet, mainnet, regtest"
|
||||
));
|
||||
}
|
||||
ark_client::save_config(&self.config.data_dir, &config).await?;
|
||||
Ok(serde_json::json!({ "config": config }))
|
||||
}
|
||||
}
|
||||
@ -258,6 +258,17 @@ impl RpcHandler {
|
||||
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
|
||||
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
|
||||
|
||||
// Ark protocol (via barkd sidecar)
|
||||
"wallet.ark-status" => self.handle_wallet_ark_status().await,
|
||||
"wallet.ark-balance" => self.handle_wallet_ark_balance().await,
|
||||
"wallet.ark-address" => self.handle_wallet_ark_address(params).await,
|
||||
"wallet.ark-send" => self.handle_wallet_ark_send(params).await,
|
||||
"wallet.ark-invoice" => self.handle_wallet_ark_invoice(params).await,
|
||||
"wallet.ark-board" => self.handle_wallet_ark_board(params).await,
|
||||
"wallet.ark-offboard" => self.handle_wallet_ark_offboard(params).await,
|
||||
"wallet.ark-history" => self.handle_wallet_ark_history().await,
|
||||
"wallet.ark-configure" => self.handle_wallet_ark_configure(params).await,
|
||||
|
||||
// Container registries
|
||||
"registry.list" => self.handle_registry_list().await,
|
||||
"registry.add" => self.handle_registry_add(params).await,
|
||||
|
||||
@ -64,8 +64,9 @@ impl RpcHandler {
|
||||
.and_then(|p| p.get("trust_level"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| {
|
||||
TrustLevel::parse(s)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid trust_level: {s} (expected trusted|observer)"))
|
||||
TrustLevel::parse(s).ok_or_else(|| {
|
||||
anyhow::anyhow!("Invalid trust_level: {s} (expected trusted|observer)")
|
||||
})
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(TrustLevel::Trusted);
|
||||
|
||||
@ -292,9 +292,7 @@ impl RpcHandler {
|
||||
let r_hash_hex = inv
|
||||
.get("r_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|b64| {
|
||||
base64::engine::general_purpose::STANDARD.decode(b64).ok()
|
||||
})
|
||||
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
|
||||
.map(hex::encode)
|
||||
.unwrap_or_default();
|
||||
transactions.push(serde_json::json!({
|
||||
|
||||
@ -56,9 +56,8 @@ impl RpcHandler {
|
||||
let words =
|
||||
crate::seed::load_lnd_aezeed_encrypted(&self.config.data_dir, &node_secret).await;
|
||||
node_secret.zeroize();
|
||||
let words = words.map_err(|_| {
|
||||
anyhow::anyhow!("Could not decrypt the saved Lightning seed backup")
|
||||
})?;
|
||||
let words = words
|
||||
.map_err(|_| anyhow::anyhow!("Could not decrypt the saved Lightning seed backup"))?;
|
||||
|
||||
let word_count = words.len();
|
||||
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
|
||||
|
||||
@ -478,7 +478,8 @@ impl RpcHandler {
|
||||
bytes,
|
||||
};
|
||||
let payload = message_types::encode_payload(&content)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
|
||||
let envelope =
|
||||
TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
|
||||
let wire = envelope.to_wire()?;
|
||||
if use_resource_transfer {
|
||||
svc.send_content_resource(
|
||||
@ -583,8 +584,7 @@ impl RpcHandler {
|
||||
.map(|d| nodes.iter().any(|n| &n.did == d))
|
||||
.unwrap_or(false);
|
||||
|
||||
let est_seconds =
|
||||
(size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
||||
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
||||
|
||||
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
|
||||
let (tier, reason) = if size <= MESH_AUTO_MAX {
|
||||
@ -596,7 +596,10 @@ impl RpcHandler {
|
||||
("auto-mesh", "No Tor path — sending inline over mesh")
|
||||
}
|
||||
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX {
|
||||
("resource-mesh", "Sending directly over LoRa via a Reticulum resource transfer")
|
||||
(
|
||||
"resource-mesh",
|
||||
"Sending directly over LoRa via a Reticulum resource transfer",
|
||||
)
|
||||
} else if size <= TOR_LARGE_WARN {
|
||||
if has_tor {
|
||||
("tor-only", "Too large for mesh — Tor only")
|
||||
|
||||
@ -214,8 +214,9 @@ pub(super) fn extract_client_ip(parts: &hyper::http::request::Parts) -> IpAddr {
|
||||
Some(ip) => ip,
|
||||
// No socket info recorded (shouldn't happen in the server path);
|
||||
// fall back to the pre-extension behavior.
|
||||
None => forwarded_client_ip(&parts.headers)
|
||||
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
|
||||
None => {
|
||||
forwarded_client_ip(&parts.headers).unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -234,10 +235,7 @@ mod client_ip_tests {
|
||||
use super::*;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
fn parts_with(
|
||||
peer: Option<&str>,
|
||||
real_ip: Option<&str>,
|
||||
) -> hyper::http::request::Parts {
|
||||
fn parts_with(peer: Option<&str>, real_ip: Option<&str>) -> hyper::http::request::Parts {
|
||||
let mut builder = hyper::Request::builder().uri("/rpc/v1");
|
||||
if let Some(ip) = real_ip {
|
||||
builder = builder.header("x-real-ip", ip);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
mod analytics;
|
||||
mod ark;
|
||||
mod auth;
|
||||
mod backup_rpc;
|
||||
mod bitcoin;
|
||||
@ -54,11 +55,11 @@ use hyper::{Request, Response, StatusCode};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error};
|
||||
|
||||
pub use middleware::PeerAddr;
|
||||
use middleware::{
|
||||
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
|
||||
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
|
||||
};
|
||||
pub use middleware::PeerAddr;
|
||||
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
|
||||
|
||||
/// Default dev password when no user is set up (matches mock-backend).
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
use super::RpcHandler;
|
||||
use crate::network::router as net_router;
|
||||
use anyhow::Result;
|
||||
use archipelago_openwrt::{
|
||||
detect,
|
||||
router::Router,
|
||||
tollgate::{self, TollGateConfig},
|
||||
wan,
|
||||
wifi_scan,
|
||||
wan, wifi_scan,
|
||||
};
|
||||
use crate::network::router as net_router;
|
||||
|
||||
/// Default port for the local Cashu mint (nutshell / cashu-mint app).
|
||||
const LOCAL_MINT_PORT: u16 = 3338;
|
||||
@ -23,7 +22,9 @@ impl RpcHandler {
|
||||
) -> Result<serde_json::Value> {
|
||||
let p = params.unwrap_or_default();
|
||||
let subnet: [u8; 4] = parse_ipv4(
|
||||
p.get("subnet").and_then(|v| v.as_str()).unwrap_or("192.168.1.0"),
|
||||
p.get("subnet")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("192.168.1.0"),
|
||||
)?;
|
||||
let prefix = p.get("prefix").and_then(|v| v.as_u64()).unwrap_or(24) as u8;
|
||||
let ssh_user = p
|
||||
@ -59,8 +60,18 @@ impl RpcHandler {
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
|
||||
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
|
||||
.or_else(|| {
|
||||
if saved.configured {
|
||||
Some(saved.address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No router configured — provide host or call router.configure first"
|
||||
)
|
||||
})?;
|
||||
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
@ -92,11 +103,14 @@ impl RpcHandler {
|
||||
None,
|
||||
Some(&ssh_user),
|
||||
Some(&ssh_password),
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// System info
|
||||
let release = router.run_ok("cat /etc/openwrt_release").unwrap_or_default();
|
||||
let release = router
|
||||
.run_ok("cat /etc/openwrt_release")
|
||||
.unwrap_or_default();
|
||||
let hostname = router
|
||||
.uci_get("system.@system[0].hostname")
|
||||
.unwrap_or_else(|_| "unknown".into());
|
||||
@ -170,8 +184,18 @@ impl RpcHandler {
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
|
||||
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
|
||||
.or_else(|| {
|
||||
if saved.configured {
|
||||
Some(saved.address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No router configured — provide host or call router.configure first"
|
||||
)
|
||||
})?;
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
@ -200,10 +224,7 @@ impl RpcHandler {
|
||||
.get("step_size_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(60_000),
|
||||
min_steps: p
|
||||
.get("min_steps")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(1) as u32,
|
||||
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
|
||||
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
};
|
||||
|
||||
@ -229,13 +250,34 @@ impl RpcHandler {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p.get("host").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
|
||||
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
|
||||
let ssh_user = p.get("ssh_user").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone()).unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p.get("ssh_password").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone()).unwrap_or_default();
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
if saved.configured {
|
||||
Some(saved.address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No router configured — provide host or call router.configure first"
|
||||
)
|
||||
})?;
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
@ -243,13 +285,15 @@ impl RpcHandler {
|
||||
let networks = wifi_scan::scan_networks(&router)?;
|
||||
let result: Vec<serde_json::Value> = networks
|
||||
.iter()
|
||||
.map(|n| serde_json::json!({
|
||||
"ssid": n.ssid,
|
||||
"bssid": n.bssid,
|
||||
"signal": n.signal,
|
||||
"channel": n.channel,
|
||||
"encryption": n.encryption,
|
||||
}))
|
||||
.map(|n| {
|
||||
serde_json::json!({
|
||||
"ssid": n.ssid,
|
||||
"bssid": n.bssid,
|
||||
"signal": n.signal,
|
||||
"channel": n.channel,
|
||||
"encryption": n.encryption,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::json!({ "networks": result }))
|
||||
@ -265,18 +309,50 @@ impl RpcHandler {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p.get("host").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
|
||||
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
|
||||
let ssh_user = p.get("ssh_user").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone()).unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p.get("ssh_password").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone()).unwrap_or_default();
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
if saved.configured {
|
||||
Some(saved.address.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No router configured — provide host or call router.configure first"
|
||||
)
|
||||
})?;
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let ssid = p.get("ssid").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?.to_string();
|
||||
let password = p.get("password").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let encryption = p.get("encryption").and_then(|v| v.as_str()).unwrap_or("psk2").to_string();
|
||||
let ssid = p
|
||||
.get("ssid")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required field: ssid"))?
|
||||
.to_string();
|
||||
let password = p
|
||||
.get("password")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let encryption = p
|
||||
.get("encryption")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("psk2")
|
||||
.to_string();
|
||||
let dhcp_start = p.get("dhcp_start").and_then(|v| v.as_u64()).unwrap_or(100) as u32;
|
||||
let dhcp_limit = p.get("dhcp_limit").and_then(|v| v.as_u64()).unwrap_or(150) as u32;
|
||||
let masq = p.get("masq").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
@ -284,7 +360,14 @@ impl RpcHandler {
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
let config = wan::WispConfig { ssid: ssid.clone(), password, encryption, dhcp_start, dhcp_limit, masq };
|
||||
let config = wan::WispConfig {
|
||||
ssid: ssid.clone(),
|
||||
password,
|
||||
encryption,
|
||||
dhcp_start,
|
||||
dhcp_limit,
|
||||
masq,
|
||||
};
|
||||
wan::configure_wisp(&router, &config)?;
|
||||
|
||||
Ok(serde_json::json!({ "ok": true, "host": host, "ssid": ssid }))
|
||||
@ -325,14 +408,16 @@ fn parse_wifi_interfaces(raw: &str) -> Vec<serde_json::Value> {
|
||||
let mut ifaces: Vec<serde_json::Value> = sections
|
||||
.into_iter()
|
||||
.filter(|(_, f)| f.get("mode").map(|m| m == "ap").unwrap_or(false))
|
||||
.map(|(name, f)| serde_json::json!({
|
||||
"section": name,
|
||||
"ssid": f.get("ssid").cloned().unwrap_or_default(),
|
||||
"device": f.get("device").cloned().unwrap_or_default(),
|
||||
"encryption": f.get("encryption").cloned().unwrap_or_else(|| "none".into()),
|
||||
"network": f.get("network").cloned().unwrap_or_default(),
|
||||
"disabled": f.get("disabled").map(|v| v == "1").unwrap_or(false),
|
||||
}))
|
||||
.map(|(name, f)| {
|
||||
serde_json::json!({
|
||||
"section": name,
|
||||
"ssid": f.get("ssid").cloned().unwrap_or_default(),
|
||||
"device": f.get("device").cloned().unwrap_or_default(),
|
||||
"encryption": f.get("encryption").cloned().unwrap_or_else(|| "none".into()),
|
||||
"network": f.get("network").cloned().unwrap_or_default(),
|
||||
"disabled": f.get("disabled").map(|v| v == "1").unwrap_or(false),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
ifaces.sort_by_key(|v| v["section"].as_str().unwrap_or("").to_string());
|
||||
|
||||
@ -378,9 +378,11 @@ where
|
||||
|
||||
// Fail fast if any missing dependency has no installed container
|
||||
// under any name variant — waiting cannot satisfy it.
|
||||
let some_dep_not_installed = missing
|
||||
.iter()
|
||||
.any(|dep| !dep.containers.iter().any(|c| existing.iter().any(|e| e == c)));
|
||||
let some_dep_not_installed = missing.iter().any(|dep| {
|
||||
!dep.containers
|
||||
.iter()
|
||||
.any(|c| existing.iter().any(|e| e == c))
|
||||
});
|
||||
if some_dep_not_installed {
|
||||
let msg = match check_install_deps(package_id, &running) {
|
||||
Err(e) => e.to_string(),
|
||||
@ -879,8 +881,10 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Collects "Waiting for X to start…" labels emitted during the wait.
|
||||
fn label_sink() -> (Arc<Mutex<Vec<String>>>, impl FnMut(String) -> std::future::Ready<()>)
|
||||
{
|
||||
fn label_sink() -> (
|
||||
Arc<Mutex<Vec<String>>>,
|
||||
impl FnMut(String) -> std::future::Ready<()>,
|
||||
) {
|
||||
let labels = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = {
|
||||
let labels = Arc::clone(&labels);
|
||||
@ -930,7 +934,8 @@ mod tests {
|
||||
// so async_lifecycle removes the optimistic Installing entry.
|
||||
assert!(err.downcast_ref::<DependencyGateError>().is_some());
|
||||
assert!(
|
||||
err.to_string().contains("LND requires a running Bitcoin node"),
|
||||
err.to_string()
|
||||
.contains("LND requires a running Bitcoin node"),
|
||||
"unexpected message: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
@ -63,7 +63,9 @@ impl RpcHandler {
|
||||
|
||||
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
|
||||
vec![orchestrator_app_id(package_id).to_string()]
|
||||
} else if let Some(members) = orchestrator_stack_members(self.orchestrator.is_some(), package_id) {
|
||||
} else if let Some(members) =
|
||||
orchestrator_stack_members(self.orchestrator.is_some(), package_id)
|
||||
{
|
||||
members
|
||||
} else {
|
||||
ordered_containers_for_start(package_id).await?
|
||||
@ -170,11 +172,12 @@ impl RpcHandler {
|
||||
// fallback to a raw `podman stop` that races systemd over the unit
|
||||
// (immich, gate 2026-07-09).
|
||||
let to_stop_ids = if !single_orchestrator_app {
|
||||
orchestrator_stack_members(self.orchestrator.is_some(), package_id)
|
||||
.map(|mut members| {
|
||||
orchestrator_stack_members(self.orchestrator.is_some(), package_id).map(
|
||||
|mut members| {
|
||||
members.reverse();
|
||||
members
|
||||
})
|
||||
},
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@ -280,7 +283,9 @@ impl RpcHandler {
|
||||
let companion_app_id = package_id_owned.clone();
|
||||
let to_restart = if single_orchestrator_app {
|
||||
vec![orchestrator_app_id(package_id).to_string()]
|
||||
} else if let Some(members) = orchestrator_stack_members(self.orchestrator.is_some(), package_id) {
|
||||
} else if let Some(members) =
|
||||
orchestrator_stack_members(self.orchestrator.is_some(), package_id)
|
||||
{
|
||||
// Restart stacks via member APP ids: restarting by live container
|
||||
// name podman-stops the quadlet container (systemd --rm removes
|
||||
// it) and the start half then finds no such container — a 5-min
|
||||
@ -2160,7 +2165,9 @@ mod tests {
|
||||
assert!(is_missing_container_error(
|
||||
"Error: no such object: \"mempool\""
|
||||
));
|
||||
assert!(is_missing_container_error("Error: no such container mempool"));
|
||||
assert!(is_missing_container_error(
|
||||
"Error: no such container mempool"
|
||||
));
|
||||
assert!(is_missing_container_error(
|
||||
"Error: no container with name or id \"x\" found"
|
||||
));
|
||||
|
||||
@ -100,7 +100,8 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
let location_file = self.config.data_dir.join("server-location.json");
|
||||
let payload = serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location });
|
||||
let payload =
|
||||
serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location });
|
||||
tokio::fs::write(&location_file, serde_json::to_vec(&payload)?)
|
||||
.await
|
||||
.context("Failed to write server location")?;
|
||||
@ -130,7 +131,9 @@ impl RpcHandler {
|
||||
/// resolves to on the LAN (avahi-daemon advertises `<hostname>.local`).
|
||||
/// Lets Settings show users where to reach this node over HTTPS for
|
||||
/// features (mic/camera access) that require a secure context.
|
||||
pub(in crate::api::rpc) async fn handle_system_get_hostname(&self) -> Result<serde_json::Value> {
|
||||
pub(in crate::api::rpc) async fn handle_system_get_hostname(
|
||||
&self,
|
||||
) -> Result<serde_json::Value> {
|
||||
let hostname = tokio::fs::read_to_string("/etc/hostname")
|
||||
.await
|
||||
.map(|s| s.trim().to_string())
|
||||
@ -401,7 +404,8 @@ async fn set_system_hostname(hostname: &str) -> Result<()> {
|
||||
/// top once a node has been renamed away from the install-time default.
|
||||
async fn regenerate_tls_cert(hostname: &str) -> Result<()> {
|
||||
let subj = format!("/C=XX/ST=Bitcoin/L=Node/O=Archipelago/CN={hostname}");
|
||||
let san = format!("subjectAltName=DNS:{hostname},DNS:{hostname}.local,DNS:localhost,IP:127.0.0.1");
|
||||
let san =
|
||||
format!("subjectAltName=DNS:{hostname},DNS:{hostname}.local,DNS:localhost,IP:127.0.0.1");
|
||||
let output = tokio::process::Command::new("/usr/bin/sudo")
|
||||
.args([
|
||||
"-n",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use super::RpcHandler;
|
||||
use crate::wallet::{ecash, fedimint_client, profits};
|
||||
use crate::wallet::{ark_client, ecash, fedimint_client, profits};
|
||||
use anyhow::Result;
|
||||
|
||||
/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form)
|
||||
@ -21,13 +21,16 @@ impl RpcHandler {
|
||||
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
};
|
||||
// Spendable Ark (barkd) balance, same best-effort contract.
|
||||
let ark_sats = ark_client::spendable_sats_or_zero(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!({
|
||||
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
|
||||
// spendable amount across Cashu + Fedimint.
|
||||
// spendable amount across Cashu + Fedimint + Ark.
|
||||
"balance_sats": cashu_sats,
|
||||
"cashu_sats": cashu_sats,
|
||||
"fedimint_sats": fedimint_sats,
|
||||
"total_sats": cashu_sats + fedimint_sats,
|
||||
"ark_sats": ark_sats,
|
||||
"total_sats": cashu_sats + fedimint_sats + ark_sats,
|
||||
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
|
||||
"mint_url": wallet.mint_url,
|
||||
}))
|
||||
@ -181,6 +184,8 @@ impl RpcHandler {
|
||||
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
|
||||
let mut transactions = wallet.transactions;
|
||||
transactions.extend(fedimint_client::load_fedimint_txs(&self.config.data_dir).await);
|
||||
// Ark movements from barkd (kind="ark"), best-effort like Fedimint.
|
||||
transactions.extend(ark_client::load_ark_txs(&self.config.data_dir).await);
|
||||
// Sort by RFC-3339 timestamp descending (string compare is valid for
|
||||
// same-offset RFC-3339), newest first.
|
||||
transactions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
|
||||
@ -53,9 +53,7 @@ pub fn stack_member_app_ids(package_id: &str) -> &'static [&'static str] {
|
||||
// The legacy umbrella id maps to the split stack (the orchestrator's
|
||||
// umbrella alias handles this too; listing it here keeps the RPC
|
||||
// layer's fan-out explicit).
|
||||
"mempool" | "mempool-web" => {
|
||||
&["archy-mempool-db", "mempool-api", "archy-mempool-web"]
|
||||
}
|
||||
"mempool" | "mempool-web" => &["archy-mempool-db", "mempool-api", "archy-mempool-web"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,7 +131,9 @@ pub async fn ensure_doctor_installed() {
|
||||
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
match run_apps_dir_repair().await {
|
||||
Ok(true) => info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps"),
|
||||
Ok(true) => {
|
||||
info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps")
|
||||
}
|
||||
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
|
||||
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
|
||||
}
|
||||
|
||||
@ -400,10 +400,7 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh>
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
|
||||
}
|
||||
|
||||
async fn fetch_one(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
) -> anyhow::Result<(AppCatalog, String)> {
|
||||
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<(AppCatalog, String)> {
|
||||
let resp = client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("HTTP {}", resp.status());
|
||||
@ -510,7 +507,10 @@ mod tests {
|
||||
// on the apps HashMap's nondeterministic key order (seen live on .228).
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let body = r#"{"schema":1,"apps":{"demo":{"version":"1.0.0"}}}"#;
|
||||
assert!(write_cache(dir.path(), body).unwrap(), "first write is a change");
|
||||
assert!(
|
||||
write_cache(dir.path(), body).unwrap(),
|
||||
"first write is a change"
|
||||
);
|
||||
assert!(
|
||||
!write_cache(dir.path(), body).unwrap(),
|
||||
"identical rewrite is not a change"
|
||||
|
||||
@ -372,6 +372,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/minmoto/fmcd".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"barkd" | "bark" => AppMetadata {
|
||||
title: "Ark Wallet".to_string(),
|
||||
description: "Ark protocol wallet daemon (barkd) — self-custodial off-chain bitcoin via an Ark server (signet)".to_string(),
|
||||
icon: "/assets/img/app-icons/bark.png".to_string(),
|
||||
repo: "https://gitlab.com/ark-bitcoin/bark".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"morphos" | "morphos-server" => AppMetadata {
|
||||
title: "Morphos".to_string(),
|
||||
description: "Self-hosted file converter".to_string(),
|
||||
|
||||
@ -7,12 +7,8 @@
|
||||
/// Registries images may be pulled from with an explicit host part.
|
||||
/// (git.tx1138.com was removed 2026-07-10: the host is retired and must
|
||||
/// never be pulled through again.)
|
||||
pub const TRUSTED_REGISTRIES: &[&str] = &[
|
||||
"docker.io",
|
||||
"ghcr.io",
|
||||
"localhost",
|
||||
"146.59.87.168:3000",
|
||||
];
|
||||
pub const TRUSTED_REGISTRIES: &[&str] =
|
||||
&["docker.io", "ghcr.io", "localhost", "146.59.87.168:3000"];
|
||||
|
||||
/// Validate a container image reference.
|
||||
///
|
||||
|
||||
@ -727,14 +727,12 @@ pub async fn ensure_btcpay_lnd_connection_secret(secrets_dir: &std::path::Path)
|
||||
Ok(s) => s,
|
||||
Err(_) => return Ok(()), // LND not installed/provisioned yet
|
||||
};
|
||||
let thumbprint =
|
||||
cert_sha256_thumbprint(&pem).context("computing LND tls.cert thumbprint")?;
|
||||
let thumbprint = cert_sha256_thumbprint(&pem).context("computing LND tls.cert thumbprint")?;
|
||||
|
||||
let target = secrets_dir.join(BTCPAY_LND_CONNECTION_SECRET);
|
||||
// Fast path (no sudo): existing secret already pins the current cert.
|
||||
if let Ok(existing) = fs::read_to_string(&target).await {
|
||||
if !existing.trim().is_empty()
|
||||
&& existing.contains(&format!("certthumbprint={thumbprint}"))
|
||||
if !existing.trim().is_empty() && existing.contains(&format!("certthumbprint={thumbprint}"))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
@ -782,7 +780,9 @@ mod tests {
|
||||
conf_path: tmp.path().join("lnd/lnd.conf"),
|
||||
};
|
||||
|
||||
let out = ensure_config(&paths, "secret", "bitcoin-knots").await.unwrap();
|
||||
let out = ensure_config(&paths, "secret", "bitcoin-knots")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out, EnsureOutcome::Written);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
assert!(conf.contains("bitcoin.active=true"));
|
||||
@ -801,11 +801,15 @@ mod tests {
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "first", "bitcoin-knots").await.unwrap(),
|
||||
ensure_config(&paths, "first", "bitcoin-knots")
|
||||
.await
|
||||
.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "second", "bitcoin-knots").await.unwrap(),
|
||||
ensure_config(&paths, "second", "bitcoin-knots")
|
||||
.await
|
||||
.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
@ -854,7 +858,9 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
ensure_config(&paths, "repaired", "bitcoin-knots").await.unwrap(),
|
||||
ensure_config(&paths, "repaired", "bitcoin-knots")
|
||||
.await
|
||||
.unwrap(),
|
||||
EnsureOutcome::Written
|
||||
);
|
||||
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
|
||||
|
||||
@ -1468,9 +1468,7 @@ impl ProdContainerOrchestrator {
|
||||
);
|
||||
}
|
||||
}
|
||||
for (port, a, b) in
|
||||
host_port_collisions(state.manifests.values().map(|lm| &lm.manifest))
|
||||
{
|
||||
for (port, a, b) in host_port_collisions(state.manifests.values().map(|lm| &lm.manifest)) {
|
||||
tracing::error!(
|
||||
port,
|
||||
app_a = %a,
|
||||
@ -1666,7 +1664,10 @@ impl ProdContainerOrchestrator {
|
||||
// and left the unit down for minutes (.228 mempool frontend, gate
|
||||
// 2026-07-09). Skip this cycle; the worker owns the outcome.
|
||||
if crate::app_ops::lifecycle_op_in_flight(&app_id) {
|
||||
report.record(&app_id, ReconcileAction::Left("lifecycle-op-in-flight".into()));
|
||||
report.record(
|
||||
&app_id,
|
||||
ReconcileAction::Left("lifecycle-op-in-flight".into()),
|
||||
);
|
||||
crate::crash_recovery::pending_boot_start_done(&app_id);
|
||||
crate::crash_recovery::pending_boot_start_done(&container_name);
|
||||
continue;
|
||||
@ -1773,10 +1774,9 @@ impl ProdContainerOrchestrator {
|
||||
|
||||
{
|
||||
let state = self.state.read().await;
|
||||
for (app, dep) in degraded_running_apps(
|
||||
&report,
|
||||
state.manifests.values().map(|lm| &lm.manifest),
|
||||
) {
|
||||
for (app, dep) in
|
||||
degraded_running_apps(&report, state.manifests.values().map(|lm| &lm.manifest))
|
||||
{
|
||||
tracing::error!(
|
||||
app_id = %app,
|
||||
dependency = %dep,
|
||||
@ -3184,8 +3184,7 @@ impl ProdContainerOrchestrator {
|
||||
// `optional` secret_env — btcpay must still start when LND is
|
||||
// absent or the derivation fails, so log-and-continue.
|
||||
if let Err(e) =
|
||||
crate::container::lnd::ensure_btcpay_lnd_connection_secret(&self.secrets_dir)
|
||||
.await
|
||||
crate::container::lnd::ensure_btcpay_lnd_connection_secret(&self.secrets_dir).await
|
||||
{
|
||||
tracing::warn!(error = %e, "btcpay-lnd-connection secret not generated; btcpay will run without the internal LND node");
|
||||
}
|
||||
@ -3260,20 +3259,17 @@ impl ProdContainerOrchestrator {
|
||||
manifest.app.container.secret_env_refs = Vec::new();
|
||||
manifest.app.container.secret_env_hash = None;
|
||||
} else {
|
||||
let hash =
|
||||
archipelago_container::manifest::secret_env_content_hash(&secret_bearing);
|
||||
let hash = archipelago_container::manifest::secret_env_content_hash(&secret_bearing);
|
||||
let app_id = manifest.app.id.clone();
|
||||
manifest.app.container.secret_env_refs = secret_bearing
|
||||
.into_iter()
|
||||
.map(|(key, value)| archipelago_container::manifest::SecretEnvRef {
|
||||
secret_name: format!(
|
||||
"archy-env-{}-{}",
|
||||
app_id,
|
||||
key.to_ascii_lowercase()
|
||||
),
|
||||
env_key: key,
|
||||
value,
|
||||
})
|
||||
.map(
|
||||
|(key, value)| archipelago_container::manifest::SecretEnvRef {
|
||||
secret_name: format!("archy-env-{}-{}", app_id, key.to_ascii_lowercase()),
|
||||
env_key: key,
|
||||
value,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
manifest.app.container.secret_env_hash = Some(hash.clone());
|
||||
|
||||
@ -3281,12 +3277,7 @@ impl ProdContainerOrchestrator {
|
||||
// the steady-state reconcile free: podman is only consulted when
|
||||
// the resolved content actually changed (or on first touch after
|
||||
// boot). Mock runtimes no-op via the trait default.
|
||||
let cached = self
|
||||
.env_secret_cache
|
||||
.lock()
|
||||
.await
|
||||
.get(&app_id)
|
||||
.cloned();
|
||||
let cached = self.env_secret_cache.lock().await.get(&app_id).cloned();
|
||||
if cached.as_deref() != Some(hash.as_str()) {
|
||||
self.runtime
|
||||
.ensure_env_secrets(&manifest.app.container.secret_env_refs)
|
||||
@ -3926,7 +3917,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
|
||||
async fn start(&self, app_id: &str) -> Result<()> {
|
||||
if let Some(members) = self.mempool_umbrella_members(app_id).await {
|
||||
tracing::info!(app_id, "starting legacy umbrella id via split-stack members");
|
||||
tracing::info!(
|
||||
app_id,
|
||||
"starting legacy umbrella id via split-stack members"
|
||||
);
|
||||
for (i, member) in members.iter().enumerate() {
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
@ -3969,7 +3963,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
|
||||
async fn stop(&self, app_id: &str) -> Result<()> {
|
||||
if let Some(members) = self.mempool_umbrella_members(app_id).await {
|
||||
tracing::info!(app_id, "stopping legacy umbrella id via split-stack members");
|
||||
tracing::info!(
|
||||
app_id,
|
||||
"stopping legacy umbrella id via split-stack members"
|
||||
);
|
||||
for member in members.iter().rev() {
|
||||
Box::pin(self.stop(member))
|
||||
.await
|
||||
@ -4036,7 +4033,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
|
||||
async fn restart(&self, app_id: &str) -> Result<()> {
|
||||
if let Some(members) = self.mempool_umbrella_members(app_id).await {
|
||||
tracing::info!(app_id, "restarting legacy umbrella id via split-stack members");
|
||||
tracing::info!(
|
||||
app_id,
|
||||
"restarting legacy umbrella id via split-stack members"
|
||||
);
|
||||
for (i, member) in members.iter().enumerate() {
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
@ -4239,9 +4239,8 @@ fn command_argv_drifted(
|
||||
// therefore Config.Cmd) has them as spaces. Normalize both sides so the
|
||||
// same script never reads as drift over line breaks alone (bitcoin-knots
|
||||
// and fedimint-gateway on .228, 2026-07-08).
|
||||
let norm = |v: &[String]| -> Vec<String> {
|
||||
v.iter().map(|s| s.replace(['\r', '\n'], " ")).collect()
|
||||
};
|
||||
let norm =
|
||||
|v: &[String]| -> Vec<String> { v.iter().map(|s| s.replace(['\r', '\n'], " ")).collect() };
|
||||
let current_cmd = norm(current_cmd);
|
||||
let expected_args = norm(expected_args);
|
||||
let Some(expected_entry) = expected_entry else {
|
||||
@ -5508,14 +5507,20 @@ app:
|
||||
("bitcoin-knots", ReconcileAction::Installed),
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert_eq!(cascade_pairs_for_report(&r, &none), vec![("bitcoin-knots", "lnd")]);
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&r, &none),
|
||||
vec![("bitcoin-knots", "lnd")]
|
||||
);
|
||||
|
||||
// Backend merely started from stopped also moves the IP → cascade.
|
||||
let r = report(vec![
|
||||
("bitcoin-core", ReconcileAction::Started),
|
||||
("lnd", ReconcileAction::NoOp),
|
||||
]);
|
||||
assert_eq!(cascade_pairs_for_report(&r, &none), vec![("bitcoin-core", "lnd")]);
|
||||
assert_eq!(
|
||||
cascade_pairs_for_report(&r, &none),
|
||||
vec![("bitcoin-core", "lnd")]
|
||||
);
|
||||
|
||||
// Backend untouched → no cascade.
|
||||
let r = report(vec![
|
||||
@ -6016,7 +6021,9 @@ app:
|
||||
let calls = rt.calls();
|
||||
for name in ["archy-mempool-db", "mempool-api", "archy-mempool-web"] {
|
||||
assert!(
|
||||
calls.iter().any(|c| c == &format!("start_container:{name}")),
|
||||
calls
|
||||
.iter()
|
||||
.any(|c| c == &format!("start_container:{name}")),
|
||||
"{name} not started: {calls:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@ -1258,7 +1258,10 @@ app:
|
||||
assert!(u.read_only_root);
|
||||
assert!(u.no_new_privileges);
|
||||
assert_eq!(u.cap_add, vec!["NET_BIND_SERVICE"]);
|
||||
assert_eq!(u.ports, vec![(8332, 8332, "tcp".to_string(), String::new())]);
|
||||
assert_eq!(
|
||||
u.ports,
|
||||
vec![(8332, 8332, "tcp".to_string(), String::new())]
|
||||
);
|
||||
assert_eq!(u.environment, vec!["BITCOIN_NETWORK=mainnet"]);
|
||||
assert_eq!(u.bind_mounts.len(), 1);
|
||||
assert_eq!(
|
||||
|
||||
@ -607,14 +607,9 @@ mod prune_missing_content_tests {
|
||||
availability: Availability::AllPeers,
|
||||
added_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
};
|
||||
save_catalog(
|
||||
data_dir,
|
||||
&ContentCatalog {
|
||||
items: vec![item],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
save_catalog(data_dir, &ContentCatalog { items: vec![item] })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// File was never written to disk under content/files/ or filebrowser/.
|
||||
let result = serve_content(data_dir, "missing-item", None, None, None, None)
|
||||
|
||||
@ -60,8 +60,9 @@ pub fn is_recovery_complete() -> bool {
|
||||
// the outcome — a container that truly failed goes back to showing its
|
||||
// real state on the next scan.
|
||||
|
||||
static PENDING_BOOT_STARTS: std::sync::LazyLock<std::sync::RwLock<std::collections::HashSet<String>>> =
|
||||
std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashSet::new()));
|
||||
static PENDING_BOOT_STARTS: std::sync::LazyLock<
|
||||
std::sync::RwLock<std::collections::HashSet<String>>,
|
||||
> = std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashSet::new()));
|
||||
|
||||
/// Register container/app names an active recovery or reconcile pass
|
||||
/// intends to start.
|
||||
@ -182,7 +183,10 @@ pub async fn load_user_uninstalled(data_dir: &Path) -> std::collections::HashSet
|
||||
}
|
||||
|
||||
/// Save the set of user-uninstalled app/container names to disk.
|
||||
pub async fn save_user_uninstalled(data_dir: &Path, uninstalled: &std::collections::HashSet<String>) {
|
||||
pub async fn save_user_uninstalled(
|
||||
data_dir: &Path,
|
||||
uninstalled: &std::collections::HashSet<String>,
|
||||
) {
|
||||
let path = data_dir.join(USER_UNINSTALLED_FILE);
|
||||
if let Ok(json) = serde_json::to_string_pretty(uninstalled) {
|
||||
let _ = fs::write(&path, json).await;
|
||||
@ -244,10 +248,7 @@ pub async fn check_for_crash(data_dir: &Path) -> Result<Option<Vec<RunningContai
|
||||
// that is not us and whose cmdline looks like the archipelago binary.
|
||||
if !old_pid.is_empty() {
|
||||
if let Ok(pid) = old_pid.parse::<u32>() {
|
||||
if pid != std::process::id()
|
||||
&& is_process_running(pid)
|
||||
&& process_is_archipelago(pid)
|
||||
{
|
||||
if pid != std::process::id() && is_process_running(pid) && process_is_archipelago(pid) {
|
||||
warn!(
|
||||
"Previous process (PID {}) is still running — not a crash, skipping recovery",
|
||||
pid
|
||||
|
||||
@ -360,8 +360,8 @@ mod tests {
|
||||
None,
|
||||
TrustLevel::Trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(code.starts_with("fed1:"));
|
||||
|
||||
let parsed = parse_invite(&code).unwrap();
|
||||
@ -384,8 +384,8 @@ mod tests {
|
||||
Some(fips),
|
||||
TrustLevel::Trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed = parse_invite(&code).unwrap();
|
||||
assert_eq!(parsed.fips_npub.as_deref(), Some(fips));
|
||||
}
|
||||
|
||||
@ -403,7 +403,21 @@ mod tests {
|
||||
last_transport_at: None,
|
||||
},
|
||||
];
|
||||
let state = build_local_state(vec![], 0.0, 0, 0, 0, 0, 0, true, None, None, None, &peers, None);
|
||||
let state = build_local_state(
|
||||
vec![],
|
||||
0.0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&peers,
|
||||
None,
|
||||
);
|
||||
assert_eq!(state.federated_peers.len(), 1);
|
||||
assert_eq!(state.federated_peers[0].did, "did:key:zTrusted");
|
||||
assert_eq!(
|
||||
|
||||
@ -1486,7 +1486,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_guard_covers_name_app_id_and_legacy_underscores() {
|
||||
assert!(!lifecycle_op_covers_container("hm-guard-app", "hm-guard-app"));
|
||||
assert!(!lifecycle_op_covers_container(
|
||||
"hm-guard-app",
|
||||
"hm-guard-app"
|
||||
));
|
||||
|
||||
// Held package lock covers the container directly and via stack
|
||||
// membership; the '_'→'-' probe covers legacy underscore names.
|
||||
|
||||
@ -107,8 +107,7 @@ async fn main() -> Result<()> {
|
||||
// RUST_LOG=archipelago=debug) to get debug logs back when debugging.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
|
||||
@ -162,7 +162,10 @@ async fn status_line() -> String {
|
||||
} else {
|
||||
format!("electrum {:.0}%", e.progress_pct)
|
||||
};
|
||||
format!("Archipelago OS v{}: {btc}, {elec}.", env!("CARGO_PKG_VERSION"))
|
||||
format!(
|
||||
"Archipelago OS v{}: {btc}, {elec}.",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -141,7 +141,9 @@ impl MeshRadioDevice {
|
||||
anyhow::bail!("Native image send is Reticulum-only")
|
||||
}
|
||||
Self::Reticulum(device) => {
|
||||
device.send_native_image(dest_pubkey_prefix, mime, bytes, caption).await
|
||||
device
|
||||
.send_native_image(dest_pubkey_prefix, mime, bytes, caption)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -297,7 +299,9 @@ async fn auto_detect_and_open(
|
||||
info!(path = %path, "Found Reticulum (RNode) device via auto-detect");
|
||||
return Ok((path.clone(), MeshRadioDevice::Reticulum(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Reticulum daemon failed to initialize"),
|
||||
Err(e) => {
|
||||
debug!(path = %path, error = %e, "Reticulum daemon failed to initialize")
|
||||
}
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Reticulum RNode"),
|
||||
}
|
||||
@ -323,7 +327,9 @@ async fn auto_detect_and_open(
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
|
||||
Err(e) => {
|
||||
debug!(path = %path, error = %e, "Could not open serial port for Meshtastic")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -399,7 +405,9 @@ async fn open_preferred_path(
|
||||
{
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => return Ok((MeshRadioDevice::Reticulum(dev), info)),
|
||||
Err(e) => debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode"),
|
||||
Err(e) => {
|
||||
debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode")
|
||||
}
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Reticulum"),
|
||||
}
|
||||
@ -431,26 +439,22 @@ async fn open_reticulum_tcp(
|
||||
our_x25519_pubkey_hex: &str,
|
||||
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let mut dev = match cfg {
|
||||
ReticulumTcpConfig::Server { bind } => {
|
||||
ReticulumLink::open_tcp_server(
|
||||
bind,
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
)
|
||||
.await
|
||||
.context("Could not open Reticulum TCP server interface")?
|
||||
}
|
||||
ReticulumTcpConfig::Client { connect } => {
|
||||
ReticulumLink::open_tcp_client(
|
||||
connect,
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
)
|
||||
.await
|
||||
.context("Could not open Reticulum TCP client interface")?
|
||||
}
|
||||
ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server(
|
||||
bind,
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
)
|
||||
.await
|
||||
.context("Could not open Reticulum TCP server interface")?,
|
||||
ReticulumTcpConfig::Client { connect } => ReticulumLink::open_tcp_client(
|
||||
connect,
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
)
|
||||
.await
|
||||
.context("Could not open Reticulum TCP client interface")?,
|
||||
};
|
||||
let info = dev
|
||||
.initialize()
|
||||
@ -790,7 +794,9 @@ fn meshtastic_contact_id(public_key_hex: &str) -> Option<u32> {
|
||||
fn reticulum_contact_id(public_key_hex: &str) -> Option<u32> {
|
||||
let bytes = hex::decode(public_key_hex).ok()?;
|
||||
let hash: [u8; 16] = bytes.try_into().ok()?;
|
||||
Some(super::super::reticulum::reticulum_contact_id_from_hash(&hash))
|
||||
Some(super::super::reticulum::reticulum_contact_id_from_hash(
|
||||
&hash,
|
||||
))
|
||||
}
|
||||
|
||||
/// Drain any queued messages from the device.
|
||||
@ -873,12 +879,23 @@ pub(super) async fn run_mesh_session(
|
||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open(data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind)
|
||||
.await?
|
||||
auto_detect_and_open(
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
auto_detect_and_open(data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind).await?
|
||||
auto_detect_and_open(
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
// Update status
|
||||
|
||||
@ -414,7 +414,11 @@ impl MeshtasticDevice {
|
||||
// tx_power defaults to max, which is what we want for a stock mesh.
|
||||
let mut lora = Vec::new();
|
||||
encode_varint_field_into(LORA_USE_PRESET_FIELD, 1, &mut lora);
|
||||
encode_varint_field_into(LORA_MODEM_PRESET_FIELD, LORA_MODEM_PRESET_LONG_FAST, &mut lora);
|
||||
encode_varint_field_into(
|
||||
LORA_MODEM_PRESET_FIELD,
|
||||
LORA_MODEM_PRESET_LONG_FAST,
|
||||
&mut lora,
|
||||
);
|
||||
encode_varint_field_into(LORA_REGION_FIELD, region_code as u64, &mut lora);
|
||||
encode_varint_field_into(LORA_HOP_LIMIT_FIELD, 3, &mut lora);
|
||||
encode_varint_field_into(LORA_TX_ENABLED_FIELD, 1, &mut lora);
|
||||
@ -772,7 +776,9 @@ impl MeshtasticDevice {
|
||||
if let Err(e) = self.send_to_radio(&encode_want_config()).await {
|
||||
warn!("Failed to re-request config after radio reboot: {}", e);
|
||||
} else {
|
||||
info!("Re-requested Meshtastic config after reboot — packet stream resubscribed");
|
||||
info!(
|
||||
"Re-requested Meshtastic config after reboot — packet stream resubscribed"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(inbound) = inbound {
|
||||
@ -890,7 +896,10 @@ impl MeshtasticDevice {
|
||||
if let Some((region, modem_preset)) = parse_config_lora_region(value) {
|
||||
self.current_region = Some(region);
|
||||
self.current_modem_preset = Some(modem_preset);
|
||||
debug!(region, modem_preset, "Meshtastic LoRa region/preset from device config");
|
||||
debug!(
|
||||
region,
|
||||
modem_preset, "Meshtastic LoRa region/preset from device config"
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
@ -1066,7 +1075,10 @@ fn packet_to_inbound_frame(
|
||||
|
||||
if packet.portnum == POSITION_APP {
|
||||
if let Some((lat, lon)) = parse_position_lat_lon(&packet.payload) {
|
||||
debug!(from = format!("!{:08x}", from), lat, lon, "Meshtastic position update");
|
||||
debug!(
|
||||
from = format!("!{:08x}", from),
|
||||
lat, lon, "Meshtastic position update"
|
||||
);
|
||||
contact.lat = Some(lat);
|
||||
contact.lon = Some(lon);
|
||||
}
|
||||
@ -1811,10 +1823,10 @@ mod tests {
|
||||
encode_fixed32_field(1, 0x1111_2222, &mut packet); // from
|
||||
encode_len_field(4, &decoded, &mut packet); // decoded
|
||||
encode_fixed32_field(8, (-7.5f32).to_bits(), &mut packet); // rx_snr
|
||||
// int32 rx_rssi=-92 dBm, protobuf varint-encodes a negative int32 as
|
||||
// the 10-byte two's-complement-extended varint; truncating back to
|
||||
// i32 after decode recovers the original value (see parse_mesh_packet's
|
||||
// `v as i32` cast — confirmed by this roundtrip, not just asserted).
|
||||
// int32 rx_rssi=-92 dBm, protobuf varint-encodes a negative int32 as
|
||||
// the 10-byte two's-complement-extended varint; truncating back to
|
||||
// i32 after decode recovers the original value (see parse_mesh_packet's
|
||||
// `v as i32` cast — confirmed by this roundtrip, not just asserted).
|
||||
encode_varint_field_into(12, (-92i32) as u32 as u64, &mut packet);
|
||||
|
||||
let parsed = parse_mesh_packet(&packet).expect("packet should parse");
|
||||
@ -1901,7 +1913,10 @@ mod tests {
|
||||
// must still update the contact's signal/position bookkeeping.
|
||||
let frame =
|
||||
packet_to_inbound_frame(&packet, Some(0x1111_1111), &mut contacts, &mut peer_pubkeys);
|
||||
assert!(frame.is_none(), "POSITION_APP must not surface as a chat frame");
|
||||
assert!(
|
||||
frame.is_none(),
|
||||
"POSITION_APP must not surface as a chat frame"
|
||||
);
|
||||
let contact = contacts.get(&from).expect("contact should be tracked");
|
||||
assert_eq!(contact.snr, Some(-6.0));
|
||||
assert_eq!(contact.rssi, Some(-80));
|
||||
@ -1930,7 +1945,10 @@ mod tests {
|
||||
// A `to == BROADCAST_NUM` text is a channel broadcast (3ccc on public
|
||||
// LongFast), so it routes to the channel thread, carrying its sender.
|
||||
assert_eq!(frame.code, protocol::RESP_MESHTASTIC_CHANNEL_TEXT);
|
||||
assert_eq!(frame.data[0], 0, "no channel field set => primary/public (0)");
|
||||
assert_eq!(
|
||||
frame.data[0], 0,
|
||||
"no channel field set => primary/public (0)"
|
||||
);
|
||||
assert_eq!(&frame.data[1..7], &[0xcc, 0x3c, 0x00, 0x00, 0x6d, 0x65]);
|
||||
assert_eq!(&frame.data[7..], b"hello from 3ccc");
|
||||
assert!(contacts.contains_key(&from));
|
||||
@ -1954,9 +1972,8 @@ mod tests {
|
||||
encode_len_field(4, &decoded, &mut packet);
|
||||
encode_fixed32_field(7, 12_345, &mut packet);
|
||||
|
||||
let frame =
|
||||
packet_to_inbound_frame(&packet, Some(me), &mut contacts, &mut peer_pubkeys)
|
||||
.expect("directed DM must surface");
|
||||
let frame = packet_to_inbound_frame(&packet, Some(me), &mut contacts, &mut peer_pubkeys)
|
||||
.expect("directed DM must surface");
|
||||
assert_eq!(frame.code, protocol::RESP_CONTACT_MSG_V3);
|
||||
let (sender_prefix, payload, _snr) =
|
||||
protocol::parse_contact_msg_v3_raw(&frame.data).unwrap();
|
||||
|
||||
@ -213,7 +213,11 @@ pub struct TypedEnvelope {
|
||||
/// Unix timestamp (seconds since epoch).
|
||||
pub ts: u32,
|
||||
/// Optional Ed25519 signature of (t || v || ts_bytes) — for signed messages.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", with = "compact_bytes_opt")]
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "compact_bytes_opt"
|
||||
)]
|
||||
pub sig: Option<Vec<u8>>,
|
||||
/// Message sequence number (per-sender, monotonically increasing).
|
||||
#[serde(default)]
|
||||
@ -564,7 +568,11 @@ pub struct ContentRefPayload {
|
||||
pub mime: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub filename: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", with = "base64_opt_bytes")]
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "base64_opt_bytes"
|
||||
)]
|
||||
pub thumb_bytes: Option<Vec<u8>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub caption: Option<String>,
|
||||
@ -860,8 +868,8 @@ mod tests {
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
let key = SigningKey::generate(&mut OsRng);
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::Alert, b"test".to_vec())
|
||||
.signed_with_seq(42, &key);
|
||||
let envelope =
|
||||
TypedEnvelope::new(MeshMessageType::Alert, b"test".to_vec()).signed_with_seq(42, &key);
|
||||
assert!(envelope.verify_signature(&key.verifying_key()).unwrap());
|
||||
|
||||
// v2 binds seq: replaying the signed envelope under a different
|
||||
@ -880,8 +888,8 @@ mod tests {
|
||||
// preimage, no seq) and allocate seq afterwards; verify must still
|
||||
// accept that.
|
||||
let key = SigningKey::generate(&mut OsRng);
|
||||
let envelope = TypedEnvelope::new_signed(MeshMessageType::Alert, b"test".to_vec(), &key)
|
||||
.with_seq(7);
|
||||
let envelope =
|
||||
TypedEnvelope::new_signed(MeshMessageType::Alert, b"test".to_vec(), &key).with_seq(7);
|
||||
assert!(envelope.verify_signature(&key.verifying_key()).unwrap());
|
||||
}
|
||||
|
||||
|
||||
@ -2327,7 +2327,10 @@ mod tests {
|
||||
// Force the dev fallback even if a packaged binary happens to exist
|
||||
// on this machine's PATH convention — this test wants exactly the
|
||||
// freshly-built venv daemon under test.
|
||||
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", "/nonexistent-force-dev-fallback");
|
||||
std::env::set_var(
|
||||
"ARCHY_RETICULUM_DAEMON_BIN",
|
||||
"/nonexistent-force-dev-fallback",
|
||||
);
|
||||
|
||||
let data_dir = root.path().join("node");
|
||||
std::fs::create_dir_all(data_dir.join("identity")).unwrap();
|
||||
|
||||
@ -270,7 +270,9 @@ impl ReticulumLink {
|
||||
our_ed_pubkey_hex: Option<&str>,
|
||||
our_x25519_pubkey_hex: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
probe_rnode(path).await.context("RNode KISS detect failed")?;
|
||||
probe_rnode(path)
|
||||
.await
|
||||
.context("RNode KISS detect failed")?;
|
||||
Self::spawn(
|
||||
ReticulumInterface::Serial(path),
|
||||
data_dir,
|
||||
@ -342,8 +344,9 @@ impl ReticulumLink {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
|
||||
.await;
|
||||
let _ =
|
||||
tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
|
||||
.await;
|
||||
}
|
||||
let label = iface.label();
|
||||
let iface_key = label.replace(['/', ' ', ':', ','], "_");
|
||||
@ -553,7 +556,11 @@ impl ReticulumLink {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], payload: &[u8]) -> Result<()> {
|
||||
pub async fn send_text_msg(
|
||||
&mut self,
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
payload: &[u8],
|
||||
) -> Result<()> {
|
||||
let dest_hash = self
|
||||
.prefix_to_hash
|
||||
.get(dest_pubkey_prefix)
|
||||
@ -738,11 +745,9 @@ impl ReticulumLink {
|
||||
async fn drain_events(&mut self) {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let read = tokio::time::timeout(
|
||||
Duration::from_millis(20),
|
||||
self.reader.read_line(&mut line),
|
||||
)
|
||||
.await;
|
||||
let read =
|
||||
tokio::time::timeout(Duration::from_millis(20), self.reader.read_line(&mut line))
|
||||
.await;
|
||||
let n = match read {
|
||||
Ok(Ok(n)) => n,
|
||||
_ => break, // timeout (no data) or read error — stop draining
|
||||
@ -840,7 +845,8 @@ impl ReticulumLink {
|
||||
// to survive a restart — give it a placeholder name (the real
|
||||
// one, if any, arrives via a later "announce" and overwrites
|
||||
// this) so its routing entry alone doesn't get lost.
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) {
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
|
||||
{
|
||||
e.insert(ReticulumPeer {
|
||||
dest_hash: source_hash,
|
||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||
@ -858,13 +864,22 @@ impl ReticulumLink {
|
||||
// UI (dispatch.rs's existing ContentInline handling, zero new
|
||||
// frontend code) instead of the plain text bytes below.
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
let caption = ev.get("content").and_then(Value::as_str).filter(|s| !s.trim().is_empty());
|
||||
let caption = ev
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
if let (Some(fmt), Some(b64)) = (
|
||||
ev.get("image_format").and_then(Value::as_str),
|
||||
ev.get("image_b64").and_then(Value::as_str),
|
||||
) {
|
||||
if let Ok(bytes) = B64.decode(b64) {
|
||||
match build_content_inline_frame(&prefix, image_format_to_mime(fmt), None, caption, bytes) {
|
||||
match build_content_inline_frame(
|
||||
&prefix,
|
||||
image_format_to_mime(fmt),
|
||||
None,
|
||||
caption,
|
||||
bytes,
|
||||
) {
|
||||
Ok(frame) => {
|
||||
self.inbound.push_back(frame);
|
||||
return;
|
||||
@ -905,7 +920,8 @@ impl ReticulumLink {
|
||||
},
|
||||
None => content_str.as_bytes().to_vec(),
|
||||
};
|
||||
self.inbound.push_back(build_synthetic_frame(&prefix, &content));
|
||||
self.inbound
|
||||
.push_back(build_synthetic_frame(&prefix, &content));
|
||||
}
|
||||
Some("resource_recv") => {
|
||||
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
|
||||
@ -916,7 +932,8 @@ impl ReticulumLink {
|
||||
};
|
||||
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
|
||||
self.prefix_to_hash.insert(prefix, source_hash);
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash) {
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
|
||||
{
|
||||
e.insert(ReticulumPeer {
|
||||
dest_hash: source_hash,
|
||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||
@ -939,7 +956,8 @@ impl ReticulumLink {
|
||||
// Resources are already a binary-safe whole-blob transfer),
|
||||
// so this is the same payload shape `decode.rs` already
|
||||
// accepts for a single-frame (non-chunked) typed envelope.
|
||||
self.inbound.push_back(build_synthetic_frame(&prefix, &data));
|
||||
self.inbound
|
||||
.push_back(build_synthetic_frame(&prefix, &data));
|
||||
}
|
||||
Some("resource_progress") => {
|
||||
debug!(
|
||||
@ -1138,7 +1156,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detect_resp_found_in_kiss_stream() {
|
||||
let stream = [0x10, 0x20, KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP, 0x99];
|
||||
let stream = [
|
||||
0x10,
|
||||
0x20,
|
||||
KISS_FEND,
|
||||
KISS_CMD_DETECT,
|
||||
KISS_DETECT_RESP,
|
||||
0x99,
|
||||
];
|
||||
assert!(contains_detect_resp(&stream));
|
||||
}
|
||||
|
||||
@ -1154,7 +1179,9 @@ mod tests {
|
||||
async fn probe_rnode_detects_real_hardware() {
|
||||
let port = std::env::var("ARCHY_RNODE_TEST_PORT")
|
||||
.expect("set ARCHY_RNODE_TEST_PORT to the RNode's serial path");
|
||||
probe_rnode(&port).await.expect("KISS detect probe failed against real hardware");
|
||||
probe_rnode(&port)
|
||||
.await
|
||||
.expect("KISS detect probe failed against real hardware");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1181,7 +1208,8 @@ mod tests {
|
||||
arch_pubkey_hex: Some("abcdef".to_string()),
|
||||
}];
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("archy-reticulum-peers-test-{}", std::process::id()));
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("archy-reticulum-peers-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("peers.json");
|
||||
std::fs::write(&path, serde_json::to_vec(&persisted).unwrap()).unwrap();
|
||||
@ -1207,7 +1235,10 @@ mod tests {
|
||||
h
|
||||
};
|
||||
let id = reticulum_contact_id_from_hash(&hash_high_bit);
|
||||
assert!(id < 0x8000_0000, "must not collide with federation-synthetic space");
|
||||
assert!(
|
||||
id < 0x8000_0000,
|
||||
"must not collide with federation-synthetic space"
|
||||
);
|
||||
assert_ne!(id, 0);
|
||||
|
||||
let zero_hash = [0u8; 16];
|
||||
|
||||
@ -19,6 +19,7 @@ const RESERVED_PORTS: &[u16] = &[
|
||||
23000, // BTCPay
|
||||
8173, 8174, 8175, // Fedimint
|
||||
8178, // Fedimint client daemon (fedimint-clientd REST)
|
||||
3535, // Ark wallet daemon (barkd REST)
|
||||
8123, // Home Assistant
|
||||
3000, // Grafana
|
||||
11434, // Ollama
|
||||
|
||||
@ -614,7 +614,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_lnd_aezeed_empty_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(save_lnd_aezeed_encrypted(dir.path(), &[], "x").await.is_err());
|
||||
assert!(save_lnd_aezeed_encrypted(dir.path(), &[], "x")
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -89,8 +89,10 @@ impl Server {
|
||||
if let Ok(loc) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
data.server_info.lat = loc.get("lat").and_then(|v| v.as_f64());
|
||||
data.server_info.lon = loc.get("lon").and_then(|v| v.as_f64());
|
||||
data.server_info.share_location =
|
||||
loc.get("share_location").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
data.server_info.share_location = loc
|
||||
.get("share_location")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
}
|
||||
}
|
||||
data.server_info.tor_address = docker_packages::read_tor_address("archipelago").await;
|
||||
@ -1265,8 +1267,9 @@ fn merge_preserving_transitional(
|
||||
/// scan is the owner: once podman reports a settled state and the id is no
|
||||
/// longer queued for a boot start, the fresh state wins immediately instead
|
||||
/// of being preserved for the transitional-stuck timeout.
|
||||
static SCANNER_RESTARTING: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
|
||||
std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
|
||||
static SCANNER_RESTARTING: std::sync::LazyLock<
|
||||
std::sync::Mutex<std::collections::HashSet<String>>,
|
||||
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
|
||||
|
||||
fn take_scanner_restarting(id: &str) -> bool {
|
||||
SCANNER_RESTARTING
|
||||
@ -1594,6 +1597,7 @@ fn fallback_package_port(app_id: &str) -> Option<u16> {
|
||||
match app_id {
|
||||
"fedimint" | "fedimintd" => Some(8175),
|
||||
"fedimint-clientd" => Some(8178),
|
||||
"barkd" => Some(3535),
|
||||
"filebrowser" => Some(8083),
|
||||
"indeedhub" => Some(7778),
|
||||
"nginx-proxy-manager" => Some(8081),
|
||||
|
||||
@ -67,7 +67,10 @@ pub async fn sweep_once(data_dir: &Path) -> Result<u64> {
|
||||
match ecash::receive_token(data_dir, token).await {
|
||||
Ok(amount) => {
|
||||
received_total += amount;
|
||||
info!(amount_sats = amount, "swept TollGate ecash into local wallet");
|
||||
info!(
|
||||
amount_sats = amount,
|
||||
"swept TollGate ecash into local wallet"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// The token is still in this log line if this happens — not
|
||||
|
||||
@ -355,8 +355,7 @@ fn parse_and_verify_manifest(raw: serde_json::Value) -> Result<(UpdateManifest,
|
||||
crate::trust::SignatureStatus::Verified { anchored, .. } => anchored,
|
||||
crate::trust::SignatureStatus::Unsigned => false,
|
||||
};
|
||||
let manifest: UpdateManifest =
|
||||
serde_json::from_value(raw).context("parse update manifest")?;
|
||||
let manifest: UpdateManifest = serde_json::from_value(raw).context("parse update manifest")?;
|
||||
Ok((manifest, signed))
|
||||
}
|
||||
|
||||
@ -625,8 +624,8 @@ pub async fn verify_pending_update(data_dir: &Path) {
|
||||
// .116 during the v1.7.40 rollout recovery.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
|
||||
|
||||
let deadline = std::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(PENDING_VERIFY_WINDOW_SECS);
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(PENDING_VERIFY_WINDOW_SECS);
|
||||
|
||||
while std::time::Instant::now() < deadline {
|
||||
attempt += 1;
|
||||
@ -2087,7 +2086,10 @@ mod tests {
|
||||
key
|
||||
}
|
||||
|
||||
fn sign_value(key: &ed25519_dalek::SigningKey, mut doc: serde_json::Value) -> serde_json::Value {
|
||||
fn sign_value(
|
||||
key: &ed25519_dalek::SigningKey,
|
||||
mut doc: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
let (sig, did) = crate::trust::signed_doc::sign_detached(key, &doc).unwrap();
|
||||
let obj = doc.as_object_mut().unwrap();
|
||||
obj.insert("signed_by".into(), serde_json::json!(did));
|
||||
|
||||
479
core/archipelago/src/wallet/ark_client.rs
Normal file
479
core/archipelago/src/wallet/ark_client.rs
Normal file
@ -0,0 +1,479 @@
|
||||
//! Thin HTTP bridge to the `barkd` sidecar container (Ark protocol).
|
||||
//!
|
||||
//! Same shape as [`super::fedimint_client`]: the heavy `bark-wallet` SDK stays
|
||||
//! OUT of this binary. The `barkd` daemon (in `apps/barkd`) holds the Ark
|
||||
//! wallet (VTXOs, rounds, unilateral exits) and we speak its REST API
|
||||
//! (`/api/v1/*`, Bearer auth). Endpoint/JSON shapes target barkd 0.3.0 and
|
||||
//! must be pinned to the vendored image tag.
|
||||
//!
|
||||
//! ARK is on-chain-anchored: VTXOs expire (`vtxo_expiry_delta` blocks) and the
|
||||
//! barkd daemon refreshes them by joining rounds on its own — the bridge never
|
||||
//! has to schedule anything. Unlike Cashu/Fedimint, funds survive the sidecar
|
||||
//! dying (the wallet mnemonic in barkd's datadir can unilaterally exit
|
||||
//! on-chain), so back up `/var/lib/archipelago/barkd`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
const BARKD_TIMEOUT_SECS: u64 = 15;
|
||||
/// Send/board/offboard can wait on Ark round participation (signet rounds run
|
||||
/// every 5 minutes), so give mutating calls generous room.
|
||||
const BARKD_HEAVY_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
/// Default host port the `barkd` container is mapped to (its in-container
|
||||
/// REST port; 3535 is unused elsewhere on the node — see `port_allocator`).
|
||||
const DEFAULT_BARKD_URL: &str = "http://127.0.0.1:3535";
|
||||
|
||||
/// Shared secret between the barkd container and this bridge. The barkd
|
||||
/// manifest generates it via `generated_secrets: [{barkd-secret, hex32}]`; the
|
||||
/// container entrypoint installs it with `barkd secret refresh --secret` and
|
||||
/// the bridge derives the matching Bearer token from the same file.
|
||||
const BARKD_SECRET: &str = "barkd-secret";
|
||||
|
||||
/// Wallet configuration used when the bridge has to create the barkd wallet
|
||||
/// (first use). Persisted so operators can point at their own Ark server.
|
||||
/// Defaults target Second's public signet deployment while Ark matures —
|
||||
/// mainnet needs an explicit opt-in edit of `wallet/ark_config.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArkConfig {
|
||||
pub network: String,
|
||||
pub ark_server: String,
|
||||
pub esplora: String,
|
||||
}
|
||||
|
||||
impl Default for ArkConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
network: "signet".to_string(),
|
||||
ark_server: "https://ark.signet.2nd.dev".to_string(),
|
||||
esplora: "https://esplora.signet.2nd.dev".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ARK_CONFIG_FILE: &str = "wallet/ark_config.json";
|
||||
|
||||
pub async fn load_config(data_dir: &Path) -> ArkConfig {
|
||||
match fs::read_to_string(data_dir.join(ARK_CONFIG_FILE)).await {
|
||||
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||
Err(_) => ArkConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_config(data_dir: &Path, config: &ArkConfig) -> Result<()> {
|
||||
let dir = data_dir.join("wallet");
|
||||
fs::create_dir_all(&dir)
|
||||
.await
|
||||
.context("Failed to create wallet dir")?;
|
||||
let content = serde_json::to_string_pretty(config).context("Failed to serialize ark config")?;
|
||||
fs::write(data_dir.join(ARK_CONFIG_FILE), content)
|
||||
.await
|
||||
.context("Failed to write ark config")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode barkd's Bearer token from the raw 32-byte shared secret:
|
||||
/// base64url-nopad of `<version 0x00><32-byte secret>` (see barkd `AuthToken`).
|
||||
fn encode_auth_token(secret: &[u8; 32]) -> String {
|
||||
let mut buf = Vec::with_capacity(33);
|
||||
buf.push(0u8);
|
||||
buf.extend_from_slice(secret);
|
||||
URL_SAFE_NO_PAD.encode(&buf)
|
||||
}
|
||||
|
||||
fn secret_hex_to_token(hex: &str) -> Result<String> {
|
||||
let hex = hex.trim();
|
||||
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
anyhow::bail!("barkd-secret must be exactly 64 hex characters");
|
||||
}
|
||||
let mut secret = [0u8; 32];
|
||||
for (i, byte) in secret.iter_mut().enumerate() {
|
||||
*byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).expect("validated hex");
|
||||
}
|
||||
Ok(encode_auth_token(&secret))
|
||||
}
|
||||
|
||||
/// HTTP client for a `barkd` instance.
|
||||
pub struct ArkClient {
|
||||
base_url: String,
|
||||
token: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ArkClient {
|
||||
pub fn new(base_url: &str, token: &str) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(BARKD_HEAVY_TIMEOUT_SECS))
|
||||
.build()
|
||||
.context("Failed to build HTTP client for barkd")?;
|
||||
Ok(Self {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
token: token.to_string(),
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve URL + auth token from env / node secret, with sane defaults.
|
||||
/// URL: `BARKD_URL` else the default mapped port. Token: `BARKD_TOKEN`
|
||||
/// (already-encoded Bearer token) else derived from the shared
|
||||
/// `barkd-secret` the manifest generated for the container.
|
||||
pub async fn from_node(data_dir: &Path) -> Result<Self> {
|
||||
let base_url = std::env::var("BARKD_URL").unwrap_or_else(|_| DEFAULT_BARKD_URL.to_string());
|
||||
let token = match std::env::var("BARKD_TOKEN") {
|
||||
Ok(t) if !t.is_empty() => t,
|
||||
_ => {
|
||||
let path = data_dir.join("secrets").join(BARKD_SECRET);
|
||||
let hex = fs::read_to_string(&path).await.context(
|
||||
"Ark wallet not configured (no BARKD_TOKEN and no barkd-secret \
|
||||
secret). Install the Ark (barkd) app.",
|
||||
)?;
|
||||
secret_hex_to_token(&hex)?
|
||||
}
|
||||
};
|
||||
Self::new(&base_url, &token)
|
||||
}
|
||||
|
||||
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
req.bearer_auth(&self.token)
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<serde_json::Value> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let resp = self
|
||||
.auth(self.client.get(&url))
|
||||
.timeout(std::time::Duration::from_secs(BARKD_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("barkd GET {path} failed (is it running?)"))?;
|
||||
Self::parse(resp, path).await
|
||||
}
|
||||
|
||||
async fn post(&self, path: &str, body: serde_json::Value) -> Result<serde_json::Value> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let resp = self
|
||||
.auth(self.client.post(&url))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("barkd POST {path} failed (is it running?)"))?;
|
||||
Self::parse(resp, path).await
|
||||
}
|
||||
|
||||
async fn parse(resp: reqwest::Response, path: &str) -> Result<serde_json::Value> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
// barkd errors are `{"message": "..."}`; surface the message.
|
||||
let msg = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
|
||||
.unwrap_or(text);
|
||||
anyhow::bail!("barkd {path} returned {status}: {msg}");
|
||||
}
|
||||
if text.is_empty() {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
serde_json::from_str(&text)
|
||||
.with_context(|| format!("barkd {path} returned non-JSON: {text}"))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/wallet` — wallet info (fingerprint, network, config).
|
||||
/// Errors with "No wallet set" until `create_wallet` has run.
|
||||
pub async fn wallet_info(&self) -> Result<serde_json::Value> {
|
||||
self.get("/api/v1/wallet").await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/wallet/create` — create (or restore, with a mnemonic) the
|
||||
/// barkd wallet. Idempotent guard is on the caller (`ensure_wallet`).
|
||||
pub async fn create_wallet(&self, config: &ArkConfig) -> Result<serde_json::Value> {
|
||||
self.post(
|
||||
"/api/v1/wallet/create",
|
||||
serde_json::json!({
|
||||
"network": config.network,
|
||||
"ark_server": config.ark_server,
|
||||
"chain_source": { "esplora": { "url": config.esplora } },
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/wallet/balance` — off-chain balance breakdown, in sats.
|
||||
pub async fn balance(&self) -> Result<serde_json::Value> {
|
||||
self.get("/api/v1/wallet/balance").await
|
||||
}
|
||||
|
||||
/// Spendable off-chain sats (0 on any missing field, never an error once
|
||||
/// the call itself succeeds).
|
||||
pub async fn spendable_sats(&self) -> Result<u64> {
|
||||
let bal = self.balance().await?;
|
||||
Ok(bal.get("spendable_sat").and_then(|v| v.as_u64()).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/onchain/balance` — the wallet's on-chain (boarding) funds.
|
||||
pub async fn onchain_balance(&self) -> Result<serde_json::Value> {
|
||||
self.get("/api/v1/onchain/balance").await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/wallet/addresses/next` — fresh Ark (`tark1…`) address.
|
||||
pub async fn ark_address(&self) -> Result<String> {
|
||||
let res = self.post("/api/v1/wallet/addresses/next", serde_json::json!({})).await?;
|
||||
res.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| anyhow::anyhow!("barkd address: no address in response"))
|
||||
}
|
||||
|
||||
/// `POST /api/v1/onchain/addresses/next` — fresh on-chain boarding address.
|
||||
pub async fn onchain_address(&self) -> Result<String> {
|
||||
let res = self.post("/api/v1/onchain/addresses/next", serde_json::json!({})).await?;
|
||||
res.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| anyhow::anyhow!("barkd onchain address: no address in response"))
|
||||
}
|
||||
|
||||
/// `POST /api/v1/wallet/send` — pay an Ark address, BOLT11 invoice, LNURL
|
||||
/// or lightning address from off-chain funds. Returns the movement barkd
|
||||
/// reports for the payment.
|
||||
pub async fn send(
|
||||
&self,
|
||||
destination: &str,
|
||||
amount_sats: Option<u64>,
|
||||
comment: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.post(
|
||||
"/api/v1/wallet/send",
|
||||
serde_json::json!({
|
||||
"destination": destination,
|
||||
"amount_sat": amount_sats,
|
||||
"comment": comment,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/lightning/receives/invoice` — BOLT11 invoice that lands
|
||||
/// as an Ark VTXO when paid.
|
||||
pub async fn lightning_invoice(&self, amount_sats: u64) -> Result<serde_json::Value> {
|
||||
self.post(
|
||||
"/api/v1/lightning/receives/invoice",
|
||||
serde_json::json!({ "amount_sat": amount_sats }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/boards/board-amount` (or `board-all` when `amount_sats`
|
||||
/// is None) — lift on-chain funds into Ark VTXOs.
|
||||
pub async fn board(&self, amount_sats: Option<u64>) -> Result<serde_json::Value> {
|
||||
match amount_sats {
|
||||
Some(sats) => {
|
||||
self.post(
|
||||
"/api/v1/boards/board-amount",
|
||||
serde_json::json!({ "amount_sat": sats }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => self.post("/api/v1/boards/board-all", serde_json::json!({})).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/wallet/offboard/all` — move all VTXOs back on-chain via a
|
||||
/// collaborative round.
|
||||
pub async fn offboard_all(&self, address: Option<&str>) -> Result<serde_json::Value> {
|
||||
self.post(
|
||||
"/api/v1/wallet/offboard/all",
|
||||
serde_json::json!({ "address": address }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/wallet/movements` — barkd's own movement history. This is
|
||||
/// authoritative (includes receives we never initiated), so unlike the
|
||||
/// Fedimint bridge there is no local tx log to maintain.
|
||||
pub async fn movements(&self) -> Result<Vec<serde_json::Value>> {
|
||||
let res = self.get("/api/v1/wallet/movements").await?;
|
||||
Ok(res.as_array().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `GET /api/v1/wallet/ark-info` — connected Ark server parameters.
|
||||
pub async fn ark_info(&self) -> Result<serde_json::Value> {
|
||||
self.get("/api/v1/wallet/ark-info").await
|
||||
}
|
||||
}
|
||||
|
||||
/// Idempotently make sure barkd has a wallet, creating one with the node's
|
||||
/// Ark config on first use. Best-effort no-op when the sidecar isn't
|
||||
/// installed/running yet — mirrors `fedimint_client::ensure_default_federation`.
|
||||
pub async fn ensure_wallet(data_dir: &Path) -> Result<()> {
|
||||
let client = match ArkClient::from_node(data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(()), // barkd not configured yet
|
||||
};
|
||||
if client.wallet_info().await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
let config = load_config(data_dir).await;
|
||||
match client.create_wallet(&config).await {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"created barkd Ark wallet ({} via {})",
|
||||
config.network,
|
||||
config.ark_server
|
||||
);
|
||||
// Persist the effective config so the settings UI shows what the
|
||||
// wallet was actually created with.
|
||||
let _ = save_config(data_dir, &config).await;
|
||||
}
|
||||
Err(e) => tracing::debug!("barkd wallet auto-create skipped: {e}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Total spendable Ark sats, soft-failing to 0 when the sidecar is not
|
||||
/// installed or unreachable so unified balances still render.
|
||||
pub async fn spendable_sats_or_zero(data_dir: &Path) -> u64 {
|
||||
match ArkClient::from_node(data_dir).await {
|
||||
Ok(client) => client.spendable_sats().await.unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map barkd movements into unified [`EcashTransaction`] history entries
|
||||
/// (kind = "ark"). Best-effort: empty on any error, never blocks history.
|
||||
pub async fn load_ark_txs(data_dir: &Path) -> Vec<crate::wallet::ecash::EcashTransaction> {
|
||||
let client = match ArkClient::from_node(data_dir).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let movements = match client.movements().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
movements
|
||||
.iter()
|
||||
.filter_map(movement_to_tx)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Convert one barkd `Movement` into an [`EcashTransaction`]. `None` for
|
||||
/// zero-delta movements (e.g. internal refreshes) so history stays meaningful.
|
||||
fn movement_to_tx(m: &serde_json::Value) -> Option<crate::wallet::ecash::EcashTransaction> {
|
||||
use crate::wallet::ecash::{EcashTransaction, TransactionType};
|
||||
|
||||
let delta = m.get("effective_balance_sat").and_then(|v| v.as_i64())?;
|
||||
if delta == 0 {
|
||||
return None;
|
||||
}
|
||||
let tx_type = if delta < 0 {
|
||||
TransactionType::Send
|
||||
} else {
|
||||
TransactionType::Receive
|
||||
};
|
||||
// `time` holds created/updated/completed; prefer the completion time.
|
||||
let timestamp = m
|
||||
.get("time")
|
||||
.and_then(|t| {
|
||||
t.get("completed_at")
|
||||
.or_else(|| t.get("updated_at"))
|
||||
.or_else(|| t.get("created_at"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
// Describe via the recipient list (send) or receive source when present.
|
||||
let peer = m
|
||||
.get("sent_to")
|
||||
.or_else(|| m.get("received_on"))
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|d| {
|
||||
d.get("destination")
|
||||
.or_else(|| d.get("address"))
|
||||
.or_else(|| d.get("invoice"))
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let subsystem = m
|
||||
.get("subsystem")
|
||||
.map(|s| match s {
|
||||
serde_json::Value::String(v) => v.clone(),
|
||||
other => other
|
||||
.as_object()
|
||||
.and_then(|o| o.keys().next().cloned())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let description = if delta < 0 {
|
||||
format!("Sent via Ark{}", suffix(&subsystem))
|
||||
} else {
|
||||
format!("Received via Ark{}", suffix(&subsystem))
|
||||
};
|
||||
Some(EcashTransaction {
|
||||
id: format!("ark-{}", m.get("id").and_then(|v| v.as_u64()).unwrap_or(0)),
|
||||
tx_type,
|
||||
amount_sats: delta.unsigned_abs(),
|
||||
timestamp,
|
||||
description,
|
||||
mint_url: String::new(),
|
||||
peer,
|
||||
kind: "ark".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn suffix(subsystem: &str) -> String {
|
||||
if subsystem.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({subsystem})")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn token_encoding_matches_barkd_format() {
|
||||
// barkd token = base64url-nopad(0x00 || secret); 33 bytes -> 44 chars.
|
||||
let token = secret_hex_to_token(&"ab".repeat(32)).unwrap();
|
||||
assert_eq!(token.len(), 44);
|
||||
let bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
|
||||
assert_eq!(bytes.len(), 33);
|
||||
assert_eq!(bytes[0], 0);
|
||||
assert_eq!(&bytes[1..], &[0xabu8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_rejects_bad_secret() {
|
||||
assert!(secret_hex_to_token("deadbeef").is_err(), "too short");
|
||||
assert!(secret_hex_to_token(&"zz".repeat(32)).is_err(), "not hex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_maps_to_history_entry() {
|
||||
let m = serde_json::json!({
|
||||
"id": 7,
|
||||
"effective_balance_sat": -1500,
|
||||
"time": { "completed_at": "2026-07-14T12:00:00Z" },
|
||||
"sent_to": [{ "destination": "tark1abc" }],
|
||||
"subsystem": "arkoor",
|
||||
});
|
||||
let tx = movement_to_tx(&m).expect("mapped");
|
||||
assert_eq!(tx.amount_sats, 1500);
|
||||
assert_eq!(tx.kind, "ark");
|
||||
assert_eq!(tx.peer, "tark1abc");
|
||||
assert!(matches!(
|
||||
tx.tx_type,
|
||||
crate::wallet::ecash::TransactionType::Send
|
||||
));
|
||||
|
||||
// Zero-delta refresh movements are dropped.
|
||||
let refresh = serde_json::json!({ "id": 8, "effective_balance_sat": 0 });
|
||||
assert!(movement_to_tx(&refresh).is_none());
|
||||
}
|
||||
}
|
||||
@ -171,19 +171,18 @@ impl CashuToken {
|
||||
let v4: TokenV4 =
|
||||
ciborium::from_reader(decoded.as_slice()).context("Invalid CBOR in cashuB token")?;
|
||||
|
||||
let proofs = v4
|
||||
.t
|
||||
.into_iter()
|
||||
.flat_map(|entry| {
|
||||
let keyset_id = hex::encode(&entry.i);
|
||||
entry.p.into_iter().map(move |p| Proof {
|
||||
amount: p.a,
|
||||
id: keyset_id.clone(),
|
||||
secret: p.s,
|
||||
c: hex::encode(&p.c),
|
||||
let proofs =
|
||||
v4.t.into_iter()
|
||||
.flat_map(|entry| {
|
||||
let keyset_id = hex::encode(&entry.i);
|
||||
entry.p.into_iter().map(move |p| Proof {
|
||||
amount: p.a,
|
||||
id: keyset_id.clone(),
|
||||
secret: p.s,
|
||||
c: hex::encode(&p.c),
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect();
|
||||
|
||||
let token = CashuToken {
|
||||
token: vec![TokenEntry { mint: v4.m, proofs }],
|
||||
@ -407,10 +406,8 @@ mod tests {
|
||||
use ciborium::value::Value;
|
||||
|
||||
let keyset_id = vec![0x00u8, 0x9a, 0x1f, 0x29, 0x32, 0x53, 0xe4, 0x1e];
|
||||
let sig = hex::decode(
|
||||
"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24",
|
||||
)
|
||||
.unwrap();
|
||||
let sig = hex::decode("02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24")
|
||||
.unwrap();
|
||||
|
||||
let proof = Value::Map(vec![
|
||||
(Value::from("a"), Value::from(8u64)),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// WIP Cashu/ecash wallet — many helpers defined for future callers.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod ark_client;
|
||||
pub mod bdhke;
|
||||
pub mod cashu;
|
||||
pub mod ecash;
|
||||
|
||||
@ -58,7 +58,13 @@ pub async fn verify_declared_signature(
|
||||
sig_ref: &str,
|
||||
allow_insecure_registry: bool,
|
||||
) -> Result<()> {
|
||||
verify_with_key_path(image, sig_ref, &pinned_pubkey_path(), allow_insecure_registry).await
|
||||
verify_with_key_path(
|
||||
image,
|
||||
sig_ref,
|
||||
&pinned_pubkey_path(),
|
||||
allow_insecure_registry,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn verify_with_key_path(
|
||||
@ -192,7 +198,9 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("pinned cosign public key is missing"));
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("pinned cosign public key is missing"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -158,9 +158,7 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
.podman_cli(&["secret", "inspect", &r.secret_name, "--format", &fmt])
|
||||
.await
|
||||
{
|
||||
if out.status.success()
|
||||
&& String::from_utf8_lossy(&out.stdout).trim() == hash
|
||||
{
|
||||
if out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == hash {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,7 +32,11 @@ pub async fn scan_subnet(
|
||||
}
|
||||
}
|
||||
|
||||
info!("{} hosts with TCP/22 open in /{}", candidates.len(), prefix_len);
|
||||
info!(
|
||||
"{} hosts with TCP/22 open in /{}",
|
||||
candidates.len(),
|
||||
prefix_len
|
||||
);
|
||||
|
||||
let mut routers = Vec::new();
|
||||
for ip in candidates {
|
||||
|
||||
@ -48,10 +48,17 @@ impl Router {
|
||||
return Ok(PkgManager::Opkg);
|
||||
}
|
||||
if add_out.contains("no such package") || add_out.contains("unable to select") {
|
||||
info!("[{}] opkg not in apk repos — staying in apk-native mode", self.host);
|
||||
info!(
|
||||
"[{}] opkg not in apk repos — staying in apk-native mode",
|
||||
self.host
|
||||
);
|
||||
return Ok(PkgManager::ApkNative);
|
||||
}
|
||||
anyhow::bail!("apk add opkg failed (exit {}): {}", add_code, add_out.trim());
|
||||
anyhow::bail!(
|
||||
"apk add opkg failed (exit {}): {}",
|
||||
add_code,
|
||||
add_out.trim()
|
||||
);
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
@ -70,7 +77,10 @@ impl Router {
|
||||
/// Install a package, skipping if already installed.
|
||||
pub fn opkg_install(&self, package: &str) -> Result<()> {
|
||||
// Check if already installed to avoid unnecessary network traffic.
|
||||
let (_, code) = self.run(&format!("/usr/bin/opkg list-installed | grep -q '^{} '", package))?;
|
||||
let (_, code) = self.run(&format!(
|
||||
"/usr/bin/opkg list-installed | grep -q '^{} '",
|
||||
package
|
||||
))?;
|
||||
if code == 0 {
|
||||
info!("[{}] {} already installed", self.host, package);
|
||||
return Ok(());
|
||||
|
||||
@ -16,8 +16,7 @@ impl Router {
|
||||
/// Connect to an OpenWrt router via SSH using a private key.
|
||||
pub fn connect(host: &str, port: u16, user: &str, key_path: &Path) -> Result<Self> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let tcp = TcpStream::connect(&addr)
|
||||
.with_context(|| format!("TCP connect to {}", addr))?;
|
||||
let tcp = TcpStream::connect(&addr).with_context(|| format!("TCP connect to {}", addr))?;
|
||||
|
||||
let mut session = Session::new().context("create SSH session")?;
|
||||
session.set_tcp_stream(tcp);
|
||||
@ -36,8 +35,7 @@ impl Router {
|
||||
/// Connect using a password (fallback for routers not yet provisioned with a key).
|
||||
pub fn connect_password(host: &str, port: u16, user: &str, password: &str) -> Result<Self> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let tcp = TcpStream::connect(&addr)
|
||||
.with_context(|| format!("TCP connect to {}", addr))?;
|
||||
let tcp = TcpStream::connect(&addr).with_context(|| format!("TCP connect to {}", addr))?;
|
||||
|
||||
let mut session = Session::new().context("create SSH session")?;
|
||||
session.set_tcp_stream(tcp);
|
||||
@ -58,7 +56,9 @@ impl Router {
|
||||
debug!("ssh [{}] $ {}", self.host, cmd);
|
||||
|
||||
let mut channel = self.session.channel_session().context("open channel")?;
|
||||
channel.exec(cmd).with_context(|| format!("exec: {}", cmd))?;
|
||||
channel
|
||||
.exec(cmd)
|
||||
.with_context(|| format!("exec: {}", cmd))?;
|
||||
|
||||
let mut stdout = String::new();
|
||||
channel.read_to_string(&mut stdout).context("read stdout")?;
|
||||
@ -72,7 +72,12 @@ impl Router {
|
||||
pub fn run_ok(&self, cmd: &str) -> Result<String> {
|
||||
let (out, code) = self.run(cmd)?;
|
||||
if code != 0 {
|
||||
anyhow::bail!("command `{}` exited with code {}: {}", cmd, code, out.trim());
|
||||
anyhow::bail!(
|
||||
"command `{}` exited with code {}: {}",
|
||||
cmd,
|
||||
code,
|
||||
out.trim()
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@ -46,8 +46,14 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
|
||||
)
|
||||
})?;
|
||||
|
||||
info!("[{}] Downloading TollGate for {} from GitHub releases", router.host, arch);
|
||||
router.run_ok(&format!("wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1", url))?;
|
||||
info!(
|
||||
"[{}] Downloading TollGate for {} from GitHub releases",
|
||||
router.host, arch
|
||||
);
|
||||
router.run_ok(&format!(
|
||||
"wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1",
|
||||
url
|
||||
))?;
|
||||
install_ipk(router, "/tmp/tollgate.ipk")
|
||||
}
|
||||
|
||||
@ -56,7 +62,10 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
|
||||
/// Downloads the .ipk from GitHub releases and extracts it manually using
|
||||
/// BusyBox `ar` and `tar` (both present on all OpenWrt images).
|
||||
pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
|
||||
info!("[{}] Installing {} (apk-native mode)", router.host, TOLLGATE_PACKAGE);
|
||||
info!(
|
||||
"[{}] Installing {} (apk-native mode)",
|
||||
router.host, TOLLGATE_PACKAGE
|
||||
);
|
||||
|
||||
// Already installed? The service binary is /usr/bin/tollgate-wrt (per its
|
||||
// init.d script) — TOLLGATE_PACKAGE is only the opkg/apk package name,
|
||||
@ -80,14 +89,14 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
|
||||
&& a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \
|
||||
&& [ -n \"$a\" ] && echo \"$a\" \
|
||||
|| /usr/bin/apk --print-arch 2>/dev/null \
|
||||
|| uname -m"
|
||||
|| uname -m",
|
||||
)?;
|
||||
// Normalise: uname -m returns bare "mipsel"/"mips"; map to 24kc variant
|
||||
// which is the standard for home-router MIPS builds.
|
||||
let arch = match arch_raw.trim() {
|
||||
"mipsel" => "mipsel_24kc",
|
||||
"mips" => "mips_24kc",
|
||||
other => other,
|
||||
"mips" => "mips_24kc",
|
||||
other => other,
|
||||
};
|
||||
info!("[{}] detected arch: {:?}", router.host, arch);
|
||||
if arch.is_empty() {
|
||||
@ -102,11 +111,15 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
|
||||
)
|
||||
})?;
|
||||
|
||||
info!("[{}] Downloading TollGate for {} from GitHub releases", router.host, arch);
|
||||
info!(
|
||||
"[{}] Downloading TollGate for {} from GitHub releases",
|
||||
router.host, arch
|
||||
);
|
||||
// --no-check-certificate: fresh OpenWrt 25.x images ship without a CA bundle;
|
||||
// GitHub serves releases over HTTPS so wget would otherwise reject the cert.
|
||||
let (dl_out, dl_code) = router.run(&format!(
|
||||
"wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1", url
|
||||
"wget --no-check-certificate -O /tmp/tollgate.ipk '{}' 2>&1",
|
||||
url
|
||||
))?;
|
||||
if dl_code != 0 {
|
||||
anyhow::bail!("TollGate download failed: {}", dl_out.trim());
|
||||
@ -149,9 +162,8 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
|
||||
let (_, ar_found) = router.run("command -v ar >/dev/null 2>&1")?;
|
||||
if ar_found != 0 {
|
||||
info!("[{}] ar not found, installing binutils", router.host);
|
||||
let (pkg_out, pkg_code) = router.run(
|
||||
"apk add binutils 2>&1 || opkg install binutils 2>&1"
|
||||
)?;
|
||||
let (pkg_out, pkg_code) =
|
||||
router.run("apk add binutils 2>&1 || opkg install binutils 2>&1")?;
|
||||
if pkg_code != 0 {
|
||||
anyhow::bail!(
|
||||
"TollGate installation failed: ar not available and binutils install failed: {}",
|
||||
@ -161,9 +173,8 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
// Try standard opkg ar format first (ar archive → data.tar.gz inside).
|
||||
let (ar_out, ar_code) = router.run(&format!(
|
||||
"cd /tmp/_tg_install && ar x {} 2>&1", ipk_path
|
||||
))?;
|
||||
let (ar_out, ar_code) =
|
||||
router.run(&format!("cd /tmp/_tg_install && ar x {} 2>&1", ipk_path))?;
|
||||
|
||||
if ar_code != 0 {
|
||||
// Fallback: some builds produce the .ipk as a gzip tarball rather than
|
||||
@ -173,17 +184,21 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
|
||||
// flat tarball of the real package files with no ipk structure at
|
||||
// all. Extract to the scratch dir and check which shape it is before
|
||||
// deciding how to install it.
|
||||
info!("[{}] ar failed ({}), trying tar -xzf", router.host, ar_out.trim());
|
||||
info!(
|
||||
"[{}] ar failed ({}), trying tar -xzf",
|
||||
router.host,
|
||||
ar_out.trim()
|
||||
);
|
||||
|
||||
// List contents first — validates format without writing anything.
|
||||
let (list_out, list_code) = router.run(&format!(
|
||||
"tar -tzf {} 2>&1 | head -30", ipk_path
|
||||
))?;
|
||||
let (list_out, list_code) =
|
||||
router.run(&format!("tar -tzf {} 2>&1 | head -30", ipk_path))?;
|
||||
if list_code != 0 {
|
||||
anyhow::bail!(
|
||||
"TollGate installation failed: file is not an ar archive or gzip tar.\n\
|
||||
ar: {}\ntar -t: {}",
|
||||
ar_out.trim(), list_out.trim()
|
||||
ar_out.trim(),
|
||||
list_out.trim()
|
||||
);
|
||||
}
|
||||
info!("[{}] ipk contents:\n{}", router.host, list_out.trim());
|
||||
@ -206,14 +221,17 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
|
||||
}
|
||||
let (cp_out, cp_code) = router.run("cp -a /tmp/_tg_install/. / 2>&1")?;
|
||||
if cp_code != 0 {
|
||||
anyhow::bail!("TollGate installation failed: file copy failed: {}", cp_out.trim());
|
||||
anyhow::bail!(
|
||||
"TollGate installation failed: file copy failed: {}",
|
||||
cp_out.trim()
|
||||
);
|
||||
}
|
||||
// No package-manager postinst ran for these files either — see
|
||||
// the uci-defaults note below.
|
||||
router.run_ok(
|
||||
"for f in /etc/uci-defaults/*; do \
|
||||
[ -f \"$f\" ] && ( cd \"$(dirname \"$f\")\" && . \"$f\" ) && rm -f \"$f\"; \
|
||||
done; uci commit 2>/dev/null; true"
|
||||
done; uci commit 2>/dev/null; true",
|
||||
)?;
|
||||
router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?;
|
||||
return Ok(());
|
||||
@ -223,18 +241,19 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
|
||||
|
||||
// Unpack data.tar.gz (the real payload) from either the `ar`-extracted or
|
||||
// gzip-tar-extracted scratch dir, then run control.tar.gz's postinst.
|
||||
let (tar_out, tar_code) = router.run(
|
||||
"tar -xzf /tmp/_tg_install/data.tar.gz -C / 2>&1"
|
||||
)?;
|
||||
let (tar_out, tar_code) = router.run("tar -xzf /tmp/_tg_install/data.tar.gz -C / 2>&1")?;
|
||||
if tar_code != 0 {
|
||||
anyhow::bail!("TollGate installation failed: data extract failed: {}", tar_out.trim());
|
||||
anyhow::bail!(
|
||||
"TollGate installation failed: data extract failed: {}",
|
||||
tar_out.trim()
|
||||
);
|
||||
}
|
||||
// Run postinst if present (optional — failures are non-fatal).
|
||||
router.run_ok(
|
||||
"if tar -xzf /tmp/_tg_install/control.tar.gz -C /tmp/_tg_install 2>/dev/null; then \
|
||||
chmod +x /tmp/_tg_install/postinst 2>/dev/null; \
|
||||
/tmp/_tg_install/postinst configure 2>/dev/null || true; \
|
||||
fi"
|
||||
fi",
|
||||
)?;
|
||||
// `default_postinst` (what most packages' postinst calls, including
|
||||
// this one) only runs pending /etc/uci-defaults/* scripts for packages
|
||||
@ -246,7 +265,7 @@ fn install_ipk(router: &Router, ipk_path: &str) -> Result<()> {
|
||||
router.run_ok(
|
||||
"for f in /etc/uci-defaults/*; do \
|
||||
[ -f \"$f\" ] && ( cd \"$(dirname \"$f\")\" && . \"$f\" ) && rm -f \"$f\"; \
|
||||
done; uci commit 2>/dev/null; true"
|
||||
done; uci commit 2>/dev/null; true",
|
||||
)?;
|
||||
|
||||
router.run_ok(&format!("rm -rf /tmp/_tg_install {}", ipk_path))?;
|
||||
|
||||
@ -84,9 +84,7 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
|
||||
fn restart_services(router: &Router, enabled: bool) -> Result<()> {
|
||||
if enabled {
|
||||
router.run_ok("/etc/init.d/tollgate-wrt enable")?;
|
||||
router.run_ok(
|
||||
"/etc/init.d/tollgate-wrt restart || /etc/init.d/tollgate-wrt start"
|
||||
)?;
|
||||
router.run_ok("/etc/init.d/tollgate-wrt restart || /etc/init.d/tollgate-wrt start")?;
|
||||
} else {
|
||||
router.run_ok("/etc/init.d/tollgate-wrt stop || true")?;
|
||||
router.run_ok("/etc/init.d/tollgate-wrt disable || true")?;
|
||||
@ -115,7 +113,7 @@ fn restart_services(router: &Router, enabled: bool) -> Result<()> {
|
||||
echo \"br-tollgate has no kernel-level IPv4 address after cycle $i, retrying\"; \
|
||||
done; \
|
||||
ip -4 addr show br-tollgate 2>/dev/null | grep -q 'inet ' || \
|
||||
{ echo 'br-tollgate never got a kernel-level IPv4 address after 5 cycles'; exit 1; }"
|
||||
{ echo 'br-tollgate never got a kernel-level IPv4 address after 5 cycles'; exit 1; }",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -63,7 +63,7 @@ pub fn install_captive_portal_symlink(router: &Router) -> Result<()> {
|
||||
fi; \
|
||||
rm -rf /etc/nodogsplash/htdocs; \
|
||||
ln -sf /etc/tollgate/tollgate-captive-portal-site /etc/nodogsplash/htdocs; \
|
||||
fi"
|
||||
fi",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@ -91,7 +91,10 @@ pub fn configure(router: &Router, cfg: &TollGateConfig) -> Result<()> {
|
||||
router.uci_set("nodogsplash.main", "nodogsplash")?;
|
||||
router.uci_set("nodogsplash.main.enabled", "1")?;
|
||||
router.uci_set("nodogsplash.main.gatewayinterface", "br-tollgate")?;
|
||||
router.uci_set("nodogsplash.main.gatewayname", &format!("{} Portal", cfg.ssid))?;
|
||||
router.uci_set(
|
||||
"nodogsplash.main.gatewayname",
|
||||
&format!("{} Portal", cfg.ssid),
|
||||
)?;
|
||||
router.uci_set("nodogsplash.main.gatewaydomainname", "TollGate.lan")?;
|
||||
router.uci_set("nodogsplash.main.gatewayport", "2050")?;
|
||||
|
||||
|
||||
@ -27,7 +27,10 @@ pub fn provision_ssid(router: &Router, cfg: &TollGateConfig) -> Result<()> {
|
||||
("wireless.tollgate.ieee80211r", "0"),
|
||||
// Stop broadcasting entirely when disabled, rather than leaving an
|
||||
// open SSID up that leads nowhere once the backend is stopped.
|
||||
("wireless.tollgate.disabled", if cfg.enabled { "0" } else { "1" }),
|
||||
(
|
||||
"wireless.tollgate.disabled",
|
||||
if cfg.enabled { "0" } else { "1" },
|
||||
),
|
||||
],
|
||||
)?;
|
||||
|
||||
@ -117,13 +120,9 @@ fn provision_firewall(router: &Router) -> Result<()> {
|
||||
|
||||
/// Return the first available wireless radio device name (e.g. "radio0").
|
||||
fn detect_radio(router: &Router) -> Result<String> {
|
||||
let out = router.run_ok("uci show wireless | grep -o 'wireless\\.radio[0-9]*\\.type' | head -1")?;
|
||||
let out =
|
||||
router.run_ok("uci show wireless | grep -o 'wireless\\.radio[0-9]*\\.type' | head -1")?;
|
||||
// Extract "radioN" from "wireless.radioN.type"
|
||||
let radio = out
|
||||
.trim()
|
||||
.split('.')
|
||||
.nth(1)
|
||||
.unwrap_or("radio0")
|
||||
.to_string();
|
||||
let radio = out.trim().split('.').nth(1).unwrap_or("radio0").to_string();
|
||||
Ok(radio)
|
||||
}
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
use crate::Router;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
use crate::Router;
|
||||
|
||||
pub struct WispConfig {
|
||||
pub ssid: String,
|
||||
pub password: String,
|
||||
pub encryption: String, // psk2 | psk | sae | none
|
||||
pub dhcp_start: u32, // first address in DHCP pool (default 100 → .100)
|
||||
pub dhcp_limit: u32, // pool size (default 150 → .100–.249)
|
||||
pub masq: bool, // enable NAT on WAN zone (almost always true)
|
||||
pub encryption: String, // psk2 | psk | sae | none
|
||||
pub dhcp_start: u32, // first address in DHCP pool (default 100 → .100)
|
||||
pub dhcp_limit: u32, // pool size (default 150 → .100–.249)
|
||||
pub masq: bool, // enable NAT on WAN zone (almost always true)
|
||||
}
|
||||
|
||||
pub fn configure_wisp(router: &Router, config: &WispConfig) -> Result<()> {
|
||||
@ -54,11 +54,21 @@ pub fn configure_wisp(router: &Router, config: &WispConfig) -> Result<()> {
|
||||
// "wifi reload" is not enough on some drivers — it keeps stale state.
|
||||
let (down_out, down_code) = router.run("wifi down 2>&1")?;
|
||||
if down_code != 0 {
|
||||
info!("[{}] wifi down failed ({}): {}", router.host, down_code, down_out.trim());
|
||||
info!(
|
||||
"[{}] wifi down failed ({}): {}",
|
||||
router.host,
|
||||
down_code,
|
||||
down_out.trim()
|
||||
);
|
||||
}
|
||||
let (up_out, up_code) = router.run("wifi up 2>&1")?;
|
||||
if up_code != 0 {
|
||||
info!("[{}] wifi up failed ({}): {} — falling back to network restart", router.host, up_code, up_out.trim());
|
||||
info!(
|
||||
"[{}] wifi up failed ({}): {} — falling back to network restart",
|
||||
router.host,
|
||||
up_code,
|
||||
up_out.trim()
|
||||
);
|
||||
router.run_ok("/etc/init.d/network restart 2>&1")?;
|
||||
}
|
||||
|
||||
@ -72,7 +82,9 @@ pub fn get_wan_status(router: &Router) -> serde_json::Value {
|
||||
.unwrap_or(false);
|
||||
|
||||
let ssid = router.uci_get("wireless.wwan.ssid").unwrap_or_default();
|
||||
let encryption = router.uci_get("wireless.wwan.encryption").unwrap_or_default();
|
||||
let encryption = router
|
||||
.uci_get("wireless.wwan.encryption")
|
||||
.unwrap_or_default();
|
||||
let radio0_disabled = router
|
||||
.uci_get("wireless.radio0.disabled")
|
||||
.map(|v| v == "1")
|
||||
@ -85,7 +97,10 @@ pub fn get_wan_status(router: &Router) -> serde_json::Value {
|
||||
// Interface operstate (up / down / absent)
|
||||
let sta_state = if !sta_iface.is_empty() {
|
||||
router
|
||||
.run_ok(&format!("cat /sys/class/net/{}/operstate 2>/dev/null", sta_iface))
|
||||
.run_ok(&format!(
|
||||
"cat /sys/class/net/{}/operstate 2>/dev/null",
|
||||
sta_iface
|
||||
))
|
||||
.unwrap_or_else(|_| "unknown".into())
|
||||
.trim()
|
||||
.to_string()
|
||||
@ -108,10 +123,18 @@ pub fn get_wan_status(router: &Router) -> serde_json::Value {
|
||||
.to_string();
|
||||
|
||||
// LAN info for the DHCP setup display
|
||||
let lan_ip = router.uci_get("network.lan.ipaddr").unwrap_or_else(|_| "192.168.1.1".into());
|
||||
let lan_netmask = router.uci_get("network.lan.netmask").unwrap_or_else(|_| "255.255.255.0".into());
|
||||
let dhcp_start = router.uci_get("dhcp.lan.start").unwrap_or_else(|_| "100".into());
|
||||
let dhcp_limit = router.uci_get("dhcp.lan.limit").unwrap_or_else(|_| "150".into());
|
||||
let lan_ip = router
|
||||
.uci_get("network.lan.ipaddr")
|
||||
.unwrap_or_else(|_| "192.168.1.1".into());
|
||||
let lan_netmask = router
|
||||
.uci_get("network.lan.netmask")
|
||||
.unwrap_or_else(|_| "255.255.255.0".into());
|
||||
let dhcp_start = router
|
||||
.uci_get("dhcp.lan.start")
|
||||
.unwrap_or_else(|_| "100".into());
|
||||
let dhcp_limit = router
|
||||
.uci_get("dhcp.lan.limit")
|
||||
.unwrap_or_else(|_| "150".into());
|
||||
|
||||
// Masquerade: check WAN zone
|
||||
let masq = {
|
||||
@ -126,7 +149,11 @@ pub fn get_wan_status(router: &Router) -> serde_json::Value {
|
||||
info!("[{}] WAN status: configured={} ssid={:?} assoc={:?} sta_iface={:?} sta_state={:?} ip={:?} lan={} masq={}",
|
||||
router.host, configured, ssid, assoc_ssid, sta_iface, sta_state, ip, lan_ip, masq);
|
||||
if !wifi_log.is_empty() {
|
||||
info!("[{}] wifi_log: {}", router.host, wifi_log.replace('\n', " | "));
|
||||
info!(
|
||||
"[{}] wifi_log: {}",
|
||||
router.host,
|
||||
wifi_log.replace('\n', " | ")
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use crate::Router;
|
||||
use anyhow::Result;
|
||||
|
||||
pub struct ScannedNetwork {
|
||||
pub ssid: String,
|
||||
@ -42,7 +42,12 @@ fn scan_via_mtk_site_survey(router: &Router, iface: &str) -> Result<Vec<ScannedN
|
||||
fn parse_mtk_site_survey(output: &str) -> Result<Vec<ScannedNetwork>> {
|
||||
let mut networks = Vec::new();
|
||||
for line in output.lines() {
|
||||
if !line.trim_start().as_bytes().first().is_some_and(u8::is_ascii_digit) {
|
||||
if !line
|
||||
.trim_start()
|
||||
.as_bytes()
|
||||
.first()
|
||||
.is_some_and(u8::is_ascii_digit)
|
||||
{
|
||||
continue; // skip header/summary lines; data rows start with an index
|
||||
}
|
||||
let ssid = line.get(8..41).unwrap_or("").trim().to_string();
|
||||
@ -51,8 +56,14 @@ fn parse_mtk_site_survey(output: &str) -> Result<Vec<ScannedNetwork>> {
|
||||
}
|
||||
let bssid = line.get(41..61).unwrap_or("").trim().to_string();
|
||||
let security = line.get(61..84).unwrap_or("");
|
||||
let channel: u8 = line.get(4..8).and_then(|s| s.trim().parse().ok()).unwrap_or(0);
|
||||
let signal: i32 = line.get(84..92).and_then(|s| s.trim().parse().ok()).unwrap_or(-100);
|
||||
let channel: u8 = line
|
||||
.get(4..8)
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(0);
|
||||
let signal: i32 = line
|
||||
.get(84..92)
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(-100);
|
||||
networks.push(ScannedNetwork {
|
||||
ssid,
|
||||
bssid,
|
||||
@ -96,7 +107,11 @@ fn find_wireless_iface(router: &Router) -> Result<(String, bool)> {
|
||||
// Create a temporary managed interface directly on the PHY. This bypasses
|
||||
// netifd entirely so it works even when there are no wifi-iface sections in
|
||||
// UCI (common on a freshly-flashed device).
|
||||
tracing::info!("[{}] Creating temporary scan interface on {}", router.host, phy);
|
||||
tracing::info!(
|
||||
"[{}] Creating temporary scan interface on {}",
|
||||
router.host,
|
||||
phy
|
||||
);
|
||||
// Remove any stale scan0 from a previous attempt, then add fresh
|
||||
let _ = router.run("iw dev scan0 del 2>/dev/null");
|
||||
router.run_ok(&format!(
|
||||
@ -119,7 +134,12 @@ fn parse_iwinfo_scan(output: &str) -> Result<Vec<ScannedNetwork>> {
|
||||
networks.push(n);
|
||||
}
|
||||
}
|
||||
let bssid = line.split("Address:").nth(1).unwrap_or("").trim().to_string();
|
||||
let bssid = line
|
||||
.split("Address:")
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
current = Some(ScannedNetwork {
|
||||
ssid: String::new(),
|
||||
bssid,
|
||||
@ -132,7 +152,10 @@ fn parse_iwinfo_scan(output: &str) -> Result<Vec<ScannedNetwork>> {
|
||||
n.ssid = rest.trim().trim_matches('"').to_string();
|
||||
} else if line.contains("Channel:") && !line.starts_with("Encryption") {
|
||||
if let Some(ch_part) = line.split("Channel:").nth(1) {
|
||||
n.channel = ch_part.trim().split_whitespace().next()
|
||||
n.channel = ch_part
|
||||
.trim()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
|
||||
@ -24,6 +24,19 @@ COPY docker/fedimint-ui /docker/fedimint-ui
|
||||
COPY demo/files /demo/files
|
||||
COPY demo/content /demo/content
|
||||
|
||||
# This image only ever serves the public demo — scrub the private release/registry
|
||||
# server address from everything it serves (mock data, catalog.json, demo assets)
|
||||
# and fail the build if any occurrence survives.
|
||||
RUN find /app /docker /demo -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \
|
||||
-o -name '*.css' -o -name '*.html' -o -name '*.json' -o -name '*.md' -o -name '*.txt' \
|
||||
-o -name '*.yml' -o -name '*.yaml' \) -not -path '*/node_modules/*' \
|
||||
-exec sed -i \
|
||||
-e 's#146\.59\.87\.168:3000/lfg2025#registry.demo.internal/archy#g' \
|
||||
-e 's#146\.59\.87\.168:3000#registry.demo.internal#g' \
|
||||
-e 's#146\.59\.87\.168#registry.demo.internal#g' {} + && \
|
||||
if grep -rq '146\.59\.87\.168' /app/mock-backend.js /app/public /docker /demo; then \
|
||||
echo 'LEAK: release-server IP still in demo image'; exit 1; fi
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5959
|
||||
|
||||
|
||||
@ -29,6 +29,20 @@ ENV VITE_DEMO=$VITE_DEMO
|
||||
# Use npm script which handles build better
|
||||
RUN npm run build:docker || (echo "Build failed! Listing files:" && ls -la && echo "Checking vite config:" && cat vite.config.ts && exit 1)
|
||||
|
||||
# Demo builds must not leak the private release/registry server address anywhere
|
||||
# in the served bundle (JS constants, catalog.json, prose). Replace every
|
||||
# occurrence with a non-routable placeholder, then fail the build if any slipped
|
||||
# through. (The companion-QR URL is handled in source — it needs a URL that works.)
|
||||
RUN if [ "$VITE_DEMO" = "1" ] || [ "$VITE_DEMO" = "true" ]; then \
|
||||
find dist -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.css' -o -name '*.html' \
|
||||
-o -name '*.json' -o -name '*.map' -o -name '*.txt' -o -name '*.webmanifest' \) \
|
||||
-exec sed -i \
|
||||
-e 's#146\.59\.87\.168:3000/lfg2025#registry.demo.internal/archy#g' \
|
||||
-e 's#146\.59\.87\.168:3000#registry.demo.internal#g' \
|
||||
-e 's#146\.59\.87\.168#registry.demo.internal#g' {} + && \
|
||||
if grep -rq '146\.59\.87\.168' dist; then echo 'LEAK: release-server IP still in dist'; exit 1; fi; \
|
||||
fi
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
|
||||
@ -122,29 +122,9 @@ http {
|
||||
sub_filter 'url(/' 'url(/app/indeedhub/';
|
||||
}
|
||||
|
||||
# Mempool: same approach. mempool.guide (not mempool.space, which blocks
|
||||
# proxied embedding) — strip framing headers and rewrite absolute paths.
|
||||
location /app/mempool/ {
|
||||
proxy_pass https://mempool.guide/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host mempool.guide;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 3600;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_ssl_server_name on;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Content-Security-Policy-Report-Only;
|
||||
sub_filter_types text/html text/css application/javascript application/json;
|
||||
sub_filter_once off;
|
||||
sub_filter 'href="/' 'href="/app/mempool/';
|
||||
sub_filter 'src="/' 'src="/app/mempool/';
|
||||
sub_filter "href='/" "href='/app/mempool/";
|
||||
sub_filter "src='/" "src='/app/mempool/";
|
||||
sub_filter 'from"/' 'from"/app/mempool/';
|
||||
sub_filter 'url(/' 'url(/app/mempool/';
|
||||
}
|
||||
# Mempool is NOT proxied upstream anymore — the mock backend serves a
|
||||
# branded placeholder page for it (see DEMO_APP_PAGES in mock-backend.js),
|
||||
# so /app/mempool/ falls through to the generic /app/ location below.
|
||||
|
||||
# Proxy every other app UI (/app/<id>/) to the mock backend, which serves
|
||||
# the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the
|
||||
|
||||
@ -132,6 +132,11 @@ const SEED_WALLET = {
|
||||
channel_sats: 8_250_000,
|
||||
ecash_sats: 250_000,
|
||||
ecash_tokens: 12,
|
||||
// Ark (barkd sidecar) demo balances — signet defaults
|
||||
ark_sats: 75_000,
|
||||
ark_pending_sats: 0,
|
||||
ark_onchain_sats: 20_000,
|
||||
ark_config: { network: 'signet', ark_server: 'https://ark.signet.2nd.dev', esplora: 'https://esplora.signet.2nd.dev' },
|
||||
block_height: 892451,
|
||||
transactions: [
|
||||
{ tx_hash: 'ab12cd34ef5678901234567890abcdef12345678', amount_sats: 2_000_000, direction: 'incoming', num_confirmations: 142, block_height: 892310, time_stamp: Math.floor(Date.now()/1000) - 86400, label: 'Channel funding', total_fees: 0, dest_addresses: [] },
|
||||
@ -2251,6 +2256,63 @@ app.post('/rpc/v1', (req, res) => {
|
||||
const total = (mockState.federations || []).reduce((s, f) => s + (f.balance_sats || 0), 0)
|
||||
return res.json({ result: { balance_sats: total } })
|
||||
}
|
||||
case 'wallet.ark-status': {
|
||||
return res.json({
|
||||
result: {
|
||||
available: true,
|
||||
wallet_ready: true,
|
||||
wallet: { network: walletState.ark_config.network },
|
||||
ark_info: { server: walletState.ark_config.ark_server },
|
||||
config: walletState.ark_config,
|
||||
},
|
||||
})
|
||||
}
|
||||
case 'wallet.ark-balance': {
|
||||
return res.json({
|
||||
result: {
|
||||
balance_sats: walletState.ark_sats,
|
||||
spendable_sats: walletState.ark_sats,
|
||||
pending_sats: walletState.ark_pending_sats,
|
||||
onchain_sats: walletState.ark_onchain_sats,
|
||||
},
|
||||
})
|
||||
}
|
||||
case 'wallet.ark-address': {
|
||||
const onchain = params?.onchain === true
|
||||
const address = onchain ? 'tb1q' + randomHex(16) : 'tark1' + randomHex(28)
|
||||
return res.json({ result: { address, onchain } })
|
||||
}
|
||||
case 'wallet.ark-board': {
|
||||
const amt = params?.amount_sats ?? walletState.ark_onchain_sats
|
||||
walletState.ark_onchain_sats = Math.max(0, walletState.ark_onchain_sats - amt)
|
||||
walletState.ark_sats += amt
|
||||
return res.json({ result: { boarded_sats: amt } })
|
||||
}
|
||||
case 'wallet.ark-offboard': {
|
||||
const amt = walletState.ark_sats
|
||||
walletState.ark_sats = 0
|
||||
walletState.ark_onchain_sats += amt
|
||||
return res.json({ result: { offboarded_sats: amt } })
|
||||
}
|
||||
case 'wallet.ark-send': {
|
||||
const amt = params?.amount_sats || 1000
|
||||
walletState.ark_sats = Math.max(0, walletState.ark_sats - amt)
|
||||
return res.json({ result: { sent: true, movement: { id: 1, effective_balance_sat: -amt } } })
|
||||
}
|
||||
case 'wallet.ark-invoice': {
|
||||
return res.json({ result: { invoice: 'lntbs' + randomHex(64) } })
|
||||
}
|
||||
case 'wallet.ark-history': {
|
||||
return res.json({ result: { transactions: [] } })
|
||||
}
|
||||
case 'wallet.ark-configure': {
|
||||
const cfg = { ...walletState.ark_config }
|
||||
for (const key of ['network', 'ark_server', 'esplora']) {
|
||||
if (typeof params?.[key] === 'string' && params[key].trim()) cfg[key] = params[key].trim()
|
||||
}
|
||||
walletState.ark_config = cfg
|
||||
return res.json({ result: { config: cfg } })
|
||||
}
|
||||
case 'content.request-onchain': {
|
||||
const price = params?.price_sats || 500
|
||||
return res.json({
|
||||
@ -3296,6 +3358,8 @@ app.post('/rpc/v1', (req, res) => {
|
||||
{ type: 'receive', amount_sats: 50000, timestamp: new Date(Date.now() - 86400000).toISOString(), note: 'Minted from Lightning' },
|
||||
{ type: 'send', amount_sats: 5000, timestamp: new Date(Date.now() - 43200000).toISOString(), note: 'Sent ecash token' },
|
||||
{ type: 'receive', amount_sats: 10000, timestamp: new Date(Date.now() - 3600000).toISOString(), note: 'Redeemed token' },
|
||||
// Ark movement in the unified history shape (see wallet.ark-history / load_ark_txs)
|
||||
{ id: 'ark-1', tx_type: 'receive', type: 'receive', amount_sats: 75000, timestamp: new Date(Date.now() - 7200000).toISOString(), description: 'Received via Ark', note: 'Received via Ark', mint_url: '', peer: '', kind: 'ark' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@ -3561,7 +3625,8 @@ app.post('/rpc/v1', (req, res) => {
|
||||
|
||||
case 'update.list-mirrors': {
|
||||
globalThis.__mockMirrors ||= [
|
||||
{ url: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json', label: 'Origin (vps2)' },
|
||||
// Neutral placeholder — the public demo must not reveal the real release-server address.
|
||||
{ url: 'https://updates.archipelago.demo/releases/manifest.json', label: 'Origin' },
|
||||
]
|
||||
return res.json({ result: { mirrors: globalThis.__mockMirrors } })
|
||||
}
|
||||
|
||||
4
neode-ui/package-lock.json
generated
4
neode-ui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.99-alpha",
|
||||
"version": "1.7.100-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.99-alpha",
|
||||
"version": "1.7.100-alpha",
|
||||
"dependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
|
||||
BIN
neode-ui/public/assets/img/app-icons/bark.png
Normal file
BIN
neode-ui/public/assets/img/app-icons/bark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@ -298,6 +298,25 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "barkd",
|
||||
"title": "Ark Wallet",
|
||||
"version": "0.3.0",
|
||||
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
|
||||
"icon": "/assets/img/app-icons/bark.png",
|
||||
"author": "Second",
|
||||
"category": "money",
|
||||
"dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
|
||||
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
"3535:3535"
|
||||
],
|
||||
"volumes": [
|
||||
"/var/lib/archipelago/barkd:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jellyfin",
|
||||
"title": "Jellyfin",
|
||||
|
||||
@ -45,6 +45,10 @@ const customIconUrls = {
|
||||
'fedimint': [
|
||||
'https://raw.githubusercontent.com/fedibtc/fedimint-ui/master/apps/router/public/favicon.svg',
|
||||
],
|
||||
// Official bark project avatar (Ark protocol wallet daemon)
|
||||
'bark': [
|
||||
'https://gitlab.com/uploads/-/system/project/avatar/75519706/bark-smiling-square-white-2.jpg',
|
||||
],
|
||||
}
|
||||
|
||||
const iconDir = path.join(__dirname, '../public/assets/img/app-icons')
|
||||
|
||||
@ -77,12 +77,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
||||
// host to resolve). Points at the companion APK hosted on the 146 release server
|
||||
// (publicly reachable) rather than the local node's /packages copy.
|
||||
const DEFAULT_DOWNLOAD_URL = 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
|
||||
// The demo serves the APK from its own public origin instead, so the QR never
|
||||
// exposes the release-server address.
|
||||
const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
||||
? `${window.location.origin}/packages/archipelago-companion.apk`
|
||||
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
|
||||
|
||||
const visible = ref(false)
|
||||
const qrDataUrl = ref('')
|
||||
|
||||
@ -72,7 +72,7 @@
|
||||
<span
|
||||
v-else
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||
:class="tx.kind === 'lightning' ? 'bg-yellow-500/15 text-yellow-400' : tx.kind === 'cashu' ? 'bg-purple-500/15 text-purple-400' : 'bg-blue-500/15 text-blue-400'"
|
||||
:class="tx.kind === 'lightning' ? 'bg-yellow-500/15 text-yellow-400' : tx.kind === 'cashu' ? 'bg-purple-500/15 text-purple-400' : tx.kind === 'ark' ? 'bg-teal-500/15 text-teal-400' : 'bg-blue-500/15 text-blue-400'"
|
||||
>
|
||||
{{ kindLabel(tx) }}
|
||||
</span>
|
||||
@ -111,7 +111,7 @@ interface WalletTransaction {
|
||||
label: string
|
||||
block_height: number
|
||||
// Which rail the transaction happened on; absent = onchain (older backends)
|
||||
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint'
|
||||
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' | 'ark'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@ -122,19 +122,23 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash'
|
||||
const filters: Array<{ key: FilterKey; label: string }> = [
|
||||
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash' | 'ark'
|
||||
// The Ark chip only appears once an Ark transaction exists — most nodes
|
||||
// don't run the barkd sidecar.
|
||||
const filters = computed<Array<{ key: FilterKey; label: string }>>(() => [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'onchain', label: 'On-chain' },
|
||||
{ key: 'lightning', label: '⚡ Lightning' },
|
||||
{ key: 'ecash', label: 'Ecash' },
|
||||
]
|
||||
...(props.transactions.some(tx => tx.kind === 'ark') ? [{ key: 'ark' as const, label: 'Ark' }] : []),
|
||||
])
|
||||
const activeFilter = ref<FilterKey>('all')
|
||||
|
||||
function matchesFilter(tx: WalletTransaction, f: FilterKey): boolean {
|
||||
if (f === 'all') return true
|
||||
if (f === 'onchain') return isOnchain(tx)
|
||||
if (f === 'lightning') return tx.kind === 'lightning'
|
||||
if (f === 'ark') return tx.kind === 'ark'
|
||||
return tx.kind === 'cashu' || tx.kind === 'fedimint'
|
||||
}
|
||||
|
||||
@ -157,6 +161,7 @@ function kindLabel(tx: WalletTransaction): string {
|
||||
if (tx.kind === 'lightning') return '⚡ Lightning'
|
||||
if (tx.kind === 'cashu') return 'Cashu'
|
||||
if (tx.kind === 'fedimint') return 'Fedimint'
|
||||
if (tx.kind === 'ark') return 'Ark'
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
@ -148,11 +148,107 @@
|
||||
Joining federations lands with the Fedimint client backend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ===================== Ark ===================== -->
|
||||
<div v-show="activeTab === 'ark'">
|
||||
<div class="flex items-start gap-2 mb-4">
|
||||
<p class="text-white/60 text-sm flex-1">
|
||||
Ark holds self-custodial off-chain bitcoin via an Ark server. Funds stay recoverable on-chain even if the server disappears.
|
||||
</p>
|
||||
<span v-if="arkStatus && !arkStatus.available" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-orange-500/15 text-orange-400">Not installed</span>
|
||||
<span v-else-if="arkStatus?.config?.network && arkStatus.config.network !== 'mainnet'" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-teal-500/15 text-teal-400">{{ arkStatus.config.network }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingArk" class="py-6 text-center text-white/50 text-sm">Checking Ark wallet…</div>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="arkStatus && !arkStatus.available" class="text-white/40 text-sm text-center py-2 mb-4">
|
||||
Install the <span class="text-white/70">Ark Wallet</span> app from the app store to enable Ark payments.
|
||||
</p>
|
||||
|
||||
<!-- Balances -->
|
||||
<div v-if="arkStatus?.available" class="grid grid-cols-3 gap-2 mb-4">
|
||||
<div class="p-3 bg-white/5 rounded-lg text-center">
|
||||
<p class="text-[11px] text-white/40 mb-1">Spendable</p>
|
||||
<p class="text-sm text-teal-400 font-medium">{{ (arkBalance?.spendable_sats ?? 0).toLocaleString() }} sats</p>
|
||||
</div>
|
||||
<div class="p-3 bg-white/5 rounded-lg text-center">
|
||||
<p class="text-[11px] text-white/40 mb-1">Pending</p>
|
||||
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.pending_sats ?? 0).toLocaleString() }} sats</p>
|
||||
</div>
|
||||
<div class="p-3 bg-white/5 rounded-lg text-center">
|
||||
<p class="text-[11px] text-white/40 mb-1">On-chain</p>
|
||||
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.onchain_sats ?? 0).toLocaleString() }} sats</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receive address -->
|
||||
<div v-if="arkStatus?.available" class="mb-4">
|
||||
<div class="flex gap-2">
|
||||
<button @click="fetchArkAddress(false)" :disabled="arkBusy" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50">Ark address</button>
|
||||
<button @click="fetchArkAddress(true)" :disabled="arkBusy" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50">On-chain (boarding) address</button>
|
||||
</div>
|
||||
<div v-if="arkAddress" class="mt-2 flex items-center gap-2 p-3 bg-white/5 rounded-lg">
|
||||
<span class="text-xs font-mono text-white/90 break-all flex-1">{{ arkAddress }}</span>
|
||||
<button @click="copyArkAddress" class="p-2 rounded-lg hover:bg-white/10 text-white/50 hover:text-white shrink-0" :title="arkCopied ? 'Copied' : 'Copy'">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Board / offboard -->
|
||||
<div v-if="arkStatus?.available" class="flex gap-2 mb-4">
|
||||
<button
|
||||
@click="boardArk"
|
||||
:disabled="arkBusy || (arkBalance?.onchain_sats ?? 0) === 0"
|
||||
class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50"
|
||||
title="Lift on-chain funds into Ark"
|
||||
>{{ arkBoarding ? 'Boarding…' : 'Board on-chain funds' }}</button>
|
||||
<button
|
||||
@click="offboardArk"
|
||||
:disabled="arkBusy || (arkBalance?.spendable_sats ?? 0) === 0"
|
||||
class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium disabled:opacity-50"
|
||||
title="Move all Ark funds back on-chain"
|
||||
>{{ arkOffboarding ? 'Offboarding…' : 'Offboard to on-chain' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Server configuration -->
|
||||
<div class="mb-3 space-y-2">
|
||||
<label class="text-white/60 text-sm block">Ark server</label>
|
||||
<input v-model="arkConfig.ark_server" type="text" placeholder="https://ark.signet.2nd.dev" class="w-full input-glass font-mono" />
|
||||
<label class="text-white/60 text-sm block">Esplora (chain source)</label>
|
||||
<input v-model="arkConfig.esplora" type="text" placeholder="https://esplora.signet.2nd.dev" class="w-full input-glass font-mono" />
|
||||
<label class="text-white/60 text-sm block">Network</label>
|
||||
<select v-model="arkConfig.network" class="w-full input-glass">
|
||||
<option value="signet">signet</option>
|
||||
<option value="mainnet">mainnet</option>
|
||||
<option value="regtest">regtest</option>
|
||||
</select>
|
||||
<p class="text-[11px] text-white/40">
|
||||
Applied when the Ark wallet is created — an existing wallet stays bound to its server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="arkError" class="mb-3 alert-error">{{ arkError }}</div>
|
||||
<div v-if="arkOk" class="mb-3 text-xs text-green-400">{{ arkOk }}</div>
|
||||
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
<button
|
||||
@click="saveArkConfig"
|
||||
:disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()"
|
||||
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>{{ savingArk ? 'Saving…' : 'Save' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
@ -167,8 +263,9 @@ const tabs = [
|
||||
{ key: 'channels' as const, label: 'Channels' },
|
||||
{ key: 'cashu' as const, label: 'Cashu Mints' },
|
||||
{ key: 'fedimint' as const, label: 'Fedimint Federations' },
|
||||
{ key: 'ark' as const, label: 'Ark' },
|
||||
]
|
||||
const activeTab = ref<'channels' | 'cashu' | 'fedimint'>('channels')
|
||||
const activeTab = ref<'channels' | 'cashu' | 'fedimint' | 'ark'>('channels')
|
||||
|
||||
// Backed by wallet.fedimint-list / -join / -leave (fedimint-clientd HTTP bridge).
|
||||
// Join degrades gracefully with a clear error if the Fedimint client app isn't installed.
|
||||
@ -194,12 +291,37 @@ const joiningFed = ref(false)
|
||||
const fedError = ref('')
|
||||
const fedJoinedOk = ref(false)
|
||||
|
||||
// ---- Ark (barkd sidecar, wallet.ark-* RPCs) ----
|
||||
interface ArkStatus {
|
||||
available: boolean
|
||||
wallet_ready: boolean
|
||||
config?: { network: string; ark_server: string; esplora: string }
|
||||
}
|
||||
interface ArkBalance {
|
||||
spendable_sats: number
|
||||
pending_sats: number
|
||||
onchain_sats: number
|
||||
}
|
||||
const arkStatus = ref<ArkStatus | null>(null)
|
||||
const arkBalance = ref<ArkBalance | null>(null)
|
||||
const arkConfig = ref({ network: 'signet', ark_server: '', esplora: '' })
|
||||
const arkAddress = ref('')
|
||||
const arkCopied = ref(false)
|
||||
const loadingArk = ref(false)
|
||||
const savingArk = ref(false)
|
||||
const arkBoarding = ref(false)
|
||||
const arkOffboarding = ref(false)
|
||||
const arkError = ref('')
|
||||
const arkOk = ref('')
|
||||
const arkBusy = computed(() => savingArk.value || arkBoarding.value || arkOffboarding.value)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (open) {
|
||||
loadMints()
|
||||
if (fedimintBackendReady) loadFederations()
|
||||
loadArk()
|
||||
}
|
||||
},
|
||||
)
|
||||
@ -309,10 +431,111 @@ async function joinFederation() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArk() {
|
||||
loadingArk.value = true
|
||||
arkError.value = ''
|
||||
arkOk.value = ''
|
||||
arkAddress.value = ''
|
||||
try {
|
||||
const status = await rpcClient.call<ArkStatus>({ method: 'wallet.ark-status' })
|
||||
arkStatus.value = status
|
||||
if (status.config) arkConfig.value = { ...status.config }
|
||||
if (status.available) {
|
||||
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
|
||||
} else {
|
||||
arkBalance.value = null
|
||||
}
|
||||
} catch {
|
||||
arkStatus.value = { available: false, wallet_ready: false }
|
||||
arkBalance.value = null
|
||||
} finally {
|
||||
loadingArk.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchArkAddress(onchain: boolean) {
|
||||
arkError.value = ''
|
||||
arkOk.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ address: string }>({
|
||||
method: 'wallet.ark-address',
|
||||
params: { onchain },
|
||||
})
|
||||
arkAddress.value = res.address
|
||||
arkCopied.value = false
|
||||
} catch (err: unknown) {
|
||||
arkError.value = err instanceof Error ? err.message : 'Failed to get address'
|
||||
}
|
||||
}
|
||||
|
||||
async function copyArkAddress() {
|
||||
if (!arkAddress.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(arkAddress.value)
|
||||
arkCopied.value = true
|
||||
} catch {
|
||||
/* clipboard unavailable (http) — the address is selectable */
|
||||
}
|
||||
}
|
||||
|
||||
async function boardArk() {
|
||||
arkBoarding.value = true
|
||||
arkError.value = ''
|
||||
arkOk.value = ''
|
||||
try {
|
||||
// Boards everything; waits on Ark round participation, so give it room.
|
||||
await rpcClient.call({ method: 'wallet.ark-board', timeout: 130000 })
|
||||
arkOk.value = 'Boarding started — funds appear as spendable once the round confirms.'
|
||||
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
|
||||
emit('changed')
|
||||
} catch (err: unknown) {
|
||||
arkError.value = err instanceof Error ? err.message : 'Failed to board funds'
|
||||
} finally {
|
||||
arkBoarding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function offboardArk() {
|
||||
arkOffboarding.value = true
|
||||
arkError.value = ''
|
||||
arkOk.value = ''
|
||||
try {
|
||||
await rpcClient.call({ method: 'wallet.ark-offboard', timeout: 130000 })
|
||||
arkOk.value = 'Offboard requested — funds return on-chain with the next round.'
|
||||
arkBalance.value = await rpcClient.call<ArkBalance>({ method: 'wallet.ark-balance' })
|
||||
emit('changed')
|
||||
} catch (err: unknown) {
|
||||
arkError.value = err instanceof Error ? err.message : 'Failed to offboard funds'
|
||||
} finally {
|
||||
arkOffboarding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveArkConfig() {
|
||||
savingArk.value = true
|
||||
arkError.value = ''
|
||||
arkOk.value = ''
|
||||
try {
|
||||
const res = await rpcClient.call<{ config: ArkStatus['config'] }>({
|
||||
method: 'wallet.ark-configure',
|
||||
params: { ...arkConfig.value },
|
||||
})
|
||||
if (res.config) arkConfig.value = { ...res.config }
|
||||
arkOk.value = 'Ark configuration saved.'
|
||||
emit('changed')
|
||||
} catch (err: unknown) {
|
||||
arkError.value = err instanceof Error ? err.message : 'Failed to save Ark configuration'
|
||||
} finally {
|
||||
savingArk.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
mintError.value = ''
|
||||
mintsSavedOk.value = false
|
||||
fedError.value = ''
|
||||
arkError.value = ''
|
||||
arkOk.value = ''
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -198,7 +198,6 @@ export interface NostrConsentRequest {
|
||||
reject: () => void
|
||||
}
|
||||
|
||||
|
||||
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const isOpen = ref(false)
|
||||
const url = ref('')
|
||||
|
||||
@ -348,7 +348,7 @@
|
||||
|
||||
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
|
||||
<p class="font-medium text-white/80 mb-2">Easy sources</p>
|
||||
<p>Use images from Docker Hub, GHCR, the VPS2 Gitea registry (146.59.87.168:3000), or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
|
||||
<p>Use images from Docker Hub, GHCR, the Archipelago app registry, or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex gap-3">
|
||||
|
||||
@ -133,6 +133,7 @@
|
||||
:wallet-lightning="walletLightning"
|
||||
:wallet-ecash="walletEcash"
|
||||
:wallet-fedimint="walletFedimint"
|
||||
:wallet-ark="walletArk"
|
||||
:wallet-transactions="walletTransactions"
|
||||
:is-dev="isDev"
|
||||
@show-send="showSendModal = true"
|
||||
@ -515,6 +516,7 @@ const showSendModal = ref(false); const showReceiveModal = ref(false); const sho
|
||||
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
|
||||
|
||||
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
|
||||
const walletArk = ref(0)
|
||||
const walletTransactions = ref<WalletTransaction[]>([])
|
||||
|
||||
// Overlay the explorer above the current page — never navigate away.
|
||||
@ -532,7 +534,7 @@ interface EcashTransaction {
|
||||
description: string
|
||||
mint_url: string
|
||||
peer: string
|
||||
kind: 'cashu' | 'fedimint'
|
||||
kind: 'cashu' | 'fedimint' | 'ark'
|
||||
}
|
||||
|
||||
function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction {
|
||||
@ -557,6 +559,7 @@ async function loadWeb5Status() {
|
||||
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false }
|
||||
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||
try { const res = await rpcClient.call<{ spendable_sats: number }>({ method: 'wallet.ark-balance', timeout: 5000 }); walletArk.value = res.spendable_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||
// Merge LND transactions with ecash/Fedimint history (wallet.ecash-history
|
||||
// already unifies both) — previously only LND transactions were fetched
|
||||
// here, so any Cashu or Fedimint receive (e.g. a TollGate payment) never
|
||||
|
||||
@ -136,6 +136,14 @@
|
||||
</div>
|
||||
<span class="text-blue-400 text-sm font-medium">{{ walletFedimint.toLocaleString() }} sats</span>
|
||||
</div>
|
||||
<!-- Only rendered once barkd reports a balance — most nodes don't run the Ark sidecar -->
|
||||
<div v-if="(walletArk ?? 0) > 0" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 text-base leading-none flex items-center justify-center" role="img" aria-label="Ark">⚓</span>
|
||||
<span class="text-sm text-white/80">Ark</span>
|
||||
</div>
|
||||
<span class="text-teal-400 text-sm font-medium">{{ (walletArk ?? 0).toLocaleString() }} sats</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-card-buttons grid gap-2 mt-auto pt-4 shrink-0" :class="isDev ? 'grid-cols-4' : 'grid-cols-3'">
|
||||
<button @click="$emit('showSend')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||
@ -173,7 +181,7 @@ export interface WalletTransaction {
|
||||
label: string
|
||||
block_height: number
|
||||
// Which rail the transaction happened on; absent = onchain (older backends)
|
||||
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint'
|
||||
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint' | 'ark'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@ -183,6 +191,7 @@ const props = defineProps<{
|
||||
walletLightning: number
|
||||
walletEcash: number
|
||||
walletFedimint: number
|
||||
walletArk?: number
|
||||
walletTransactions: WalletTransaction[]
|
||||
isDev: boolean
|
||||
}>()
|
||||
|
||||
@ -362,34 +362,33 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.00-alpha -->
|
||||
<!-- v1.7.100-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.00-alpha</span>
|
||||
<span class="text-xs text-white/40">June 18, 2026</span>
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.100-alpha</span>
|
||||
<span class="text-xs text-white/40">July 14, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The off-grid mesh radio no longer posts cryptic identity codes to the shared public channel. Your node was announcing a line starting with "ARCHY:" to the public channel about once a minute, which everyone else on that channel saw as spam; that broadcast has been removed.</p>
|
||||
<p>You can now use your node's AI assistant straight from a normal chat. Send "!ai <your question>" in a direct message to an AI-enabled node and the answer comes right back in the same conversation — whether your message travelled over the internet or the LoRa radio. Before, the reply could be sent on the wrong path and never arrive.</p>
|
||||
<p>The Mesh AI Assistant panel is easier to set up: pick the Claude model from a dropdown (Haiku, Sonnet, or Opus) instead of typing it, and add specific contacts to an "always allow" list so chosen people can use "!ai" even when the assistant is set to trusted-nodes-only.</p>
|
||||
<p>Fedimint federations show up in Wallet Settings again. The Fedimint client app wasn't starting because of a configuration error, so the federation your node auto-joins never appeared; the client is fixed and runs again.</p>
|
||||
<p>In Settings, "App Updates" and "App Registry" now sit directly under your Account section for quicker access.</p>
|
||||
<p>In Mesh chat, scrolling the conversation no longer also scrolls the contact list behind it.</p>
|
||||
<p>Mesh direct messages are now private and end-to-end encrypted to the recipient — they're sent as real radio DMs instead of being broadcast on the public channel, so other people on the mesh no longer see them, and the answer arrives intact (even on standard meshcore phone apps).</p>
|
||||
<p>You can now message standard meshcore apps (like the phone companion) and they can message you — text shows up readable on both sides, and your node's AI answers come back as a private reply rather than on the public channel.</p>
|
||||
<p>New contacts you hear on the radio are added automatically, so people show up in your Peers list without any extra steps.</p>
|
||||
<p>"Clear All" now actually removes contacts (rather than hiding them forever); a contact comes back on its own the next time it's in range. Each contact also shows a reachability dot so you can see who's currently reachable.</p>
|
||||
<p>The Peers list has a search box (with a clear button) to quickly filter your contacts by name, DID, npub, or key.</p>
|
||||
<p>Your node can now hold Fedimint ecash as well as Cashu, with tabbed Wallet Settings for each and both balances shown side by side on the home wallet card.</p>
|
||||
<p>You can buy files shared by another node right from their cloud, paying from this node's ecash, your Lightning wallet, on-chain, or by scanning a Lightning QR with any outside wallet.</p>
|
||||
<p>Your node can act as an AI assistant on the off-grid mesh: peers ask by starting a message with "!ai" and get an answer back over the radio, with a panel to turn it on or off.</p>
|
||||
<p>You can view your node's 24-word recovery phrase any time from Settings, behind a password (and 2FA) confirmation and a tap-to-show blur.</p>
|
||||
<p>Setting up a brand-new node is smoother: it waits and retries quietly instead of flashing errors, and shows a gentle "securing your private connection…" status that turns to "ready" on its own.</p>
|
||||
<p>The NetBird VPN app now logs in (it's served over HTTPS and opens in a browser tab).</p>
|
||||
<p>Phone remote-control of a node's screen now supports two-finger scrolling inside apps, and external-browser apps open on your phone.</p>
|
||||
<p>You can choose whether your node shares Bitcoin block headers over the mesh, and your choices are remembered.</p>
|
||||
<p>Version numbers display cleanly everywhere (no more doubled "v"), and "Back" buttons look and behave consistently across desktop and mobile.</p>
|
||||
<p>For advanced testing, Settings includes an optional update & app source choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode, with the trusted origin remaining the default.</p>
|
||||
<p>Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs.</p>
|
||||
<p>Lightning grew up: your LND wallet's recovery seed is captured at setup and kept as an encrypted backup you can reveal from Settings, there's a new Channels tab with a fee control when opening channels, and on-chain and Lightning balances now show side by side.</p>
|
||||
<p>Installing Lightning (and other Bitcoin-dependent apps) on a fresh node no longer fails repeatedly — the node now waits until Bitcoin is genuinely ready to answer before starting them, and Bitcoin sizes its storage to your actual disk and its memory cache to your RAM, so small machines stop swapping and stalling.</p>
|
||||
<p>The wallet understands more money: Cashu v4 tokens are supported, you can pay for a peer's files from either your Cashu or Fedimint ecash, and the Transactions view now shows your Lightning, Cashu, and Fedimint activity together — with a payment confirmation screen and an automatic refund if a purchase fails.</p>
|
||||
<p>Mesh radios got a major upgrade: Meshtastic direct messages are now true end-to-end-encrypted radio messages that interoperate with off-the-shelf Meshtastic phone apps, your radio's region and a shared channel are provisioned automatically, and a new setup window appears when a radio is plugged in — with board pictures, full radio settings, and signal-strength indicators.</p>
|
||||
<p>Reticulum joins as a third mesh radio protocol with RNode LoRa hardware support, including sending images and voice messages over the radio — and every chat message now carries a small pill showing how it travelled (Mesh, FIPS, or Tor).</p>
|
||||
<p>Your node can manage an OpenWrt router: set up its internet uplink from the UI with a Wi-Fi network scan, turn it into a TollGate pay-for-Wi-Fi hotspot with a real captive portal, and sweep the router's earnings into your node's wallet. The gateway's status appears on the Home screen's Network tile.</p>
|
||||
<p>Peering is now trust-aware: "Invite a Peer" grants view-only Observer access while "Link Your Nodes" grants Trusted access, incoming requests ask for your confirmation with an optional message, Node Visibility is a single clear switch plus a list of discoverable nodes you can peer with, and the Fleet view shows your trusted nodes' health.</p>
|
||||
<p>Updates and apps are verified end-to-end: release updates are cryptographically signed and checked against a key baked into your node, app definitions arrive via the signed catalog, and container images are checked against trusted sources before anything installs or runs.</p>
|
||||
<p>Dozens of reliability fixes: failed installs no longer leave phantom app cards, uninstalling can't hang forever, apps you stopped stay stopped, crashed apps heal themselves (even "running" containers whose process actually died), the login page no longer refresh-loops, and the mobile layout fits real phone screens instead of hiding the last row behind the browser bar.</p>
|
||||
<p>Ask your node things over the radio: send "!archy" for node status with no AI involved, or "!ai <your question>" in a direct message for an AI answer that comes back on the same path it arrived — with a model dropdown (Haiku, Sonnet, or Opus) and an "always allow" list in the Mesh AI Assistant panel.</p>
|
||||
<p>The off-grid mesh radio no longer posts cryptic identity codes ("ARCHY:") to the shared public channel every minute, and mesh contacts take care of themselves: new radios you hear are added automatically, "Clear All" really removes contacts (they return when in range), each contact shows a reachability dot, and the Peers list has a search box.</p>
|
||||
<p>You can message standard meshcore phone apps and they can message you — readable text both ways, private replies instead of public-channel broadcasts. Federated Archipelago nodes now appear on the Mesh Map.</p>
|
||||
<p>Apps open as an overlay on top of whatever page you're on, in every display mode, instead of yanking you to a different screen; the Services tab groups apps by category with proper icons.</p>
|
||||
<p>BTCPay Server keeps its plugins across restarts, connects to your node's own LND out of the box, and its invoices stay payable over private Lightning channels.</p>
|
||||
<p>Fedimint federations show up in Wallet Settings again (the client app's configuration error is fixed), and Wallet Settings has tabbed sections for Cashu and Fedimint.</p>
|
||||
<p>The phone companion app can upload and download files, edit saved server entries, opens non-embeddable apps in an in-app browser, and got a proper round launcher icon.</p>
|
||||
<p>Six placeholder "apps" that were just web bookmarks are gone from the store, the Bitcoin dashboard works fully offline, Gitea opens on the right port, and mempool, strfry, and Electrum stopped their restart loops.</p>
|
||||
<p>Kiosk displays: HDMI audio no longer stutters, and a bad display-clone state no longer sticks after reboot.</p>
|
||||
<p>Consistent dropdowns, toggles, tabs, and modal styling across the UI; in Mesh chat, scrolling the conversation no longer also scrolls the contact list; "App Updates" and "App Registry" sit directly under Account in Settings; and a fresh node no longer reinstalls apps just because their definition file exists on disk.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.99-alpha -->
|
||||
@ -1068,7 +1067,7 @@ init()
|
||||
<p>Installing, updating, and removing apps no longer freezes the UI. The backend now spawns the actual work in the background and returns immediately, so the progress bar starts moving right away instead of the whole page locking up for 30+ seconds while podman pulls an image.</p>
|
||||
<p>Install progress bar actually reflects reality now. It previously stayed at 0% until the very end because podman doesn't emit parseable progress when run without a TTY. Replaced byte-counting with seven clearly-labelled phases — Preparing, Pulling image, Creating container, Starting, Waiting for health, Finalizing, Done — each mapped to a fixed percentage so the bar only moves forward.</p>
|
||||
<p>Launch button now appears the moment an install finishes, instead of waiting up to 60 seconds for the next container scan. After a successful install or update, the backend kicks the scanner and waits for a fresh manifest to land before flipping the app to Running, so the UI always has real port and UI-route info by the time the card becomes clickable.</p>
|
||||
<p>Retired the decommissioned .23 Hetzner VPS mirror. New nodes default to OVH (146.59.87.168) as Server 1 and tx1138 as Server 2 for both system updates and the app registry. Existing nodes auto-purge any saved .23 entries on next load so they stop paying connection-timeout penalties against a dead host.</p>
|
||||
<p>Retired the decommissioned Hetzner VPS mirror. New nodes default to the OVH mirror as Server 1 and tx1138 as Server 2 for both system updates and the app registry. Existing nodes auto-purge any saved entries for the dead mirror on next load so they stop paying connection-timeout penalties against a dead host.</p>
|
||||
<p>Update-available badges and version comparisons work again across every app. The backend was looking for its pinned-image catalog at the wrong path and silently getting an empty result on deployed nodes, which meant the UI never showed "update available" even when a newer image was ready. The search path now matches where the image recipe actually installs the file.</p>
|
||||
<p>Nodes with a 2 TB data drive are no longer silently configured as pruned Bitcoin nodes. The disk-size check that decides whether to enable pruning was measuring the tiny OS partition instead of the large encrypted data partition, so every archy install with a separate data volume was flipping into prune=550 mode on reconcile and deleting its historical blocks on the next bitcoin-knots restart. The check now measures the actual data partition, so full-archive nodes stay full-archive.</p>
|
||||
<p>Recovery from a failed update no longer leaves a container permanently missing. When an app update failed partway through, the rollback path tried to restart the old container by name even though the forward path had already deleted it, leaving a hole in the node that required manual intervention. The reconcile tool now supports a --create-missing flag that rebuilds any registered container from its canonical spec, giving the update flow a safe recovery path.</p>
|
||||
@ -1226,7 +1225,7 @@ init()
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>App installs now show a real download progress bar — same accuracy as the system update bar. You'll see "Downloading: 50.5 / 200.0 MB (25%)" with a live percentage instead of a generic spinner. The bar keeps streaming even when the install falls back from one registry to another, so you'll never see a "stuck at 0%" again.</p>
|
||||
<p>Uninstalls now show what's actually happening: "Stopping containers (2/5)", "Cleaning up volumes", "Removing app data" — labelled per app so you can fire off multiple uninstalls in parallel and watch each one's stage on its own card.</p>
|
||||
<p>OVH (146.59.87.168) is now baked in as Server 3 by default for both updates and the app registry — extra mirror, completely independent network path so a single-provider outage can't take everything down.</p>
|
||||
<p>The OVH mirror is now baked in as Server 3 by default for both updates and the app registry — extra mirror, completely independent network path so a single-provider outage can't take everything down.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.29-alpha -->
|
||||
|
||||
@ -1,34 +1,36 @@
|
||||
{
|
||||
"version": "1.7.99-alpha",
|
||||
"release_date": "2026-06-18",
|
||||
"changelog": [
|
||||
"Your node can now hold Fedimint ecash as well as Cashu. Wallet Settings now has tabbed sections for each: keep your list of trusted Cashu mints, or paste a Fedimint invite code to join a federation, and the home wallet card shows both your Cashu and Fedimint balances side by side. A new \"Fedimint Client\" app in the catalog powers the federation side.",
|
||||
"You can now buy files shared by another node, right from their cloud. When you open a peer's paid file you get a simple \"Buy this file\" picker with several ways to pay \u2014 instantly from this node's ecash balance, from your node's own Lightning wallet, on-chain from your node, or by scanning a Lightning QR code with any outside wallet. Once payment settles, the file downloads automatically.",
|
||||
"Your node can now act as an AI assistant on the off-grid mesh radio network. If your node has a local AI model available (via Ollama), other people on the mesh can ask it a question by starting their message with \"!ai\" and get an answer back over the radio \u2014 handy where there's no internet. A new Mesh assistant panel lets you turn this on or off and shows whether a local AI model was detected.",
|
||||
"You can now view your node's 24-word recovery phrase whenever you need it. Settings has a new \"Recovery phrase\" option that, after you confirm your password (and 2FA code if you use one), reveals the words behind a tap-to-show blur with a copy button \u2014 so you can write them down and store them safely offline.",
|
||||
"Setting up a brand-new node is smoother and less alarming. If the node is still starting up while you generate or confirm your recovery phrase, it now quietly waits and retries instead of flashing a scary error, and offers a clear \"Try again\" button only when something genuinely goes wrong. The final setup screen also shows a gentle \"securing your private connection\u2026\" status that turns to \"ready\" on its own, so you can tell the encrypted transport is coming up rather than stuck.",
|
||||
"The NetBird VPN app now actually logs in. It was failing to reach its sign-in screen because the dashboard needs a secure (HTTPS) connection that wasn't being provided; the node now serves it over HTTPS and opens it in a browser tab, so the login flow completes.",
|
||||
"When you use your phone to remote-control a node's attached screen, two-finger scrolling now works inside apps and panels, not just the main page. And tapping an app that's meant to open in an external browser now hands the link to your phone to open there, instead of trying to open it on the (often unattended) attached display.",
|
||||
"You can now choose whether your node shares Bitcoin block headers over the mesh. The Mesh Bitcoin panel has new switches to announce headers to peers and to accept headers from them, and your choices are remembered.",
|
||||
"Version numbers now display cleanly everywhere. In a few places the interface was showing a doubled \"v\" (like \"vv1.7.98\"); it now always shows a single, tidy version label.",
|
||||
"The \"Back\" buttons throughout the cloud and other detail screens now look and behave consistently on both desktop and mobile, including when browsing another node's files."
|
||||
"Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs.",
|
||||
"Lightning grew up: your LND wallet's recovery seed is captured at setup and kept as an encrypted backup you can reveal from Settings, there's a new Channels tab with a fee control when opening channels, and on-chain and Lightning balances now show side by side.",
|
||||
"Installing Lightning (and other Bitcoin-dependent apps) on a fresh node no longer fails repeatedly — the node now waits until Bitcoin is genuinely ready to answer before starting them, and Bitcoin sizes its storage to your actual disk and its memory cache to your RAM, so small machines stop swapping and stalling.",
|
||||
"The wallet understands more money: Cashu v4 tokens are supported, you can pay for a peer's files from either your Cashu or Fedimint ecash, and the Transactions view now shows your Lightning, Cashu, and Fedimint activity together — with a payment confirmation screen and an automatic refund if a purchase fails.",
|
||||
"Mesh radios got a major upgrade: Meshtastic direct messages are now true end-to-end-encrypted radio messages that interoperate with off-the-shelf Meshtastic phone apps, your radio's region and a shared channel are provisioned automatically, and a new setup window appears when a radio is plugged in — with board pictures, full radio settings, and signal-strength indicators.",
|
||||
"Reticulum joins as a third mesh radio protocol with RNode LoRa hardware support, including sending images and voice messages over the radio — and every chat message now carries a small pill showing how it travelled (Mesh, FIPS, or Tor).",
|
||||
"Your node can manage an OpenWrt router: set up its internet uplink from the UI with a Wi-Fi network scan, turn it into a TollGate pay-for-Wi-Fi hotspot with a real captive portal, and sweep the router's earnings into your node's wallet. The gateway's status appears on the Home screen's Network tile.",
|
||||
"Peering is now trust-aware: \"Invite a Peer\" grants view-only Observer access while \"Link Your Nodes\" grants Trusted access, incoming requests ask for your confirmation with an optional message, Node Visibility is a single clear switch plus a list of discoverable nodes you can peer with, and the Fleet view shows your trusted nodes' health.",
|
||||
"Updates and apps are verified end-to-end: release updates are cryptographically signed and checked against a key baked into your node, app definitions arrive via the signed catalog, and container images are checked against trusted sources before anything installs or runs.",
|
||||
"Dozens of reliability fixes: failed installs no longer leave phantom app cards, uninstalling can't hang forever, apps you stopped stay stopped, crashed apps heal themselves (even \"running\" containers whose process actually died), the login page no longer refresh-loops, and the mobile layout fits real phone screens instead of hiding the last row behind the browser bar."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.100-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.100-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.99-alpha",
|
||||
"new_version": "1.7.99-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.99-alpha/archipelago",
|
||||
"sha256": "88d9faa114c8b94db61f8dd352ad37701248f1245b2bf577bcdcbee57dac10c6",
|
||||
"size_bytes": 46313104
|
||||
"new_version": "1.7.100-alpha",
|
||||
"sha256": "9d317e8f308557af7f4f5928ccb39d452f61ff7eeff2024ef61067f72afc469a",
|
||||
"size_bytes": 49792496
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.99-alpha.tar.gz",
|
||||
"current_version": "1.7.99-alpha",
|
||||
"new_version": "1.7.99-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.99-alpha/archipelago-frontend-1.7.99-alpha.tar.gz",
|
||||
"sha256": "0ec526b585dda165878fb54b18c777a4ab373f6dc358a6217e5c4bd618010d35",
|
||||
"size_bytes": 177663765
|
||||
"current_version": "1.7.100-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.100-alpha/archipelago-frontend-1.7.100-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.100-alpha.tar.gz",
|
||||
"new_version": "1.7.100-alpha",
|
||||
"sha256": "b5962bc779f9e238b842398b13ef7727570afb181fcecbc14a1645cb96bdfb77",
|
||||
"size_bytes": 173254632
|
||||
}
|
||||
]
|
||||
],
|
||||
"release_date": "2026-07-14",
|
||||
"signature": "646e24ffddfb7f3769ca81e9f8c788d021636b9715a3d285b97a561116ce9224fe9b61a441f9d379feca549862e89e0cd31cb8eed6fd14ad177c97781cf4d10f",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.100-alpha"
|
||||
}
|
||||
|
||||
@ -1,34 +1,36 @@
|
||||
{
|
||||
"version": "1.7.99-alpha",
|
||||
"release_date": "2026-06-18",
|
||||
"changelog": [
|
||||
"Your node can now hold Fedimint ecash as well as Cashu. Wallet Settings now has tabbed sections for each: keep your list of trusted Cashu mints, or paste a Fedimint invite code to join a federation, and the home wallet card shows both your Cashu and Fedimint balances side by side. A new \"Fedimint Client\" app in the catalog powers the federation side.",
|
||||
"You can now buy files shared by another node, right from their cloud. When you open a peer's paid file you get a simple \"Buy this file\" picker with several ways to pay \u2014 instantly from this node's ecash balance, from your node's own Lightning wallet, on-chain from your node, or by scanning a Lightning QR code with any outside wallet. Once payment settles, the file downloads automatically.",
|
||||
"Your node can now act as an AI assistant on the off-grid mesh radio network. If your node has a local AI model available (via Ollama), other people on the mesh can ask it a question by starting their message with \"!ai\" and get an answer back over the radio \u2014 handy where there's no internet. A new Mesh assistant panel lets you turn this on or off and shows whether a local AI model was detected.",
|
||||
"You can now view your node's 24-word recovery phrase whenever you need it. Settings has a new \"Recovery phrase\" option that, after you confirm your password (and 2FA code if you use one), reveals the words behind a tap-to-show blur with a copy button \u2014 so you can write them down and store them safely offline.",
|
||||
"Setting up a brand-new node is smoother and less alarming. If the node is still starting up while you generate or confirm your recovery phrase, it now quietly waits and retries instead of flashing a scary error, and offers a clear \"Try again\" button only when something genuinely goes wrong. The final setup screen also shows a gentle \"securing your private connection\u2026\" status that turns to \"ready\" on its own, so you can tell the encrypted transport is coming up rather than stuck.",
|
||||
"The NetBird VPN app now actually logs in. It was failing to reach its sign-in screen because the dashboard needs a secure (HTTPS) connection that wasn't being provided; the node now serves it over HTTPS and opens it in a browser tab, so the login flow completes.",
|
||||
"When you use your phone to remote-control a node's attached screen, two-finger scrolling now works inside apps and panels, not just the main page. And tapping an app that's meant to open in an external browser now hands the link to your phone to open there, instead of trying to open it on the (often unattended) attached display.",
|
||||
"You can now choose whether your node shares Bitcoin block headers over the mesh. The Mesh Bitcoin panel has new switches to announce headers to peers and to accept headers from them, and your choices are remembered.",
|
||||
"Version numbers now display cleanly everywhere. In a few places the interface was showing a doubled \"v\" (like \"vv1.7.98\"); it now always shows a single, tidy version label.",
|
||||
"The \"Back\" buttons throughout the cloud and other detail screens now look and behave consistently on both desktop and mobile, including when browsing another node's files."
|
||||
"Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs.",
|
||||
"Lightning grew up: your LND wallet's recovery seed is captured at setup and kept as an encrypted backup you can reveal from Settings, there's a new Channels tab with a fee control when opening channels, and on-chain and Lightning balances now show side by side.",
|
||||
"Installing Lightning (and other Bitcoin-dependent apps) on a fresh node no longer fails repeatedly — the node now waits until Bitcoin is genuinely ready to answer before starting them, and Bitcoin sizes its storage to your actual disk and its memory cache to your RAM, so small machines stop swapping and stalling.",
|
||||
"The wallet understands more money: Cashu v4 tokens are supported, you can pay for a peer's files from either your Cashu or Fedimint ecash, and the Transactions view now shows your Lightning, Cashu, and Fedimint activity together — with a payment confirmation screen and an automatic refund if a purchase fails.",
|
||||
"Mesh radios got a major upgrade: Meshtastic direct messages are now true end-to-end-encrypted radio messages that interoperate with off-the-shelf Meshtastic phone apps, your radio's region and a shared channel are provisioned automatically, and a new setup window appears when a radio is plugged in — with board pictures, full radio settings, and signal-strength indicators.",
|
||||
"Reticulum joins as a third mesh radio protocol with RNode LoRa hardware support, including sending images and voice messages over the radio — and every chat message now carries a small pill showing how it travelled (Mesh, FIPS, or Tor).",
|
||||
"Your node can manage an OpenWrt router: set up its internet uplink from the UI with a Wi-Fi network scan, turn it into a TollGate pay-for-Wi-Fi hotspot with a real captive portal, and sweep the router's earnings into your node's wallet. The gateway's status appears on the Home screen's Network tile.",
|
||||
"Peering is now trust-aware: \"Invite a Peer\" grants view-only Observer access while \"Link Your Nodes\" grants Trusted access, incoming requests ask for your confirmation with an optional message, Node Visibility is a single clear switch plus a list of discoverable nodes you can peer with, and the Fleet view shows your trusted nodes' health.",
|
||||
"Updates and apps are verified end-to-end: release updates are cryptographically signed and checked against a key baked into your node, app definitions arrive via the signed catalog, and container images are checked against trusted sources before anything installs or runs.",
|
||||
"Dozens of reliability fixes: failed installs no longer leave phantom app cards, uninstalling can't hang forever, apps you stopped stay stopped, crashed apps heal themselves (even \"running\" containers whose process actually died), the login page no longer refresh-loops, and the mobile layout fits real phone screens instead of hiding the last row behind the browser bar."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.100-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.100-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"current_version": "1.7.99-alpha",
|
||||
"new_version": "1.7.99-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.99-alpha/archipelago",
|
||||
"sha256": "88d9faa114c8b94db61f8dd352ad37701248f1245b2bf577bcdcbee57dac10c6",
|
||||
"size_bytes": 46313104
|
||||
"new_version": "1.7.100-alpha",
|
||||
"sha256": "9d317e8f308557af7f4f5928ccb39d452f61ff7eeff2024ef61067f72afc469a",
|
||||
"size_bytes": 49792496
|
||||
},
|
||||
{
|
||||
"name": "archipelago-frontend-1.7.99-alpha.tar.gz",
|
||||
"current_version": "1.7.99-alpha",
|
||||
"new_version": "1.7.99-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.99-alpha/archipelago-frontend-1.7.99-alpha.tar.gz",
|
||||
"sha256": "0ec526b585dda165878fb54b18c777a4ab373f6dc358a6217e5c4bd618010d35",
|
||||
"size_bytes": 177663765
|
||||
"current_version": "1.7.100-alpha",
|
||||
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.100-alpha/archipelago-frontend-1.7.100-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.100-alpha.tar.gz",
|
||||
"new_version": "1.7.100-alpha",
|
||||
"sha256": "b5962bc779f9e238b842398b13ef7727570afb181fcecbc14a1645cb96bdfb77",
|
||||
"size_bytes": 173254632
|
||||
}
|
||||
]
|
||||
],
|
||||
"release_date": "2026-07-14",
|
||||
"signature": "646e24ffddfb7f3769ca81e9f8c788d021636b9715a3d285b97a561116ce9224fe9b61a441f9d379feca549862e89e0cd31cb8eed6fd14ad177c97781cf4d10f",
|
||||
"signed_by": "did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur",
|
||||
"version": "1.7.100-alpha"
|
||||
}
|
||||
|
||||
@ -64,6 +64,13 @@ FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0"
|
||||
# federation first (the interim default is node-local). See docs/dual-ecash-design.md.
|
||||
FMCD_IMAGE="$ARCHY_REGISTRY/fmcd:0.8.1"
|
||||
|
||||
# Ark (bark)
|
||||
# barkd = Ark wallet daemon, packaged from the pinned upstream release binary
|
||||
# (apps/barkd/Dockerfile). Signet-only default config; keep the tag in
|
||||
# lockstep with core/archipelago/src/wallet/ark_client.rs REST shapes. Not in
|
||||
# the bundled CONTAINER_IMAGES list — install via the barkd app manifest.
|
||||
BARKD_IMAGE="$ARCHY_REGISTRY/barkd:0.3.0"
|
||||
|
||||
# Media
|
||||
REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user