Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edcce5a308 | ||
|
|
e42bd26ec7 |
@@ -29,14 +29,3 @@ unrestricted `cargo test` on a node with installed apps: older mocked-runtime
|
|||||||
tests still reached real service commands. The runner isolates wallet data,
|
tests still reached real service commands. The runner isolates wallet data,
|
||||||
service buses, container storage, networking, and process IDs. Compilation with
|
service buses, container storage, networking, and process IDs. Compilation with
|
||||||
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
|
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
|
||||||
|
|
||||||
## Active release regression checklist
|
|
||||||
|
|
||||||
Before resuming release work, read
|
|
||||||
`docs/post-1.8.22-regressions-20261001.md` and retain its unfinished tasks.
|
|
||||||
The operator requested that every reported issue be tracked, fixed and tested
|
|
||||||
before another OTA/ISO. Keep source/unit-test results separate from actual-node
|
|
||||||
acceptance. In particular, paid-file recovery must not send another payment,
|
|
||||||
and app cleanup must preserve wallets, persistent data and uninstall decisions.
|
|
||||||
Do not mark the new paid-file incident resolved merely because the earlier
|
|
||||||
Framework LND startup incident was closed.
|
|
||||||
|
|||||||
@@ -2,12 +2,6 @@
|
|||||||
|
|
||||||
## v1.8.22-alpha (2026-09-30)
|
## v1.8.22-alpha (2026-09-30)
|
||||||
|
|
||||||
- Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.
|
|
||||||
|
|
||||||
- Network diagnostic failures no longer stop all apps or rebuild shared container networking.
|
|
||||||
- Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.
|
|
||||||
- Fixed companion dashboard builds still referencing a retired image registry.
|
|
||||||
|
|
||||||
- Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.
|
- Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.
|
||||||
|
|
||||||
- Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.
|
- Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
app:
|
||||||
|
id: nostr-vpn-web
|
||||||
|
name: Nostr VPN Control Panel
|
||||||
|
version: 1.0.0
|
||||||
|
upstream:
|
||||||
|
kind: github
|
||||||
|
repo: mmalmi/nostr-vpn
|
||||||
|
description: |
|
||||||
|
Web control panel for the nostr-vpn paid-exit seller (apps/nostr-vpn).
|
||||||
|
Talks to the daemon only through the shared /data volume (state-file
|
||||||
|
status + shelling out to the nvpn CLI) -- no network link between the
|
||||||
|
two containers, mirroring upstream's own umbrel/docker-compose.yml
|
||||||
|
exactly (read directly, not assumed). Same image as apps/nostr-vpn,
|
||||||
|
different entrypoint args.
|
||||||
|
category: money
|
||||||
|
|
||||||
|
container:
|
||||||
|
build:
|
||||||
|
context: /opt/archipelago/docker/nostr-vpn
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
tag: localhost/nostr-vpn:local
|
||||||
|
entrypoint: ["/usr/local/bin/archy-nvpn-entrypoint.sh"]
|
||||||
|
custom_args:
|
||||||
|
- /usr/local/bin/nvpn-web
|
||||||
|
- --listen
|
||||||
|
- 0.0.0.0:38080
|
||||||
|
- --behind-trusted-proxy
|
||||||
|
- --config
|
||||||
|
- /data/config/nvpn/config.toml
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- app_id: nostr-vpn
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 128Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
capabilities: []
|
||||||
|
readonly_root: false
|
||||||
|
no_new_privileges: true
|
||||||
|
network_policy: bridge
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- host: 38080
|
||||||
|
container: 38080
|
||||||
|
protocol: tcp
|
||||||
|
bind: 127.0.0.1
|
||||||
|
auth: gated
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Same volume as apps/nostr-vpn, read-write: the panel's wallet/seller
|
||||||
|
# actions (wallet send, paid-exit run) shell out to the nvpn CLI
|
||||||
|
# against this same config.toml and data dir, per
|
||||||
|
# NVPN_EXTERNAL_DAEMON/NVPN_DAEMON_STATUS_MODE below.
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/nostr-vpn
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- NVPN_CLI_PATH=/usr/local/bin/nvpn
|
||||||
|
- NVPN_DAEMON_STATUS_MODE=state-file
|
||||||
|
- NVPN_EXTERNAL_DAEMON=true
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: http
|
||||||
|
endpoint: http://127.0.0.1:38080
|
||||||
|
path: /
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Control Panel
|
||||||
|
description: nostr-vpn paid-exit status, wallet, and seller settings
|
||||||
|
type: ui
|
||||||
|
port: 38080
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
category: money
|
||||||
|
tier: optional
|
||||||
|
author: mmalmi
|
||||||
|
repo: https://github.com/mmalmi/nostr-vpn
|
||||||
|
features:
|
||||||
|
- Paid-exit seller status, wallet, and offer controls
|
||||||
|
- Shares state with apps/nostr-vpn via one data volume, no RPC link
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
app:
|
||||||
|
id: nostr-vpn
|
||||||
|
name: Nostr VPN (paid exit)
|
||||||
|
version: 1.0.0
|
||||||
|
# Pinned commit, not a tag -- upstream has no release tags yet. Re-pin
|
||||||
|
# deliberately in docker/nostr-vpn/Dockerfile's NVPN_COMMIT build arg; see
|
||||||
|
# docs/nostr-vpn-integration-plan.md for the Phase 0 feasibility log this
|
||||||
|
# pin was verified against.
|
||||||
|
upstream:
|
||||||
|
kind: github
|
||||||
|
repo: mmalmi/nostr-vpn
|
||||||
|
description: |
|
||||||
|
Sells spare bandwidth as a Nostr-discovered, Cashu-metered paid exit
|
||||||
|
(github.com/mmalmi/nostr-vpn). Runs rootless in its own network
|
||||||
|
namespace (pasta) -- NET_ADMIN/NET_RAW are scoped to that netns, never
|
||||||
|
the host. Seller mode defaults OFF (upstream's own `paid_exit.enabled`
|
||||||
|
default); turning it on is a separate step (Phase 3 UI, not yet built).
|
||||||
|
|
||||||
|
This replaces the old root-mode integration (image-recipe's
|
||||||
|
nostr-vpn.service running `nvpn daemon` as root, auto-enabled on first
|
||||||
|
login via rpc/auth.rs) that broke the rootless/no-OS-reliance
|
||||||
|
invariant. That old path and its RPC TOML-rewriting code
|
||||||
|
(rpc/vpn.rs::handle_vpn_add_participant) are a separate, higher-risk
|
||||||
|
removal -- not done here, since it's wired into every node's login
|
||||||
|
flow today, not just this app.
|
||||||
|
category: money
|
||||||
|
|
||||||
|
container:
|
||||||
|
build:
|
||||||
|
context: /opt/archipelago/docker/nostr-vpn
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
tag: localhost/nostr-vpn:local
|
||||||
|
network: pasta
|
||||||
|
# Image has no image-level ENTRYPOINT/CMD (see Dockerfile) -- both this
|
||||||
|
# app and nostr-vpn-web point the shared seed-config entrypoint at
|
||||||
|
# different binaries/args.
|
||||||
|
entrypoint: ["/usr/local/bin/archy-nvpn-entrypoint.sh"]
|
||||||
|
custom_args:
|
||||||
|
- /usr/local/bin/nvpn
|
||||||
|
- daemon
|
||||||
|
- --config
|
||||||
|
- /data/config/nvpn/config.toml
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- storage: 1Gi
|
||||||
|
|
||||||
|
resources:
|
||||||
|
memory_limit: 256Mi
|
||||||
|
|
||||||
|
security:
|
||||||
|
# NET_ADMIN/NET_RAW: TUN device + the exit forwarding/NAT nvpn installs
|
||||||
|
# itself inside its own netns (nvpn-exit-forward-in/out, nvpn-exit-masq,
|
||||||
|
# the MSS clamp) -- confirmed working rootless in Phase 0 testing, with
|
||||||
|
# no capabilities beyond these two plus the sysctl below. Host iptables
|
||||||
|
# and routes were confirmed untouched.
|
||||||
|
capabilities: [NET_ADMIN, NET_RAW]
|
||||||
|
# false: not verified read-only-root-compatible in Phase 0 testing (the
|
||||||
|
# working run flags there didn't include --read-only). nvpn's own state
|
||||||
|
# (config/identity/wallet) lives on the /data volume either way.
|
||||||
|
readonly_root: false
|
||||||
|
no_new_privileges: true
|
||||||
|
network_policy: isolated
|
||||||
|
|
||||||
|
# Rootless /proc/sys is read-only, so forwarding can only be set at
|
||||||
|
# container-create time via this primitive (added for exactly this app --
|
||||||
|
# see commit e42bd26). nvpn only *reads* ip_forward and writes it when 0,
|
||||||
|
# so setting it here once at create is enough; nvpn's own cleanup path
|
||||||
|
# leaves it alone.
|
||||||
|
sysctls:
|
||||||
|
net.ipv4.ip_forward: "1"
|
||||||
|
|
||||||
|
devices:
|
||||||
|
- /dev/net/tun
|
||||||
|
|
||||||
|
ports:
|
||||||
|
# Paid-exit buyers dial this directly from the open internet to pay for
|
||||||
|
# bandwidth -- it's the whole point of the app, not an admin surface,
|
||||||
|
# and it speaks nvpn's own FIPS UDP wire protocol, not HTTP, so the app
|
||||||
|
# gate cannot front it. 51822, not upstream's default 51820: that
|
||||||
|
# collides with archipelago-wg (kernel WireGuard) on fleet nodes --
|
||||||
|
# found running both side by side in Phase 0 testing.
|
||||||
|
- host: 51822
|
||||||
|
container: 51822
|
||||||
|
protocol: udp
|
||||||
|
auth: none
|
||||||
|
auth_rationale: >-
|
||||||
|
FIPS UDP transport for paid-exit buyers. Anonymous by design (not
|
||||||
|
HTTP), and the seller is off by default (paid_exit.enabled=false)
|
||||||
|
until an operator explicitly turns on selling, so exposure here
|
||||||
|
alone grants no access to anything.
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Adopts whatever a node already has under the old root-mode path
|
||||||
|
# (nostr-vpn.service wrote here too) -- an identity, wallet balance, or
|
||||||
|
# pending Cashu credit must survive this migration, not reset.
|
||||||
|
- type: bind
|
||||||
|
source: /var/lib/archipelago/nostr-vpn
|
||||||
|
target: /data
|
||||||
|
options: [rw]
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- NVPN_LISTEN_PORT=51822
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
type: exec
|
||||||
|
endpoint: nvpn status
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
metadata:
|
||||||
|
category: money
|
||||||
|
tier: optional
|
||||||
|
author: mmalmi
|
||||||
|
repo: https://github.com/mmalmi/nostr-vpn
|
||||||
|
features:
|
||||||
|
- Sell spare bandwidth as a Cashu-metered Nostr paid exit
|
||||||
|
- Rootless: own network namespace, no host network access
|
||||||
|
- Seller mode off by default
|
||||||
@@ -74,7 +74,7 @@ impl ApiHandler {
|
|||||||
let invoice_hash = headers
|
let invoice_hash = headers
|
||||||
.get("x-invoice-hash")
|
.get("x-invoice-hash")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.map(|s| s.to_ascii_lowercase())
|
.map(|s| s.to_string())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
headers
|
headers
|
||||||
.get("x-onchain-address")
|
.get("x-onchain-address")
|
||||||
@@ -98,46 +98,6 @@ impl ApiHandler {
|
|||||||
None => false,
|
None => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Payment settlement is verified on the seller even when no status
|
|
||||||
// poll preceded this download (e.g. direct payment from another node).
|
|
||||||
let requires_payment = if !owner_session && headers.contains_key("x-invoice-hash") {
|
|
||||||
content_server::load_catalog(&config.data_dir)
|
|
||||||
.await?
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.any(|item| {
|
|
||||||
item.id == content_id
|
|
||||||
&& matches!(item.access, content_server::AccessControl::Paid { .. })
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
if requires_payment {
|
|
||||||
if let Some(hash) = headers.get("x-invoice-hash").and_then(|v| v.to_str().ok()) {
|
|
||||||
if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) {
|
|
||||||
return Ok(build_response(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"text/plain",
|
|
||||||
hyper::Body::from("Invalid payment hash"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Err(error) = self
|
|
||||||
.rpc_handler
|
|
||||||
.settle_content_invoice(hash, content_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!("Cannot verify peer-file invoice settlement: {error:#}");
|
|
||||||
return Ok(build_response(
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"application/json",
|
|
||||||
hyper::Body::from(
|
|
||||||
r#"{"error":"Payment verification is temporarily unavailable. Retry the download without paying again."}"#,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Range header for streaming support
|
// Parse Range header for streaming support
|
||||||
let range = headers
|
let range = headers
|
||||||
.get("range")
|
.get("range")
|
||||||
@@ -289,13 +249,7 @@ impl ApiHandler {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
|
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
|
||||||
crate::content_invoice::record_pending(
|
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
|
||||||
&self.config.data_dir,
|
|
||||||
&payment_hash,
|
|
||||||
content_id,
|
|
||||||
price_sats,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"bolt11": bolt11,
|
"bolt11": bolt11,
|
||||||
"payment_hash": payment_hash,
|
"payment_hash": payment_hash,
|
||||||
@@ -355,10 +309,26 @@ impl ApiHandler {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let paid = self
|
// The hash must be one we issued for exactly this content item.
|
||||||
.rpc_handler
|
match crate::content_invoice::lookup(payment_hash).await {
|
||||||
.settle_content_invoice(payment_hash, content_id)
|
Some((cid, _)) if cid == content_id => {}
|
||||||
.await?;
|
_ => {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already paid? Otherwise ask our LND and persist the result.
|
||||||
|
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
|
||||||
|
if !paid {
|
||||||
|
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
|
||||||
|
crate::content_invoice::mark_paid(payment_hash).await;
|
||||||
|
paid = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let body = serde_json::json!({ "paid": paid });
|
let body = serde_json::json!({ "paid": paid });
|
||||||
Ok(build_response(
|
Ok(build_response(
|
||||||
@@ -419,13 +389,7 @@ impl ApiHandler {
|
|||||||
|
|
||||||
match self.rpc_handler.new_onchain_address().await {
|
match self.rpc_handler.new_onchain_address().await {
|
||||||
Ok(address) if !address.is_empty() => {
|
Ok(address) if !address.is_empty() => {
|
||||||
crate::content_invoice::record_pending(
|
crate::content_invoice::record_pending(&address, content_id, price_sats).await;
|
||||||
&self.config.data_dir,
|
|
||||||
&address,
|
|
||||||
content_id,
|
|
||||||
price_sats,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"address": address,
|
"address": address,
|
||||||
"amount_sats": price_sats,
|
"amount_sats": price_sats,
|
||||||
@@ -475,7 +439,7 @@ impl ApiHandler {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
// The address must be one we issued for exactly this content item.
|
// The address must be one we issued for exactly this content item.
|
||||||
let price = match crate::content_invoice::lookup(&self.config.data_dir, address).await? {
|
let price = match crate::content_invoice::lookup(address).await {
|
||||||
Some((cid, price)) if cid == content_id => price,
|
Some((cid, price)) if cid == content_id => price,
|
||||||
_ => {
|
_ => {
|
||||||
return Ok(build_response(
|
return Ok(build_response(
|
||||||
@@ -486,11 +450,10 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut paid =
|
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
|
||||||
crate::content_invoice::is_paid_for(&self.config.data_dir, address, content_id).await;
|
|
||||||
if !paid {
|
if !paid {
|
||||||
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
|
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
|
||||||
crate::content_invoice::mark_paid(&self.config.data_dir, address).await?;
|
crate::content_invoice::mark_paid(address).await;
|
||||||
paid = true;
|
paid = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,17 +73,6 @@ fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updated clients open the persisted file through the Range-capable HTTP
|
|
||||||
// endpoint. Avoid putting two base64 copies of a large video in a JSON reply.
|
|
||||||
// Keep older clients compatible until both sides have upgraded.
|
|
||||||
fn invoice_download_response(bytes: &[u8], mime: &str, cache_only: bool) -> serde_json::Value {
|
|
||||||
if cache_only {
|
|
||||||
serde_json::json!({ "owned": true, "mime_type": mime, "size_bytes": bytes.len() })
|
|
||||||
} else {
|
|
||||||
paid_content_response(bytes, mime, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// File purchases through an atomic no-clobber write in Files' own namespace.
|
/// File purchases through an atomic no-clobber write in Files' own namespace.
|
||||||
async fn file_purchase_in_files(
|
async fn file_purchase_in_files(
|
||||||
data_dir: &std::path::Path,
|
data_dir: &std::path::Path,
|
||||||
@@ -881,29 +870,10 @@ impl RpcHandler {
|
|||||||
if !is_valid_v3_onion(onion) {
|
if !is_valid_v3_onion(onion) {
|
||||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
if payment_hash.is_empty() || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||||
return Err(anyhow::anyhow!("Invalid payment_hash"));
|
return Err(anyhow::anyhow!("Invalid payment_hash"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let cache_only = params
|
|
||||||
.get("cache_only")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if let Some((mime, bytes)) =
|
|
||||||
crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await
|
|
||||||
{
|
|
||||||
return Ok(invoice_download_response(&bytes, &mime, cache_only));
|
|
||||||
}
|
|
||||||
// Older sellers only mark settlement during status polling. Always
|
|
||||||
// perform that handshake before requesting bytes; retries never pay.
|
|
||||||
// The download gate remains authoritative: a file may have become
|
|
||||||
// free, and newer sellers verify directly if status polling fails.
|
|
||||||
let _ = self
|
|
||||||
.handle_content_invoice_status(Some(serde_json::json!({
|
|
||||||
"onion": onion, "content_id": content_id, "payment_hash": payment_hash,
|
|
||||||
})))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||||
@@ -942,7 +912,7 @@ impl RpcHandler {
|
|||||||
|
|
||||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": "The seller has not confirmed access yet. Retry the download without paying again."
|
"error": "Seller has not registered this payment yet — wait for settlement and retry."
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
@@ -951,45 +921,16 @@ impl RpcHandler {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mime = response
|
|
||||||
.headers()
|
|
||||||
.get(reqwest::header::CONTENT_TYPE)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("application/octet-stream")
|
|
||||||
.split(';')
|
|
||||||
.next()
|
|
||||||
.unwrap_or("application/octet-stream")
|
|
||||||
.to_string();
|
|
||||||
let bytes = response
|
let bytes = response
|
||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.context("Paid file transfer interrupted; retry the download without paying again")?;
|
.context("Failed to read response body")?;
|
||||||
let filename = params
|
use base64::Engine;
|
||||||
.get("filename")
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
.and_then(|v| v.as_str())
|
Ok(serde_json::json!({
|
||||||
.unwrap_or(content_id);
|
"data": encoded,
|
||||||
crate::content_owned::record_purchase(
|
"size": bytes.len(),
|
||||||
&self.config.data_dir,
|
}))
|
||||||
onion,
|
|
||||||
content_id,
|
|
||||||
filename,
|
|
||||||
&mime,
|
|
||||||
&bytes,
|
|
||||||
params
|
|
||||||
.get("price_sats")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(0),
|
|
||||||
"lightning",
|
|
||||||
&chrono::Utc::now().to_rfc3339(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.context("Paid file could not be saved; retry the download without paying again")?;
|
|
||||||
if let Err(error) =
|
|
||||||
file_purchase_in_files(&self.config.data_dir, filename, &mime, &bytes).await
|
|
||||||
{
|
|
||||||
tracing::warn!("Lightning purchase cached; optional Files copy failed: {error:#}");
|
|
||||||
}
|
|
||||||
Ok(invoice_download_response(&bytes, &mime, cache_only))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
|
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
|
||||||
@@ -1464,19 +1405,3 @@ impl RpcHandler {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "content_tests.rs"]
|
#[path = "content_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod invoice_delivery_response_tests {
|
|
||||||
use super::*;
|
|
||||||
#[test]
|
|
||||||
fn cached_delivery_avoids_base64_but_keeps_old_clients_compatible() {
|
|
||||||
let cached = invoice_download_response(b"paid bytes", "video/mp4", true);
|
|
||||||
assert_eq!(cached["owned"], true);
|
|
||||||
assert_eq!(cached["size_bytes"], 10);
|
|
||||||
assert!(cached.get("data").is_none());
|
|
||||||
assert!(cached.get("data_base64").is_none());
|
|
||||||
let legacy = invoice_download_response(b"paid bytes", "video/mp4", false);
|
|
||||||
assert_eq!(legacy["data"], "cGFpZCBieXRlcw==");
|
|
||||||
assert_eq!(legacy["data"], legacy["data_base64"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -473,7 +473,6 @@ impl RpcHandler {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let fee_query = close_channel_fee_query(¶ms)?;
|
|
||||||
let force = params
|
let force = params
|
||||||
.get("force")
|
.get("force")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
@@ -499,11 +498,13 @@ impl RpcHandler {
|
|||||||
.build()
|
.build()
|
||||||
.context("Failed to create streaming HTTP client")?;
|
.context("Failed to create streaming HTTP client")?;
|
||||||
|
|
||||||
let url = format!("{LND_REST_BASE_URL}/v1/channels/{}/{}", parts[0], parts[1]);
|
let url = format!(
|
||||||
|
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
|
||||||
|
parts[0], parts[1], force
|
||||||
|
);
|
||||||
|
|
||||||
let mut resp = client
|
let mut resp = client
|
||||||
.delete(&url)
|
.delete(&url)
|
||||||
.query(&fee_query)
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -571,101 +572,3 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LND's CloseChannel REST endpoint takes fee selection as query parameters.
|
|
||||||
/// With neither parameter LND uses a lax target; keep legacy clients on our
|
|
||||||
/// explicit Standard target rather than silently accepting that default.
|
|
||||||
fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static str, String)>> {
|
|
||||||
let force = match params.get("force") {
|
|
||||||
None | Some(serde_json::Value::Null) => false,
|
|
||||||
Some(value) => value
|
|
||||||
.as_bool()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("force must be a boolean"))?,
|
|
||||||
};
|
|
||||||
let integer = |key: &str, max: u64| -> Result<Option<u64>> {
|
|
||||||
match params.get(key) {
|
|
||||||
None | Some(serde_json::Value::Null) => Ok(None),
|
|
||||||
Some(value) => {
|
|
||||||
let n = value
|
|
||||||
.as_u64()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("{key} must be a positive whole number"))?;
|
|
||||||
anyhow::ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}");
|
|
||||||
Ok(Some(n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let target = integer("target_conf", 1008)?;
|
|
||||||
let rate = integer("sat_per_vbyte", 5000)?;
|
|
||||||
anyhow::ensure!(
|
|
||||||
target.is_none() || rate.is_none(),
|
|
||||||
"Specify either target_conf or sat_per_vbyte, not both"
|
|
||||||
);
|
|
||||||
anyhow::ensure!(
|
|
||||||
!force || (target.is_none() && rate.is_none()),
|
|
||||||
"Closing fee selection requires a cooperative close"
|
|
||||||
);
|
|
||||||
let mut query = vec![("force", force.to_string())];
|
|
||||||
if !force {
|
|
||||||
if let Some(rate) = rate {
|
|
||||||
query.push(("sat_per_vbyte", rate.to_string()));
|
|
||||||
} else {
|
|
||||||
query.push(("target_conf", target.unwrap_or(6).to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod close_fee_tests {
|
|
||||||
use super::*;
|
|
||||||
#[test]
|
|
||||||
fn close_fee_query_forwards_presets_custom_and_legacy_default() {
|
|
||||||
for target in [1, 3, 6, 1008] {
|
|
||||||
assert_eq!(
|
|
||||||
close_channel_fee_query(&serde_json::json!({"target_conf":target})).unwrap(),
|
|
||||||
vec![
|
|
||||||
("force", "false".into()),
|
|
||||||
("target_conf", target.to_string())
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for rate in [1, 25, 5000] {
|
|
||||||
let query =
|
|
||||||
close_channel_fee_query(&serde_json::json!({"sat_per_vbyte":rate})).unwrap();
|
|
||||||
let request = reqwest::Client::new()
|
|
||||||
.delete("http://localhost/v1/channels/test/0")
|
|
||||||
.query(&query)
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(request.method(), reqwest::Method::DELETE);
|
|
||||||
assert_eq!(
|
|
||||||
request.url().query(),
|
|
||||||
Some(format!("force=false&sat_per_vbyte={rate}").as_str())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
close_channel_fee_query(&serde_json::json!({})).unwrap(),
|
|
||||||
vec![("force", "false".into()), ("target_conf", "6".into())]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
close_channel_fee_query(&serde_json::json!({"force":true})).unwrap(),
|
|
||||||
vec![("force", "true".into())]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn malformed_or_conflicting_close_fees_fail_before_wallet_access() {
|
|
||||||
for params in [
|
|
||||||
serde_json::json!({"target_conf":1,"sat_per_vbyte":2}),
|
|
||||||
serde_json::json!({"force":true,"target_conf":1}),
|
|
||||||
serde_json::json!({"force":"false"}),
|
|
||||||
serde_json::json!({"target_conf":0}),
|
|
||||||
serde_json::json!({"target_conf":1009}),
|
|
||||||
serde_json::json!({"sat_per_vbyte":5001}),
|
|
||||||
serde_json::json!({"sat_per_vbyte":-1}),
|
|
||||||
serde_json::json!({"sat_per_vbyte":1.5}),
|
|
||||||
serde_json::json!({"sat_per_vbyte":"25"}),
|
|
||||||
] {
|
|
||||||
assert!(close_channel_fee_query(¶ms).is_err(), "{params}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -453,56 +453,6 @@ impl RpcHandler {
|
|||||||
Ok(settled)
|
Ok(settled)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify against LND at download time, rather than relying on a browser
|
|
||||||
/// having polled first. The memo/amount also recover pre-upgrade in-memory
|
|
||||||
/// entitlements after restart; unrelated invoices never unlock a file.
|
|
||||||
pub(crate) async fn settle_content_invoice(
|
|
||||||
&self,
|
|
||||||
hash: &str,
|
|
||||||
content_id: &str,
|
|
||||||
) -> Result<bool> {
|
|
||||||
anyhow::ensure!(
|
|
||||||
hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()),
|
|
||||||
"Invalid payment hash"
|
|
||||||
);
|
|
||||||
let hash = hash.to_ascii_lowercase();
|
|
||||||
let existing = crate::content_invoice::lookup(&self.config.data_dir, &hash).await?;
|
|
||||||
if let Some((id, _)) = &existing {
|
|
||||||
if id != content_id {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if crate::content_invoice::is_paid_for(&self.config.data_dir, &hash, content_id).await {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
|
||||||
let response = client
|
|
||||||
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{hash}"))
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let body: serde_json::Value = response.error_for_status()?.json().await?;
|
|
||||||
let Some(price) = content_invoice_amount(&body, content_id) else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
if existing
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|(_, expected)| *expected != price)
|
|
||||||
{
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
crate::content_invoice::record_pending(&self.config.data_dir, &hash, content_id, price)
|
|
||||||
.await?;
|
|
||||||
let settled = content_invoice_fully_settled(&body, price);
|
|
||||||
if settled {
|
|
||||||
crate::content_invoice::mark_paid(&self.config.data_dir, &hash).await?;
|
|
||||||
}
|
|
||||||
Ok(settled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a fresh on-chain receive address (seller side, #46).
|
/// Generate a fresh on-chain receive address (seller side, #46).
|
||||||
pub(crate) async fn new_onchain_address(&self) -> Result<String> {
|
pub(crate) async fn new_onchain_address(&self) -> Result<String> {
|
||||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||||
@@ -1494,81 +1444,3 @@ mod tests {
|
|||||||
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
|
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LND REST uses decimal strings for int64 fields. Match the complete seller
|
|
||||||
// memo, not a substring supplied by a buyer or an arbitrary settled invoice.
|
|
||||||
fn json_u64(value: &serde_json::Value) -> Option<u64> {
|
|
||||||
value.as_u64().or_else(|| value.as_str()?.parse().ok())
|
|
||||||
}
|
|
||||||
fn content_invoice_fully_settled(body: &serde_json::Value, price: u64) -> bool {
|
|
||||||
let settled = match body.get("state").and_then(|v| v.as_str()) {
|
|
||||||
Some(state) => state == "SETTLED",
|
|
||||||
None => body.get("settled").and_then(|v| v.as_bool()) == Some(true),
|
|
||||||
};
|
|
||||||
settled
|
|
||||||
&& price > 0
|
|
||||||
&& body
|
|
||||||
.get("amt_paid_sat")
|
|
||||||
.and_then(json_u64)
|
|
||||||
.is_some_and(|paid| paid >= price)
|
|
||||||
}
|
|
||||||
fn content_invoice_amount(body: &serde_json::Value, content_id: &str) -> Option<u64> {
|
|
||||||
if body.get("memo")?.as_str()? != format!("Archipelago peer file {content_id}") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
body.get("value").and_then(json_u64).filter(|v| *v > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod peer_file_invoice_tests {
|
|
||||||
use super::*;
|
|
||||||
#[test]
|
|
||||||
fn settlement_requires_terminal_state_and_full_amount() {
|
|
||||||
for state in ["OPEN", "ACCEPTED", "CANCELED", "unknown"] {
|
|
||||||
assert!(!content_invoice_fully_settled(
|
|
||||||
&serde_json::json!({"state":state,"settled":true,"amt_paid_sat":"100"}),
|
|
||||||
7
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for amount in [
|
|
||||||
serde_json::json!(6),
|
|
||||||
serde_json::json!("-1"),
|
|
||||||
serde_json::json!(null),
|
|
||||||
serde_json::json!("bad"),
|
|
||||||
] {
|
|
||||||
assert!(!content_invoice_fully_settled(
|
|
||||||
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
|
|
||||||
7
|
|
||||||
));
|
|
||||||
}
|
|
||||||
for amount in [serde_json::json!(7), serde_json::json!("8")] {
|
|
||||||
assert!(content_invoice_fully_settled(
|
|
||||||
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
|
|
||||||
7
|
|
||||||
));
|
|
||||||
}
|
|
||||||
assert!(content_invoice_fully_settled(
|
|
||||||
&serde_json::json!({"settled":true,"amt_paid_sat":"7"}),
|
|
||||||
7
|
|
||||||
));
|
|
||||||
assert!(!content_invoice_fully_settled(
|
|
||||||
&serde_json::json!({"state":"SETTLED","amt_paid_sat":"7"}),
|
|
||||||
0
|
|
||||||
));
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn legacy_recovery_requires_exact_file_memo_and_positive_amount() {
|
|
||||||
let invoice = serde_json::json!({"memo":"Archipelago peer file file-1", "value":"7"});
|
|
||||||
assert_eq!(content_invoice_amount(&invoice, "file-1"), Some(7));
|
|
||||||
assert_eq!(content_invoice_amount(&invoice, "file-2"), None);
|
|
||||||
for value in [
|
|
||||||
serde_json::json!("-1"),
|
|
||||||
serde_json::json!(0),
|
|
||||||
serde_json::json!("bad"),
|
|
||||||
] {
|
|
||||||
let mut invalid = invoice.clone();
|
|
||||||
invalid["value"] = value;
|
|
||||||
assert_eq!(content_invoice_amount(&invalid, "file-1"), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -103,15 +103,6 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Missing companion UIs are provisioned here, never by snapshot recovery.
|
|
||||||
/// A stale running-container snapshot must not resurrect an orphaned UI.
|
|
||||||
pub fn is_companion_app(app_id: &str) -> bool {
|
|
||||||
ALL_COMPANIONS
|
|
||||||
.iter()
|
|
||||||
.flat_map(|specs| specs.iter())
|
|
||||||
.any(|spec| spec.image_base == app_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every companion this build knows how to provision. Kept beside
|
/// Every companion this build knows how to provision. Kept beside
|
||||||
/// `companions_for` — a new companion must be added to both, or the reaper
|
/// `companions_for` — a new companion must be added to both, or the reaper
|
||||||
/// will not recognise it as one of ours and will leave it running forever.
|
/// will not recognise it as one of ours and will leave it running forever.
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ use crate::data_model::{
|
|||||||
/// stopped-app restoration path in agreement with live-container discovery.
|
/// stopped-app restoration path in agreement with live-container discovery.
|
||||||
fn canonical_package_id(name: &str) -> &str {
|
fn canonical_package_id(name: &str) -> &str {
|
||||||
match name.strip_prefix("archy-").unwrap_or(name) {
|
match name.strip_prefix("archy-").unwrap_or(name) {
|
||||||
"immich_server" | "immich-server" => "immich",
|
"immich_server" => "immich",
|
||||||
"immich-postgres" => "immich_postgres",
|
|
||||||
"immich-redis" => "immich_redis",
|
|
||||||
"mempool-web" | "mempool-frontend" => "mempool",
|
"mempool-web" | "mempool-frontend" => "mempool",
|
||||||
name => name,
|
name => name,
|
||||||
}
|
}
|
||||||
@@ -445,19 +443,6 @@ mod lifecycle_regression_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn immich_dependency_aliases_share_the_hidden_component_ids() {
|
|
||||||
for id in [
|
|
||||||
"immich-postgres",
|
|
||||||
"immich_postgres",
|
|
||||||
"archy-immich-postgres",
|
|
||||||
] {
|
|
||||||
assert_eq!(canonical_package_id(id), "immich_postgres");
|
|
||||||
}
|
|
||||||
assert_eq!(canonical_package_id("immich-redis"), "immich_redis");
|
|
||||||
assert_eq!(canonical_package_id("immich-server"), "immich");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
|
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
|
||||||
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
|
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
|
||||||
@@ -993,20 +978,14 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
|
|||||||
let mut first_candidate = None;
|
let mut first_candidate = None;
|
||||||
for port_str in ports {
|
for port_str in ports {
|
||||||
// Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp"
|
// Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp"
|
||||||
let Some((public_part, _)) = port_str.split_once("->") else {
|
let Some(public_part) = port_str.split("->").next() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some((_, port_part)) = public_part.rsplit_once(':') else {
|
let Some(port_part) = public_part.split(':').nth(1) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// Extract just the first port if it's a range (e.g., "18443-18444" -> "18443")
|
// Extract just the first port if it's a range (e.g., "18443-18444" -> "18443")
|
||||||
let host_port = port_part.split('-').next().unwrap_or(port_part);
|
let host_port = port_part.split('-').next().unwrap_or(port_part);
|
||||||
let Ok(host_port) = host_port.parse::<u16>() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if host_port == 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let candidate = format!("http://localhost:{}", host_port);
|
let candidate = format!("http://localhost:{}", host_port);
|
||||||
if first_candidate.is_none() {
|
if first_candidate.is_none() {
|
||||||
first_candidate = Some(candidate.clone());
|
first_candidate = Some(candidate.clone());
|
||||||
@@ -1134,29 +1113,6 @@ fn package_launch_candidate(
|
|||||||
if let Some(companion) = companion_lan_address(app_id) {
|
if let Some(companion) = companion_lan_address(app_id) {
|
||||||
return Some(companion);
|
return Some(companion);
|
||||||
}
|
}
|
||||||
if app_id == "nginx-proxy-manager" {
|
|
||||||
// 80/443 serve users' proxy hosts; only container port 81 serves the
|
|
||||||
// admin UI. Podman's binding order is unstable across recreation.
|
|
||||||
// Resolve its actual host allocation rather than guessing the first
|
|
||||||
// HTTP port or hardcoding the default host port 8081.
|
|
||||||
let admin_ports: Vec<String> = ports
|
|
||||||
.iter()
|
|
||||||
.filter(|port| {
|
|
||||||
port.split_once("->")
|
|
||||||
.is_some_and(|(_, target)| target == "81/tcp")
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
// With published bindings, a missing admin mapping is not evidence
|
|
||||||
// that some unrelated service on the default host port is this UI.
|
|
||||||
return extract_lan_address(&admin_ports).or_else(|| {
|
|
||||||
if ports.is_empty() {
|
|
||||||
known
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if uses_allocated_launch_port(app_id) {
|
if uses_allocated_launch_port(app_id) {
|
||||||
extract_lan_address(ports).or(known)
|
extract_lan_address(ports).or(known)
|
||||||
} else {
|
} else {
|
||||||
@@ -1285,98 +1241,6 @@ mod extract_lan_address_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn npm_admin_launch_is_independent_of_proxy_binding_order() {
|
|
||||||
let mappings = [
|
|
||||||
"10.77.0.2:18081->80/tcp",
|
|
||||||
"10.77.0.2:18443->443/tcp",
|
|
||||||
"127.0.0.1:8081->81/tcp",
|
|
||||||
];
|
|
||||||
for order in [
|
|
||||||
[0, 1, 2],
|
|
||||||
[0, 2, 1],
|
|
||||||
[1, 0, 2],
|
|
||||||
[1, 2, 0],
|
|
||||||
[2, 0, 1],
|
|
||||||
[2, 1, 0],
|
|
||||||
] {
|
|
||||||
let ports: Vec<String> = order.iter().map(|&i| mappings[i].into()).collect();
|
|
||||||
assert_eq!(
|
|
||||||
package_launch_candidate(
|
|
||||||
"nginx-proxy-manager",
|
|
||||||
&ports,
|
|
||||||
Some("http://localhost:8081/".into())
|
|
||||||
)
|
|
||||||
.as_deref(),
|
|
||||||
Some("http://localhost:8081")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn npm_admin_launch_respects_host_allocation_and_ipv6_bindings() {
|
|
||||||
for binding in ["127.0.0.1", "0.0.0.0", "[::1]", "[::]"] {
|
|
||||||
let ports = vec![
|
|
||||||
"10.77.0.2:18081->80/tcp".into(),
|
|
||||||
format!("{binding}:28081->81/tcp"),
|
|
||||||
];
|
|
||||||
assert_eq!(
|
|
||||||
package_launch_candidate(
|
|
||||||
"nginx-proxy-manager",
|
|
||||||
&ports,
|
|
||||||
Some("http://localhost:8081/".into())
|
|
||||||
)
|
|
||||||
.as_deref(),
|
|
||||||
Some("http://localhost:28081")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let proxies = vec![
|
|
||||||
"10.77.0.2:18081->80/tcp".into(),
|
|
||||||
"10.77.0.2:18443->443/tcp".into(),
|
|
||||||
];
|
|
||||||
assert_eq!(
|
|
||||||
package_launch_candidate("nginx-proxy-manager", &proxies, None),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
package_launch_candidate(
|
|
||||||
"nginx-proxy-manager",
|
|
||||||
&proxies,
|
|
||||||
Some("http://localhost:8081/".into())
|
|
||||||
),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn npm_without_port_information_uses_declared_admin_url() {
|
|
||||||
assert_eq!(
|
|
||||||
package_launch_candidate(
|
|
||||||
"nginx-proxy-manager",
|
|
||||||
&[],
|
|
||||||
Some("http://localhost:8081/".into())
|
|
||||||
)
|
|
||||||
.as_deref(),
|
|
||||||
Some("http://localhost:8081/")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_published_ports_do_not_become_launch_urls() {
|
|
||||||
for port in [
|
|
||||||
"81/tcp",
|
|
||||||
"127.0.0.1:bad->81/tcp",
|
|
||||||
"[::1]:0->81/tcp",
|
|
||||||
"[::]:65536->81/tcp",
|
|
||||||
"127.0.0.1:8081->81/udp",
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
package_launch_candidate("nginx-proxy-manager", &[port.into()], None),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skips_ssh_port_when_web_port_is_published() {
|
fn skips_ssh_port_when_web_port_is_published() {
|
||||||
// gitea: SSH published before the web port, in podman's list order.
|
// gitea: SSH published before the web port, in podman's list order.
|
||||||
|
|||||||
@@ -2071,10 +2071,6 @@ impl ProdContainerOrchestrator {
|
|||||||
Ok(ReconcileAction::Left(reason))
|
Ok(ReconcileAction::Left(reason))
|
||||||
if mode == ReconcileMode::ExistingOnly
|
if mode == ReconcileMode::ExistingOnly
|
||||||
&& reason == "absent"
|
&& reason == "absent"
|
||||||
// companion.rs owns missing UI provisioning/removal.
|
|
||||||
// Never resurrect an orphan from a stale snapshot.
|
|
||||||
// Existing UIs still pass through security config repair.
|
|
||||||
&& !super::companion::is_companion_app(&app_id)
|
|
||||||
&& (was_running.contains(&compute_container_name(&lm.manifest))
|
&& (was_running.contains(&compute_container_name(&lm.manifest))
|
||||||
// The durable answer, and the one that does not
|
// The durable answer, and the one that does not
|
||||||
// erode. `was_running` only records what was
|
// erode. `was_running` only records what was
|
||||||
@@ -3153,12 +3149,7 @@ impl ProdContainerOrchestrator {
|
|||||||
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
|
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
|
||||||
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
|
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
|
||||||
let restart_for_security_change = quadlet::security_changed(&old_body, &new_body);
|
let restart_for_security_change = quadlet::security_changed(&old_body, &new_body);
|
||||||
let restart_for_managed_override =
|
|
||||||
quadlet::redundant_managed_network_override(&unit, &unit_dir)
|
|
||||||
.await?
|
|
||||||
.is_some();
|
|
||||||
let needs_restart = restart_required
|
let needs_restart = restart_required
|
||||||
|| restart_for_managed_override
|
|
||||||
|| restart_for_port_change
|
|| restart_for_port_change
|
||||||
|| restart_for_network_alias_change
|
|| restart_for_network_alias_change
|
||||||
|| restart_for_exec_change
|
|| restart_for_exec_change
|
||||||
@@ -4886,26 +4877,6 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
|||||||
/// here (production volumes live under `/var/lib/archipelago` — removal is a
|
/// here (production volumes live under `/var/lib/archipelago` — removal is a
|
||||||
/// separate operation owned by the data layer, not this orchestrator).
|
/// separate operation owned by the data layer, not this orchestrator).
|
||||||
async fn remove(&self, app_id: &str, _preserve_data: bool) -> Result<()> {
|
async fn remove(&self, app_id: &str, _preserve_data: bool) -> Result<()> {
|
||||||
// A removed catalog entry must remain uninstallable. The RPC caller
|
|
||||||
// still removes legacy containers and persists uninstall intent after
|
|
||||||
// confirming they are gone; do not block it on a missing manifest.
|
|
||||||
if !self.state.read().await.manifests.contains_key(app_id) {
|
|
||||||
anyhow::ensure!(
|
|
||||||
!app_id.is_empty()
|
|
||||||
&& app_id.len() <= 128
|
|
||||||
&& app_id
|
|
||||||
.bytes()
|
|
||||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_')),
|
|
||||||
"Invalid app id"
|
|
||||||
);
|
|
||||||
let lock = self.app_lock(app_id).await;
|
|
||||||
let _guard = lock.lock().await;
|
|
||||||
for name in [app_id.to_string(), format!("archy-{app_id}")] {
|
|
||||||
self.remove_quadlet_unit_if_present(&name).await?;
|
|
||||||
}
|
|
||||||
self.state.write().await.disabled.insert(app_id.to_string());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let lm = self.loaded(app_id).await?;
|
let lm = self.loaded(app_id).await?;
|
||||||
let lock = self.app_lock(app_id).await;
|
let lock = self.app_lock(app_id).await;
|
||||||
let _guard = lock.lock().await;
|
let _guard = lock.lock().await;
|
||||||
@@ -7254,52 +7225,6 @@ app:
|
|||||||
assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
|
assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reconcile_existing_does_not_resurrect_orphaned_companions() {
|
|
||||||
let rt = Arc::new(MockRuntime::default());
|
|
||||||
let mut orch = orch_with(rt.clone()).await;
|
|
||||||
orch.set_disk_gb_for_test(500);
|
|
||||||
let companions = [
|
|
||||||
"bitcoin-ui",
|
|
||||||
"electrs-ui",
|
|
||||||
"lnd-ui",
|
|
||||||
"fedimint-ui",
|
|
||||||
"cuprate-ui",
|
|
||||||
];
|
|
||||||
let mut names = Vec::new();
|
|
||||||
for id in companions {
|
|
||||||
let manifest = pull_manifest(id, "localhost/companion:local");
|
|
||||||
names.push(compute_container_name(&manifest));
|
|
||||||
orch.insert_manifest_for_test(manifest, PathBuf::from("/tmp/companion"))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
|
|
||||||
crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &refs).await;
|
|
||||||
// Repeated passes must leave lifecycle ownership with companion.rs.
|
|
||||||
for _ in 0..3 {
|
|
||||||
let report = orch.reconcile_existing().await;
|
|
||||||
assert_eq!(report.actions.len(), companions.len());
|
|
||||||
assert!(report
|
|
||||||
.actions
|
|
||||||
.iter()
|
|
||||||
.all(|(_, action)| *action == ReconcileAction::Left("absent".into())));
|
|
||||||
assert!(report.failures.is_empty());
|
|
||||||
}
|
|
||||||
let calls = rt.calls();
|
|
||||||
for operation in [
|
|
||||||
"pull_image:",
|
|
||||||
"create_container:",
|
|
||||||
"start_container:",
|
|
||||||
"stop_container:",
|
|
||||||
"remove_container:",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
!calls.iter().any(|call| call.starts_with(operation)),
|
|
||||||
"{calls:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reconcile_existing_self_heals_missing_optional_installed_app() {
|
async fn reconcile_existing_self_heals_missing_optional_installed_app() {
|
||||||
// A non-baseline app (gitea) self-heals ONLY with installation
|
// A non-baseline app (gitea) self-heals ONLY with installation
|
||||||
@@ -7460,19 +7385,6 @@ app:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn removed_catalog_entry_does_not_block_legacy_uninstall() {
|
|
||||||
let rt = Arc::new(MockRuntime::default());
|
|
||||||
let orch = orch_with(rt.clone()).await;
|
|
||||||
orch.remove("cryptpad", true).await.unwrap();
|
|
||||||
assert!(orch.state.read().await.disabled.contains("cryptpad"));
|
|
||||||
assert!(
|
|
||||||
rt.calls().is_empty(),
|
|
||||||
"legacy RPC teardown owns the actual containers"
|
|
||||||
);
|
|
||||||
assert!(orch.remove("../other", true).await.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn remove_disables_manifest_so_reconcile_does_not_reinstall() {
|
async fn remove_disables_manifest_so_reconcile_does_not_reinstall() {
|
||||||
let rt = Arc::new(MockRuntime::default());
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ pub struct QuadletUnit {
|
|||||||
/// for rotation-drift detection.
|
/// for rotation-drift detection.
|
||||||
pub labels: Vec<(String, String)>,
|
pub labels: Vec<(String, String)>,
|
||||||
pub devices: Vec<String>,
|
pub devices: Vec<String>,
|
||||||
|
/// Namespaced sysctls (`Sysctl=k=v`), already allow-listed by the manifest.
|
||||||
|
pub sysctls: Vec<(String, String)>,
|
||||||
pub add_hosts: Vec<(String, String)>,
|
pub add_hosts: Vec<(String, String)>,
|
||||||
pub network_aliases: Vec<String>,
|
pub network_aliases: Vec<String>,
|
||||||
pub entrypoint: Option<Vec<String>>,
|
pub entrypoint: Option<Vec<String>>,
|
||||||
@@ -307,6 +309,9 @@ impl QuadletUnit {
|
|||||||
for dev in &self.devices {
|
for dev in &self.devices {
|
||||||
let _ = writeln!(s, "AddDevice={dev}");
|
let _ = writeln!(s, "AddDevice={dev}");
|
||||||
}
|
}
|
||||||
|
for (k, v) in &self.sysctls {
|
||||||
|
let _ = writeln!(s, "Sysctl={k}={v}");
|
||||||
|
}
|
||||||
for (name, ip) in &self.add_hosts {
|
for (name, ip) in &self.add_hosts {
|
||||||
let _ = writeln!(s, "AddHost={name}:{ip}");
|
let _ = writeln!(s, "AddHost={name}:{ip}");
|
||||||
}
|
}
|
||||||
@@ -521,6 +526,11 @@ impl QuadletUnit {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
devices: app.devices.clone(),
|
devices: app.devices.clone(),
|
||||||
|
sysctls: app
|
||||||
|
.sysctls
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect(),
|
||||||
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
||||||
// Container always answers to its own name; manifest extras add the
|
// Container always answers to its own name; manifest extras add the
|
||||||
// short hostnames peers bake in (e.g. indeedhub api/minio/relay).
|
// short hostnames peers bake in (e.g. indeedhub api/minio/relay).
|
||||||
@@ -713,76 +723,29 @@ pub async fn unit_dir() -> Result<PathBuf> {
|
|||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The early same-node Portainer repair used a managed Quadlet drop-in. Once
|
/// Atomically write `unit` into `dir/<name>.container` if the bytes
|
||||||
/// the manifest supplies slirp, the two Network= entries are additive and
|
/// differ from what's already there. Returns true if the file changed.
|
||||||
/// Podman rejects startup. Retire only that exact redundant managed override;
|
|
||||||
/// arbitrary operator settings must survive reconciliation.
|
|
||||||
pub async fn redundant_managed_network_override(
|
|
||||||
unit: &QuadletUnit,
|
|
||||||
dir: &Path,
|
|
||||||
) -> Result<Option<PathBuf>> {
|
|
||||||
if unit.name != "portainer" || !matches!(unit.network, NetworkMode::Slirp4netns) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let path = dir.join("portainer.container.d/archy-same-node-network.conf");
|
|
||||||
let body = match fs::read_to_string(&path).await {
|
|
||||||
Ok(body) => body,
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
||||||
Err(error) => return Err(error).context("read managed Portainer network override"),
|
|
||||||
};
|
|
||||||
let lines: Vec<&str> = body
|
|
||||||
.lines()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|line| !line.is_empty() && !line.starts_with(['#', ';']))
|
|
||||||
.collect();
|
|
||||||
Ok((lines == ["[Container]", "Network=slirp4netns"]).then_some(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn retire_managed_network_override(path: &Path) -> Result<()> {
|
|
||||||
let backup = path.with_extension("conf.retired");
|
|
||||||
match fs::hard_link(path, &backup).await {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
|
||||||
anyhow::ensure!(
|
|
||||||
fs::read(path).await? == fs::read(&backup).await?,
|
|
||||||
"Existing Portainer override backup differs; preserve both for operator review"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(error) => return Err(error).context("back up managed Portainer network override"),
|
|
||||||
}
|
|
||||||
fs::remove_file(path)
|
|
||||||
.await
|
|
||||||
.context("retire redundant Portainer network override")?;
|
|
||||||
tracing::info!("Retired redundant managed Portainer network override; backup retained");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Atomically write the manifest unit and retire known redundant managed
|
|
||||||
/// overrides. Returns true whenever systemd needs a daemon-reload.
|
|
||||||
pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result<bool> {
|
pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result<bool> {
|
||||||
let path = dir.join(unit.unit_filename());
|
let path = dir.join(unit.unit_filename());
|
||||||
let new_bytes = unit.render();
|
let new_bytes = unit.render();
|
||||||
let redundant = redundant_managed_network_override(unit, dir).await?;
|
|
||||||
let changed = fs::read_to_string(&path)
|
if let Ok(old) = fs::read_to_string(&path).await {
|
||||||
|
if old == new_bytes {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(dir)
|
||||||
.await
|
.await
|
||||||
.map(|old| old != new_bytes)
|
.with_context(|| format!("create_dir_all {}", dir.display()))?;
|
||||||
.unwrap_or(true);
|
let tmp = path.with_extension("container.tmp");
|
||||||
if changed {
|
fs::write(&tmp, new_bytes.as_bytes())
|
||||||
fs::create_dir_all(dir)
|
.await
|
||||||
.await
|
.with_context(|| format!("write tmp {}", tmp.display()))?;
|
||||||
.with_context(|| format!("create_dir_all {}", dir.display()))?;
|
fs::rename(&tmp, &path)
|
||||||
let tmp = path.with_extension("container.tmp");
|
.await
|
||||||
fs::write(&tmp, new_bytes.as_bytes())
|
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
|
||||||
.await
|
Ok(true)
|
||||||
.with_context(|| format!("write tmp {}", tmp.display()))?;
|
|
||||||
fs::rename(&tmp, &path)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
|
|
||||||
}
|
|
||||||
if let Some(override_path) = &redundant {
|
|
||||||
retire_managed_network_override(override_path).await?;
|
|
||||||
}
|
|
||||||
Ok(changed || redundant.is_some())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reload the user systemd manager. Required after any quadlet write
|
/// Reload the user systemd manager. Required after any quadlet write
|
||||||
@@ -1534,6 +1497,7 @@ app:
|
|||||||
"RELAY_NAME=Archipelago Nostr Relay".into(),
|
"RELAY_NAME=Archipelago Nostr Relay".into(),
|
||||||
],
|
],
|
||||||
devices: vec!["/dev/kvm".into()],
|
devices: vec!["/dev/kvm".into()],
|
||||||
|
sysctls: vec![("net.ipv4.ip_forward".into(), "1".into())],
|
||||||
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
add_hosts: vec![("host.archipelago".into(), "10.89.0.1".into())],
|
||||||
entrypoint: Some(vec!["/usr/local/bin/bitcoind".into()]),
|
entrypoint: Some(vec!["/usr/local/bin/bitcoind".into()]),
|
||||||
command: vec!["-server=1".into(), "-rpcbind=0.0.0.0".into()],
|
command: vec!["-server=1".into(), "-rpcbind=0.0.0.0".into()],
|
||||||
@@ -1550,6 +1514,7 @@ app:
|
|||||||
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
|
assert!(s.contains("Environment=BITCOIN_RPC_PASS=secret"));
|
||||||
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
|
assert!(s.contains("Environment=\"RELAY_NAME=Archipelago Nostr Relay\""));
|
||||||
assert!(s.contains("AddDevice=/dev/kvm"));
|
assert!(s.contains("AddDevice=/dev/kvm"));
|
||||||
|
assert!(s.contains("Sysctl=net.ipv4.ip_forward=1"));
|
||||||
assert!(s.contains("AddHost=host.archipelago:10.89.0.1"));
|
assert!(s.contains("AddHost=host.archipelago:10.89.0.1"));
|
||||||
assert!(s.contains("ReadOnly=true"));
|
assert!(s.contains("ReadOnly=true"));
|
||||||
assert!(s.contains("NoNewPrivileges=true"));
|
assert!(s.contains("NoNewPrivileges=true"));
|
||||||
@@ -1571,6 +1536,7 @@ app:
|
|||||||
assert!(!s.contains("PublishPort="));
|
assert!(!s.contains("PublishPort="));
|
||||||
assert!(!s.contains("Environment="));
|
assert!(!s.contains("Environment="));
|
||||||
assert!(!s.contains("AddDevice="));
|
assert!(!s.contains("AddDevice="));
|
||||||
|
assert!(!s.contains("Sysctl="));
|
||||||
assert!(!s.contains("AddHost="));
|
assert!(!s.contains("AddHost="));
|
||||||
assert!(!s.contains("ReadOnly="));
|
assert!(!s.contains("ReadOnly="));
|
||||||
assert!(!s.contains("NoNewPrivileges="));
|
assert!(!s.contains("NoNewPrivileges="));
|
||||||
@@ -1668,6 +1634,31 @@ app:
|
|||||||
assert!(!s.contains("Network=host"));
|
assert!(!s.contains("Network=host"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_manifest_renders_namespaced_sysctls() {
|
||||||
|
let yaml = r#"
|
||||||
|
app:
|
||||||
|
id: vpn-exit
|
||||||
|
name: VPN Exit
|
||||||
|
version: 1.0.0
|
||||||
|
container:
|
||||||
|
image: test/vpn:1.0.0
|
||||||
|
network: pasta
|
||||||
|
devices: [/dev/net/tun]
|
||||||
|
sysctls:
|
||||||
|
net.ipv4.ip_forward: "1"
|
||||||
|
security:
|
||||||
|
capabilities: [NET_ADMIN, NET_RAW]
|
||||||
|
"#;
|
||||||
|
let m = AppManifest::parse(yaml).expect("manifest must parse");
|
||||||
|
let s = QuadletUnit::from_manifest(&m, "vpn-exit").render();
|
||||||
|
|
||||||
|
assert!(s.contains("Network=pasta"));
|
||||||
|
assert!(s.contains("AddDevice=/dev/net/tun"));
|
||||||
|
assert!(s.contains("Sysctl=net.ipv4.ip_forward=1"));
|
||||||
|
assert!(s.contains("AddCapability=NET_ADMIN"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
|
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
|
||||||
let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml"))
|
let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml"))
|
||||||
@@ -2038,80 +2029,6 @@ app:
|
|||||||
assert!(!network_aliases_changed(new, new));
|
assert!(!network_aliases_changed(new, new));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn redundant_portainer_override_is_backed_up_and_retired_even_when_base_matches() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let manifest =
|
|
||||||
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap();
|
|
||||||
let unit = QuadletUnit::from_manifest(&manifest, "portainer");
|
|
||||||
assert!(write_if_changed(&unit, dir.path()).await.unwrap());
|
|
||||||
let path = dir
|
|
||||||
.path()
|
|
||||||
.join("portainer.container.d/archy-same-node-network.conf");
|
|
||||||
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
|
|
||||||
let old = "[Container]\nNetwork=slirp4netns\n";
|
|
||||||
fs::write(&path, old).await.unwrap();
|
|
||||||
assert!(redundant_managed_network_override(&unit, dir.path())
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.is_some());
|
|
||||||
assert!(write_if_changed(&unit, dir.path()).await.unwrap());
|
|
||||||
assert!(!path.exists());
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(path.with_extension("conf.retired"))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
old
|
|
||||||
);
|
|
||||||
assert!(!write_if_changed(&unit, dir.path()).await.unwrap());
|
|
||||||
assert_eq!(
|
|
||||||
fs::read_to_string(dir.path().join("portainer.container"))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.matches("Network=slirp4netns")
|
|
||||||
.count(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn network_override_migration_preserves_operator_customizations_and_failed_backups() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let manifest =
|
|
||||||
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap();
|
|
||||||
let mut unit = QuadletUnit::from_manifest(&manifest, "portainer");
|
|
||||||
let path = dir
|
|
||||||
.path()
|
|
||||||
.join("portainer.container.d/archy-same-node-network.conf");
|
|
||||||
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
|
|
||||||
for custom in [
|
|
||||||
"[Container]\nNetwork=custom-net\n",
|
|
||||||
"[Container]\nNetwork=slirp4netns\nEnvironment=OPERATOR_SETTING=1\n",
|
|
||||||
] {
|
|
||||||
fs::write(&path, custom).await.unwrap();
|
|
||||||
assert!(redundant_managed_network_override(&unit, dir.path())
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.is_none());
|
|
||||||
write_if_changed(&unit, dir.path()).await.unwrap();
|
|
||||||
assert_eq!(fs::read_to_string(&path).await.unwrap(), custom);
|
|
||||||
}
|
|
||||||
fs::write(&path, "[Container]\nNetwork=slirp4netns\n")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
unit.network = NetworkMode::Pasta;
|
|
||||||
assert!(redundant_managed_network_override(&unit, dir.path())
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.is_none());
|
|
||||||
unit.network = NetworkMode::Slirp4netns;
|
|
||||||
fs::write(path.with_extension("conf.retired"), "different backup")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(write_if_changed(&unit, dir.path()).await.is_err());
|
|
||||||
assert!(path.exists(), "failure must preserve the active override");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
|
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,155 +1,80 @@
|
|||||||
//! Durable seller-side entitlements for peer-file invoices and on-chain sales.
|
//! Seller-side pending entitlements for Lightning-invoice peer-file sales (#46).
|
||||||
//! Payment records must outlive browser polling, process restarts and invoice
|
//!
|
||||||
//! expiry: an invoice can settle while the buyer is disconnected.
|
//! When a buyer asks to pay for a paid catalog item with an external wallet (as
|
||||||
|
//! opposed to the local-ecash fast path), the *selling* node mints a Lightning
|
||||||
|
//! invoice on its own LND and records a pending entitlement here, keyed by the
|
||||||
|
//! invoice's payment hash. The buyer pays the invoice from any wallet and polls
|
||||||
|
//! for settlement; once the seller's LND confirms the invoice is settled we mark
|
||||||
|
//! the entitlement paid, and the content gate (`content_server::serve_content`)
|
||||||
|
//! then releases the file to anyone presenting that payment hash.
|
||||||
|
//!
|
||||||
|
//! State is in-memory and bounded by a TTL. If the seller restarts before the
|
||||||
|
//! buyer pays, the buyer simply requests a fresh invoice — no value is lost
|
||||||
|
//! because an unpaid invoice represents no money.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use std::collections::HashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use std::sync::LazyLock;
|
||||||
use sha2::{Digest, Sha256};
|
use std::time::{Duration, Instant};
|
||||||
use std::path::{Path, PathBuf};
|
use tokio::sync::Mutex;
|
||||||
use tokio::{fs, io::AsyncWriteExt, sync::Mutex};
|
|
||||||
|
|
||||||
static WRITES: Mutex<()> = Mutex::const_new(());
|
/// How long a pending/paid entitlement is retained. Generous enough for a human
|
||||||
|
/// to pay an invoice and download, short enough to keep the map small.
|
||||||
|
const ENTITLEMENT_TTL: Duration = Duration::from_secs(3600); // 1 hour
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone)]
|
||||||
struct Entitlement {
|
struct Entitlement {
|
||||||
content_id: String,
|
content_id: String,
|
||||||
price_sats: u64,
|
price_sats: u64,
|
||||||
paid: bool,
|
paid: bool,
|
||||||
|
created_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn path(data_dir: &Path, token: &str) -> PathBuf {
|
static ENTITLEMENTS: LazyLock<Mutex<HashMap<String, Entitlement>>> =
|
||||||
data_dir.join("content-entitlements").join(format!(
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
"{}.json",
|
|
||||||
hex::encode(Sha256::digest(token.as_bytes()))
|
/// Drop expired entries. Caller must hold the lock.
|
||||||
))
|
fn prune(map: &mut HashMap<String, Entitlement>) {
|
||||||
|
map.retain(|_, e| e.created_at.elapsed() < ENTITLEMENT_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read(data_dir: &Path, token: &str) -> Result<Option<Entitlement>> {
|
/// Record a freshly-minted invoice as a pending (unpaid) entitlement.
|
||||||
match fs::read(path(data_dir, token)).await {
|
pub async fn record_pending(payment_hash: &str, content_id: &str, price_sats: u64) {
|
||||||
Ok(bytes) => Ok(Some(
|
let mut map = ENTITLEMENTS.lock().await;
|
||||||
serde_json::from_slice(&bytes).context("Invalid payment entitlement")?,
|
prune(&mut map);
|
||||||
)),
|
map.insert(
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
payment_hash.to_string(),
|
||||||
Err(e) => Err(e).context("Reading payment entitlement"),
|
Entitlement {
|
||||||
}
|
content_id: content_id.to_string(),
|
||||||
}
|
|
||||||
|
|
||||||
async fn write(data_dir: &Path, token: &str, entry: &Entitlement) -> Result<()> {
|
|
||||||
let target = path(data_dir, token);
|
|
||||||
let dir = target.parent().unwrap();
|
|
||||||
fs::create_dir_all(dir).await?;
|
|
||||||
let tmp = target.with_extension("tmp");
|
|
||||||
let mut file = fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create(true)
|
|
||||||
.truncate(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(&tmp)
|
|
||||||
.await?;
|
|
||||||
file.write_all(&serde_json::to_vec(entry)?).await?;
|
|
||||||
file.sync_all().await?;
|
|
||||||
drop(file);
|
|
||||||
fs::rename(&tmp, &target).await?;
|
|
||||||
fs::File::open(dir).await?.sync_all().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save before exposing an invoice/address to the buyer. Never overwrite an
|
|
||||||
/// existing payment or silently rebind its token to another item or price.
|
|
||||||
pub async fn record_pending(
|
|
||||||
data_dir: &Path,
|
|
||||||
token: &str,
|
|
||||||
content_id: &str,
|
|
||||||
price_sats: u64,
|
|
||||||
) -> Result<()> {
|
|
||||||
let _lock = WRITES.lock().await;
|
|
||||||
if let Some(existing) = read(data_dir, token).await? {
|
|
||||||
anyhow::ensure!(
|
|
||||||
existing.content_id == content_id && existing.price_sats == price_sats,
|
|
||||||
"Payment entitlement mismatch"
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
write(
|
|
||||||
data_dir,
|
|
||||||
token,
|
|
||||||
&Entitlement {
|
|
||||||
content_id: content_id.into(),
|
|
||||||
price_sats,
|
price_sats,
|
||||||
paid: false,
|
paid: false,
|
||||||
|
created_at: Instant::now(),
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mark_paid(data_dir: &Path, token: &str) -> Result<()> {
|
/// Mark the entitlement for `payment_hash` paid. No-op if unknown/expired.
|
||||||
let _lock = WRITES.lock().await;
|
pub async fn mark_paid(payment_hash: &str) {
|
||||||
let mut entry = read(data_dir, token)
|
let mut map = ENTITLEMENTS.lock().await;
|
||||||
.await?
|
prune(&mut map);
|
||||||
.context("Unknown payment entitlement")?;
|
if let Some(e) = map.get_mut(payment_hash) {
|
||||||
entry.paid = true;
|
e.paid = true;
|
||||||
write(data_dir, token, &entry).await
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn lookup(data_dir: &Path, token: &str) -> Result<Option<(String, u64)>> {
|
/// The content_id + price an entitlement was issued for, if still live.
|
||||||
Ok(read(data_dir, token)
|
pub async fn lookup(payment_hash: &str) -> Option<(String, u64)> {
|
||||||
.await?
|
let mut map = ENTITLEMENTS.lock().await;
|
||||||
.map(|e| (e.content_id, e.price_sats)))
|
prune(&mut map);
|
||||||
|
map.get(payment_hash)
|
||||||
|
.map(|e| (e.content_id.clone(), e.price_sats))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_paid_for(data_dir: &Path, token: &str, content_id: &str) -> bool {
|
/// True if `payment_hash` is a paid entitlement for exactly `content_id`.
|
||||||
read(data_dir, token)
|
/// This is the gate the content server consults to release a file.
|
||||||
.await
|
pub async fn is_paid_for(payment_hash: &str, content_id: &str) -> bool {
|
||||||
.ok()
|
let mut map = ENTITLEMENTS.lock().await;
|
||||||
.flatten()
|
prune(&mut map);
|
||||||
|
map.get(payment_hash)
|
||||||
.map(|e| e.paid && e.content_id == content_id)
|
.map(|e| e.paid && e.content_id == content_id)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
#[tokio::test]
|
|
||||||
async fn paid_entitlement_survives_reload_and_cannot_be_rebound() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
record_pending(dir.path(), "hash", "file", 12)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(!is_paid_for(dir.path(), "hash", "file").await);
|
|
||||||
mark_paid(dir.path(), "hash").await.unwrap();
|
|
||||||
// All reads reopen disk; no process-local entitlement map exists.
|
|
||||||
assert!(is_paid_for(dir.path(), "hash", "file").await);
|
|
||||||
assert!(!is_paid_for(dir.path(), "hash", "other").await);
|
|
||||||
record_pending(dir.path(), "hash", "file", 12)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(is_paid_for(dir.path(), "hash", "file").await);
|
|
||||||
assert!(record_pending(dir.path(), "hash", "other", 12)
|
|
||||||
.await
|
|
||||||
.is_err());
|
|
||||||
assert!(record_pending(dir.path(), "hash", "file", 13)
|
|
||||||
.await
|
|
||||||
.is_err());
|
|
||||||
let other = tempfile::tempdir().unwrap();
|
|
||||||
assert!(!is_paid_for(other.path(), "hash", "file").await);
|
|
||||||
assert!(mark_paid(dir.path(), "unknown").await.is_err());
|
|
||||||
}
|
|
||||||
#[tokio::test]
|
|
||||||
async fn corrupt_or_unwritable_records_fail_closed() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
record_pending(dir.path(), "../../token", "file", 1)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
fs::write(path(dir.path(), "../../token"), b"broken")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(lookup(dir.path(), "../../token").await.is_err());
|
|
||||||
assert!(!is_paid_for(dir.path(), "../../token", "file").await);
|
|
||||||
assert!(record_pending(dir.path(), "../../token", "file", 1)
|
|
||||||
.await
|
|
||||||
.is_err());
|
|
||||||
let file = dir.path().join("not-directory");
|
|
||||||
fs::write(&file, b"x").await.unwrap();
|
|
||||||
assert!(record_pending(&file, "hash", "file", 1).await.is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tokio::{fs, io::AsyncWriteExt, sync::Mutex};
|
use tokio::fs;
|
||||||
|
|
||||||
static PURCHASE_WRITES: Mutex<()> = Mutex::const_new(());
|
|
||||||
|
|
||||||
const OWNED_DIR: &str = "purchased-content";
|
const OWNED_DIR: &str = "purchased-content";
|
||||||
const OWNED_INDEX: &str = "owned.json";
|
const OWNED_INDEX: &str = "owned.json";
|
||||||
@@ -68,42 +66,11 @@ fn bytes_path(data_dir: &Path, onion: &str, content_id: &str) -> PathBuf {
|
|||||||
.join(sanitize(content_id))
|
.join(sanitize(content_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_index_checked(data_dir: &Path) -> Result<OwnedIndex> {
|
|
||||||
match fs::read_to_string(index_path(data_dir)).await {
|
|
||||||
Ok(s) => serde_json::from_str(&s)
|
|
||||||
.context("Invalid purchase index; existing records were preserved"),
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(OwnedIndex::default()),
|
|
||||||
Err(error) => Err(error).context("Reading purchase index"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_index(data_dir: &Path) -> OwnedIndex {
|
async fn load_index(data_dir: &Path) -> OwnedIndex {
|
||||||
load_index_checked(data_dir).await.unwrap_or_default()
|
match fs::read_to_string(index_path(data_dir)).await {
|
||||||
}
|
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||||
|
Err(_) => OwnedIndex::default(),
|
||||||
async fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
|
||||||
let parent = path.parent().context("Purchase path has no parent")?;
|
|
||||||
fs::create_dir_all(parent).await?;
|
|
||||||
let temp = parent.join(format!(".purchase-{}.tmp", uuid::Uuid::new_v4()));
|
|
||||||
let result = async {
|
|
||||||
let mut file = fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(&temp)
|
|
||||||
.await?;
|
|
||||||
file.write_all(bytes).await?;
|
|
||||||
file.sync_all().await?;
|
|
||||||
drop(file);
|
|
||||||
fs::rename(&temp, path).await?;
|
|
||||||
fs::File::open(parent).await?.sync_all().await?;
|
|
||||||
Ok::<_, anyhow::Error>(())
|
|
||||||
}
|
}
|
||||||
.await;
|
|
||||||
if result.is_err() {
|
|
||||||
let _ = fs::remove_file(&temp).await;
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
|
async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
|
||||||
@@ -112,7 +79,7 @@ async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.with_context(|| format!("creating {}", root.display()))?;
|
.with_context(|| format!("creating {}", root.display()))?;
|
||||||
let content = serde_json::to_string_pretty(index).context("serializing owned index")?;
|
let content = serde_json::to_string_pretty(index).context("serializing owned index")?;
|
||||||
atomic_write(&index_path(data_dir), content.as_bytes())
|
fs::write(index_path(data_dir), content)
|
||||||
.await
|
.await
|
||||||
.context("writing owned index")
|
.context("writing owned index")
|
||||||
}
|
}
|
||||||
@@ -131,15 +98,17 @@ pub async fn record_purchase(
|
|||||||
ecash_backend: &str,
|
ecash_backend: &str,
|
||||||
purchased_at: &str,
|
purchased_at: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Read-modify-write must be one serialized transaction. Never replace a
|
|
||||||
// damaged index with an empty one, and never expose partially written bytes.
|
|
||||||
let _lock = PURCHASE_WRITES.lock().await;
|
|
||||||
let mut index = load_index_checked(data_dir).await?;
|
|
||||||
let path = bytes_path(data_dir, onion, content_id);
|
let path = bytes_path(data_dir, onion, content_id);
|
||||||
atomic_write(&path, bytes)
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("creating {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
fs::write(&path, bytes)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("writing purchased bytes to {}", path.display()))?;
|
.with_context(|| format!("writing purchased bytes to {}", path.display()))?;
|
||||||
|
|
||||||
|
let mut index = load_index(data_dir).await;
|
||||||
let entry = OwnedItem {
|
let entry = OwnedItem {
|
||||||
onion: onion.to_string(),
|
onion: onion.to_string(),
|
||||||
content_id: content_id.to_string(),
|
content_id: content_id.to_string(),
|
||||||
@@ -196,90 +165,3 @@ pub async fn read_owned(
|
|||||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
Some((mime, bytes))
|
Some((mime, bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
#[tokio::test]
|
|
||||||
async fn concurrent_purchases_preserve_every_item_and_exact_bytes() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let mut jobs = tokio::task::JoinSet::new();
|
|
||||||
for n in 0..24 {
|
|
||||||
let root = dir.path().to_path_buf();
|
|
||||||
jobs.spawn(async move {
|
|
||||||
let id = format!("file-{n}");
|
|
||||||
record_purchase(
|
|
||||||
&root,
|
|
||||||
"seller.onion",
|
|
||||||
&id,
|
|
||||||
&id,
|
|
||||||
"text/plain",
|
|
||||||
id.as_bytes(),
|
|
||||||
5,
|
|
||||||
"lightning",
|
|
||||||
"now",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
while let Some(result) = jobs.join_next().await {
|
|
||||||
result.unwrap();
|
|
||||||
}
|
|
||||||
assert_eq!(list_owned(dir.path()).await.len(), 24);
|
|
||||||
for n in 0..24 {
|
|
||||||
let id = format!("file-{n}");
|
|
||||||
assert!(is_owned(dir.path(), "seller.onion", &id).await);
|
|
||||||
let (mime, bytes) = read_owned(dir.path(), "seller.onion", &id).await.unwrap();
|
|
||||||
assert_eq!(mime, "text/plain");
|
|
||||||
assert_eq!(bytes, id.as_bytes());
|
|
||||||
}
|
|
||||||
record_purchase(
|
|
||||||
dir.path(),
|
|
||||||
"seller.onion",
|
|
||||||
"file-0",
|
|
||||||
"file-0",
|
|
||||||
"text/plain",
|
|
||||||
b"updated",
|
|
||||||
5,
|
|
||||||
"lightning",
|
|
||||||
"later",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(list_owned(dir.path()).await.len(), 24);
|
|
||||||
assert_eq!(
|
|
||||||
read_owned(dir.path(), "seller.onion", "file-0")
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.1,
|
|
||||||
b"updated"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
#[tokio::test]
|
|
||||||
async fn damaged_index_is_preserved_instead_of_erasing_prior_ownership() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
fs::create_dir_all(owned_root(dir.path())).await.unwrap();
|
|
||||||
fs::write(index_path(dir.path()), b"damaged but preserve me")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(record_purchase(
|
|
||||||
dir.path(),
|
|
||||||
"seller.onion",
|
|
||||||
"new",
|
|
||||||
"new",
|
|
||||||
"text/plain",
|
|
||||||
b"bytes",
|
|
||||||
5,
|
|
||||||
"lightning",
|
|
||||||
"now"
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.is_err());
|
|
||||||
assert_eq!(
|
|
||||||
fs::read(index_path(dir.path())).await.unwrap(),
|
|
||||||
b"damaged but preserve me"
|
|
||||||
);
|
|
||||||
assert!(!bytes_path(dir.path(), "seller.onion", "new").exists());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ where
|
|||||||
if !authorized {
|
if !authorized {
|
||||||
if let Some(hash) = invoice_hash {
|
if let Some(hash) = invoice_hash {
|
||||||
if method_accepted(&item.access, "lightning")
|
if method_accepted(&item.access, "lightning")
|
||||||
&& crate::content_invoice::is_paid_for(data_dir, hash, id).await
|
&& crate::content_invoice::is_paid_for(hash, id).await
|
||||||
{
|
{
|
||||||
authorized = true;
|
authorized = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -664,10 +664,6 @@ pub async fn start_stopped_stack_containers(data_dir: &Path) -> RecoveryReport {
|
|||||||
start_stopped_app_stacks(data_dir).await
|
start_stopped_app_stacks(data_dir).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stack_member_needs_recovery(state: Option<&str>, user_stopped: bool) -> bool {
|
|
||||||
!user_stopped && matches!(state, Some("exited" | "stopped" | "created" | "configured"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
||||||
let user_stopped = load_user_stopped(data_dir).await;
|
let user_stopped = load_user_stopped(data_dir).await;
|
||||||
let mut report = RecoveryReport {
|
let mut report = RecoveryReport {
|
||||||
@@ -681,27 +677,24 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthy members must never acquire a restarting overlay merely
|
info!(
|
||||||
// because the periodic recovery scan ran. Queue existing stopped
|
"Recovering stopped {} stack containers after boot",
|
||||||
// members only; recheck each immediately before starting below.
|
stack.name
|
||||||
let mut pending = Vec::new();
|
);
|
||||||
for container in stack.containers {
|
|
||||||
let state = container_state(container).await;
|
|
||||||
if stack_member_needs_recovery(state.as_deref(), user_stopped.contains(*container)) {
|
|
||||||
pending.push((*container).to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pending.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
info!("Recovering stopped {} stack containers", stack.name);
|
|
||||||
repair_stack_network_aliases(stack).await;
|
repair_stack_network_aliases(stack).await;
|
||||||
pending_boot_starts_add(pending.iter().cloned());
|
|
||||||
|
// Register the whole stack up front: the per-member dependency waits
|
||||||
|
// below can take minutes, and the UI should say "Restarting", not
|
||||||
|
// "Stopped", for members still queued behind them.
|
||||||
|
pending_boot_starts_add(
|
||||||
|
stack
|
||||||
|
.containers
|
||||||
|
.iter()
|
||||||
|
.filter(|c| !user_stopped.contains(**c))
|
||||||
|
.map(|c| (*c).to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
for container in stack.containers {
|
for container in stack.containers {
|
||||||
if !pending.iter().any(|name| name.as_str() == *container) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if user_stopped.contains(*container) {
|
if user_stopped.contains(*container) {
|
||||||
info!("Skipping user-stopped container: {}", container);
|
info!("Skipping user-stopped container: {}", container);
|
||||||
continue;
|
continue;
|
||||||
@@ -713,8 +706,8 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
|||||||
pending_boot_start_done(container);
|
pending_boot_start_done(container);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Some(state) if stack_member_needs_recovery(Some(&state), false) => {}
|
Some(_) => {}
|
||||||
_ => {
|
None => {
|
||||||
pending_boot_start_done(container);
|
pending_boot_start_done(container);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1541,24 +1534,3 @@ mod installed_concurrency_tests {
|
|||||||
assert!(!dir.path().join("installed-apps.json.tmp").exists());
|
assert!(!dir.path().join("installed-apps.json.tmp").exists());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod stack_recovery_overlay_tests {
|
|
||||||
use super::stack_member_needs_recovery;
|
|
||||||
#[test]
|
|
||||||
fn only_existing_stopped_members_receive_recovery_overlay() {
|
|
||||||
for state in [
|
|
||||||
None,
|
|
||||||
Some("running"),
|
|
||||||
Some("paused"),
|
|
||||||
Some("restarting"),
|
|
||||||
Some("removing"),
|
|
||||||
] {
|
|
||||||
assert!(!stack_member_needs_recovery(state, false));
|
|
||||||
}
|
|
||||||
for state in ["exited", "stopped", "created", "configured"] {
|
|
||||||
assert!(stack_member_needs_recovery(Some(state), false));
|
|
||||||
assert!(!stack_member_needs_recovery(Some(state), true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -54,6 +54,12 @@ pub struct AppDefinition {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub devices: Vec<String>,
|
pub devices: Vec<String>,
|
||||||
|
|
||||||
|
/// Namespaced kernel parameters for the app's OWN network namespace
|
||||||
|
/// (podman `--sysctl`). Allow-listed to [`ALLOWED_SYSCTLS`] and rejected
|
||||||
|
/// under host networking, where they would change the host itself.
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub sysctls: BTreeMap<String, String>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub interfaces: HashMap<String, AppInterface>,
|
pub interfaces: HashMap<String, AppInterface>,
|
||||||
|
|
||||||
@@ -1009,6 +1015,11 @@ impl AppManifest {
|
|||||||
}
|
}
|
||||||
validate_environment(&self.app.environment)?;
|
validate_environment(&self.app.environment)?;
|
||||||
validate_devices(&self.app.devices)?;
|
validate_devices(&self.app.devices)?;
|
||||||
|
validate_sysctls(
|
||||||
|
&self.app.sysctls,
|
||||||
|
self.app.container.network.as_deref(),
|
||||||
|
&self.app.security.network_policy,
|
||||||
|
)?;
|
||||||
|
|
||||||
// Volume tmpfs_options: only meaningful for type: tmpfs.
|
// Volume tmpfs_options: only meaningful for type: tmpfs.
|
||||||
for (i, v) in self.app.volumes.iter().enumerate() {
|
for (i, v) in self.app.volumes.iter().enumerate() {
|
||||||
@@ -1342,6 +1353,45 @@ fn validate_devices(devices: &[String]) -> Result<(), ManifestError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sysctls an app may set. Each is scoped to the container's own network
|
||||||
|
/// namespace, so it cannot reach the host. Packet forwarding is what a
|
||||||
|
/// routing app (a VPN exit) needs, and rootless `/proc/sys` is read-only
|
||||||
|
/// inside the container, so it can only be set at create time.
|
||||||
|
pub const ALLOWED_SYSCTLS: &[&str] = &["net.ipv4.ip_forward", "net.ipv6.conf.all.forwarding"];
|
||||||
|
|
||||||
|
fn validate_sysctls(
|
||||||
|
sysctls: &BTreeMap<String, String>,
|
||||||
|
network: Option<&str>,
|
||||||
|
network_policy: &str,
|
||||||
|
) -> Result<(), ManifestError> {
|
||||||
|
if sysctls.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let host_network = match network {
|
||||||
|
Some(n) => n == "host",
|
||||||
|
None => network_policy == "host",
|
||||||
|
};
|
||||||
|
if host_network {
|
||||||
|
return Err(ManifestError::Invalid(
|
||||||
|
"sysctls require the app's own network namespace, not host networking".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (key, value) in sysctls {
|
||||||
|
if !ALLOWED_SYSCTLS.contains(&key.as_str()) {
|
||||||
|
return Err(ManifestError::Invalid(format!(
|
||||||
|
"sysctls.{key} is not allowed (allowed: {})",
|
||||||
|
ALLOWED_SYSCTLS.join(", ")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if value != "0" && value != "1" {
|
||||||
|
return Err(ManifestError::Invalid(format!(
|
||||||
|
"sysctls.{key} must be \"0\" or \"1\""
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> {
|
fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> {
|
||||||
let path = std::path::Path::new(source);
|
let path = std::path::Path::new(source);
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
@@ -2784,6 +2834,72 @@ app:
|
|||||||
assert_eq!(m.app.ports[2].bind, "");
|
assert_eq!(m.app.ports[2].bind, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sysctl_manifest(network: &str, sysctls: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
app:
|
||||||
|
id: sysctl-app
|
||||||
|
name: Sysctl App
|
||||||
|
version: 1.0.0
|
||||||
|
container:
|
||||||
|
image: test/image:1.0.0
|
||||||
|
network: {network}
|
||||||
|
sysctls:
|
||||||
|
{sysctls}
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forwarding_sysctls_parse_in_own_netns() {
|
||||||
|
let m = AppManifest::parse(&sysctl_manifest(
|
||||||
|
"pasta",
|
||||||
|
" net.ipv4.ip_forward: \"1\"\n net.ipv6.conf.all.forwarding: \"0\"",
|
||||||
|
))
|
||||||
|
.expect("allow-listed forwarding sysctls must validate");
|
||||||
|
assert_eq!(m.app.sysctls["net.ipv4.ip_forward"], "1");
|
||||||
|
assert_eq!(m.app.sysctls["net.ipv6.conf.all.forwarding"], "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sysctls_absent_by_default_and_not_serialized() {
|
||||||
|
let m = AppManifest::parse(
|
||||||
|
"app:\n id: plain\n name: Plain\n version: 1.0.0\n container:\n image: test/image:1.0.0\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(m.app.sysctls.is_empty());
|
||||||
|
assert!(!serde_yaml::to_string(&m).unwrap().contains("sysctls"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsafe_sysctls_are_rejected() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
sysctl_manifest("pasta", " kernel.core_pattern: \"|/bin/sh\""),
|
||||||
|
"not allowed",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
sysctl_manifest("pasta", " net.ipv4.ip_forward: \"2\""),
|
||||||
|
"must be \"0\" or \"1\"",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
sysctl_manifest("host", " net.ipv4.ip_forward: \"1\""),
|
||||||
|
"own network namespace",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
// No explicit network: the host policy still means the host netns.
|
||||||
|
sysctl_manifest("pasta", " net.ipv4.ip_forward: \"1\"")
|
||||||
|
.replace(" network: pasta\n", "")
|
||||||
|
.replace(" sysctls:", " security:\n network_policy: host\n sysctls:"),
|
||||||
|
"own network namespace",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (yaml, expected) in cases {
|
||||||
|
let msg = AppManifest::parse(&yaml).unwrap_err().to_string();
|
||||||
|
assert!(msg.contains(expected), "expected '{expected}', got: {msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reviewed_host_bind_exceptions_parse() {
|
fn reviewed_host_bind_exceptions_parse() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ impl PodmanClient {
|
|||||||
"devices": manifest.app.devices.iter().map(|d| {
|
"devices": manifest.app.devices.iter().map(|d| {
|
||||||
serde_json::json!({"path": d})
|
serde_json::json!({"path": d})
|
||||||
}).collect::<Vec<_>>(),
|
}).collect::<Vec<_>>(),
|
||||||
|
"sysctl": manifest.app.sysctls,
|
||||||
"resource_limits": resource_limits,
|
"resource_limits": resource_limits,
|
||||||
"cap_add": cap_add,
|
"cap_add": cap_add,
|
||||||
"cap_drop": cap_drop,
|
"cap_drop": cap_drop,
|
||||||
|
|||||||
@@ -712,6 +712,9 @@ impl ContainerRuntime for DockerRuntime {
|
|||||||
for device in &manifest.app.devices {
|
for device in &manifest.app.devices {
|
||||||
cmd.arg("--device").arg(device);
|
cmd.arg("--device").arg(device);
|
||||||
}
|
}
|
||||||
|
for (key, value) in &manifest.app.sysctls {
|
||||||
|
cmd.arg("--sysctl").arg(format!("{key}={value}"));
|
||||||
|
}
|
||||||
|
|
||||||
// Environment variables
|
// Environment variables
|
||||||
for env in &manifest.app.environment {
|
for env in &manifest.app.environment {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
|
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||||
# Static site content.
|
# Static site content.
|
||||||
COPY index.html /usr/share/nginx/html/
|
COPY index.html /usr/share/nginx/html/
|
||||||
COPY tailwind.css /usr/share/nginx/html/
|
COPY tailwind.css /usr/share/nginx/html/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
|
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||||
# Static site content.
|
# Static site content.
|
||||||
COPY index.html /usr/share/nginx/html/
|
COPY index.html /usr/share/nginx/html/
|
||||||
COPY 50x.html /usr/share/nginx/html/
|
COPY 50x.html /usr/share/nginx/html/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
|
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||||
COPY index.html /usr/share/nginx/html/
|
COPY index.html /usr/share/nginx/html/
|
||||||
COPY 50x.html /usr/share/nginx/html/
|
COPY 50x.html /usr/share/nginx/html/
|
||||||
COPY qrcode.js /usr/share/nginx/html/
|
COPY qrcode.js /usr/share/nginx/html/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
|
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||||
|
|
||||||
COPY index.html /usr/share/nginx/html/index.html
|
COPY index.html /usr/share/nginx/html/index.html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
|
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||||
# Static site content.
|
# Static site content.
|
||||||
COPY index.html /usr/share/nginx/html/
|
COPY index.html /usr/share/nginx/html/
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
|
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
|
||||||
|
|
||||||
# Copy the HTML file
|
# Copy the HTML file
|
||||||
COPY index.html /usr/share/nginx/html/
|
COPY index.html /usr/share/nginx/html/
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Packages nostr-vpn (github.com/mmalmi/nostr-vpn) as the paid-exit seller
|
||||||
|
# daemon + its web control panel. Both apps/nostr-vpn and apps/nostr-vpn-web
|
||||||
|
# build from this one image (same binaries, different entrypoint/command),
|
||||||
|
# mirroring upstream's own umbrel/docker-compose.yml, which runs `daemon`
|
||||||
|
# and `web` as two containers sharing one /data volume with no network link
|
||||||
|
# between them — reviewed directly, not assumed.
|
||||||
|
#
|
||||||
|
# This is upstream's own umbrel/Dockerfile, unchanged except for how the
|
||||||
|
# source arrives (a pinned commit tarball here, instead of a local checkout
|
||||||
|
# in their build context) — see docs/nostr-vpn-integration-plan.md for why
|
||||||
|
# the pin exists and what was verified against this exact commit.
|
||||||
|
ARG NVPN_COMMIT=87f19447741998ab5a06aadc701abc7ae021004b
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim AS source
|
||||||
|
ARG NVPN_COMMIT
|
||||||
|
# git clone, not a codeload.github.com/archive/<sha>.tar.gz tarball: the
|
||||||
|
# latter 404s from this environment even for refs/heads/main HEAD (network
|
||||||
|
# policy on that specific endpoint, not a real upstream 404 — plain
|
||||||
|
# `git clone https://github.com/...` works fine).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /src
|
||||||
|
# GitHub's anonymous smart-HTTP upload-pack refuses to fetch an arbitrary
|
||||||
|
# SHA directly (only advertised refs) — fetch main by name and verify the
|
||||||
|
# pinned commit is actually what we land on, so a force-push to main can't
|
||||||
|
# silently swap out the reviewed code.
|
||||||
|
RUN git init -q . \
|
||||||
|
&& git remote add origin https://github.com/mmalmi/nostr-vpn.git \
|
||||||
|
&& git fetch -q --depth 1 origin master \
|
||||||
|
&& git checkout -q FETCH_HEAD \
|
||||||
|
&& test "$(git rev-parse HEAD)" = "${NVPN_COMMIT}" \
|
||||||
|
&& rm -rf .git
|
||||||
|
|
||||||
|
FROM node:24-bookworm AS web-builder
|
||||||
|
WORKDIR /work/web/control-panel
|
||||||
|
COPY --from=source /src/web/control-panel/package.json /src/web/control-panel/pnpm-lock.yaml ./
|
||||||
|
RUN --mount=type=cache,id=nostr-vpn-pnpm-store,target=/pnpm/store \
|
||||||
|
corepack enable \
|
||||||
|
&& corepack prepare pnpm@10.28.2 --activate \
|
||||||
|
&& pnpm install --frozen-lockfile --store-dir /pnpm/store
|
||||||
|
COPY --from=source /src/web/control-panel ./
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
FROM rust:1.94-bookworm AS rust-builder
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
WORKDIR /work
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
clang \
|
||||||
|
libclang-dev \
|
||||||
|
libdbus-1-dev \
|
||||||
|
pkg-config \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=source /src/Cargo.toml /src/Cargo.lock ./
|
||||||
|
COPY --from=source /src/crates ./crates
|
||||||
|
COPY --from=source /src/vendor ./vendor
|
||||||
|
RUN --mount=type=cache,id=nostr-vpn-cargo-registry-${TARGETPLATFORM},target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,id=nostr-vpn-cargo-git-${TARGETPLATFORM},target=/usr/local/cargo/git \
|
||||||
|
--mount=type=cache,id=nostr-vpn-cargo-target-${TARGETPLATFORM},target=/work/target \
|
||||||
|
cargo build --release -p nvpn -p nostr-vpn-web \
|
||||||
|
&& mkdir -p /out \
|
||||||
|
&& cp /work/target/release/nvpn /out/nvpn \
|
||||||
|
&& cp /work/target/release/nostr-vpn-web /out/nvpn-web
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/mmalmi/nostr-vpn" \
|
||||||
|
org.opencontainers.image.description="nostr-vpn, packaged as an Archipelago paid-exit seller app" \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
iproute2 \
|
||||||
|
iptables \
|
||||||
|
iputils-ping \
|
||||||
|
libdbus-1-3 \
|
||||||
|
procps \
|
||||||
|
wireguard-tools \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=rust-builder /out/nvpn /usr/local/bin/nvpn
|
||||||
|
COPY --from=rust-builder /out/nvpn-web /usr/local/bin/nvpn-web
|
||||||
|
COPY --from=web-builder /work/web/control-panel/dist /usr/share/nostr-vpn/web
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/archy-nvpn-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/archy-nvpn-entrypoint.sh
|
||||||
|
|
||||||
|
ENV HOME=/data/home \
|
||||||
|
XDG_CONFIG_HOME=/data/config \
|
||||||
|
NVPN_CLI_PATH=/usr/local/bin/nvpn \
|
||||||
|
RUST_LOG=info
|
||||||
|
|
||||||
|
EXPOSE 38080
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
# No image-level ENTRYPOINT/CMD: apps/nostr-vpn and apps/nostr-vpn-web set
|
||||||
|
# their own entrypoint/custom_args in their manifests (daemon vs. web),
|
||||||
|
# both pointing at archy-nvpn-entrypoint.sh — see that script for why the
|
||||||
|
# seed-config step has to run before either binary starts.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Shared entrypoint for both apps/nostr-vpn (daemon) and apps/nostr-vpn-web
|
||||||
|
# (control panel) -- they're the same image, differing only in the args
|
||||||
|
# this script execs into (see each manifest's container.entrypoint/custom_args).
|
||||||
|
#
|
||||||
|
# Seeds a minimal config.toml with our chosen listen_port BEFORE nvpn's own
|
||||||
|
# bootstrap (config_bootstrap.rs::load_or_default_config) ever runs, so the
|
||||||
|
# very first boot never has to self-heal off upstream's default 51820 --
|
||||||
|
# archy-x250 fleet nodes already run archipelago-wg on that port (found in
|
||||||
|
# Phase 0 testing, see docs/nostr-vpn-integration-plan.md). Every AppConfig
|
||||||
|
# field has #[serde(default = ...)], confirmed by reading
|
||||||
|
# crates/nostr-vpn-core/src/config/types.rs directly, so a partial TOML here
|
||||||
|
# merges cleanly with nvpn's own defaults (including the self-generated
|
||||||
|
# Nostr seller identity) instead of needing a full config.
|
||||||
|
#
|
||||||
|
# Never overwrites an existing config.toml: this volume may already hold a
|
||||||
|
# seller's identity, wallet, and pending Cashu credit adopted from the old
|
||||||
|
# root-mode install (/var/lib/archipelago/nostr-vpn) -- clobbering it would
|
||||||
|
# be a real funds-safety bug, not just a config reset.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
NVPN_LISTEN_PORT="${NVPN_LISTEN_PORT:-51822}"
|
||||||
|
CONFIG_DIR=/data/config/nvpn
|
||||||
|
CONFIG_PATH="$CONFIG_DIR/config.toml"
|
||||||
|
|
||||||
|
mkdir -p "$CONFIG_DIR" /data/home
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG_PATH" ]; then
|
||||||
|
cat > "$CONFIG_PATH" <<EOF
|
||||||
|
[node]
|
||||||
|
listen_port = ${NVPN_LISTEN_PORT}
|
||||||
|
EOF
|
||||||
|
chmod 600 "$CONFIG_PATH"
|
||||||
|
echo "nostr-vpn: seeded $CONFIG_PATH with listen_port=${NVPN_LISTEN_PORT} (first boot)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -35,6 +35,7 @@ As of the current `1.8-alpha` workstream:
|
|||||||
- Manifest-owned generated files exist through `app.files` and have been used for app config material (e.g. strfry, netbird config regeneration).
|
- Manifest-owned generated files exist through `app.files` and have been used for app config material (e.g. strfry, netbird config regeneration).
|
||||||
- Local image builds are represented with `container.build`; pulled images are represented with `container.image`.
|
- Local image builds are represented with `container.build`; pulled images are represented with `container.image`.
|
||||||
- Data ownership repair is represented with `container.data_uid`.
|
- Data ownership repair is represented with `container.data_uid`.
|
||||||
|
- Per-app network-namespace kernel parameters are represented with `app.sysctls`, allow-listed to packet forwarding (added for rootless VPN exits such as nostr-vpn).
|
||||||
- Derived host facts and secret-file-backed environment variables are represented with `container.derived_env` and `container.secret_env`.
|
- Derived host facts and secret-file-backed environment variables are represented with `container.derived_env` and `container.secret_env`.
|
||||||
- Catalog metadata generation is implemented by `scripts/generate-app-catalog.py`.
|
- Catalog metadata generation is implemented by `scripts/generate-app-catalog.py`.
|
||||||
- App-session launch ports/titles and new-tab launch behavior now have a generated TypeScript metadata path from manifests, with manual overrides preserved for companion UIs and aliases that do not have manifest-owned metadata yet.
|
- App-session launch ports/titles and new-tab launch behavior now have a generated TypeScript metadata path from manifests, with manual overrides preserved for companion UIs and aliases that do not have manifest-owned metadata yet.
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ app:
|
|||||||
| `app.environment` | Static `KEY=value` environment entries |
|
| `app.environment` | Static `KEY=value` environment entries |
|
||||||
| `app.health_check` | HTTP or TCP health check settings |
|
| `app.health_check` | HTTP or TCP health check settings |
|
||||||
| `app.devices` | Explicit device paths |
|
| `app.devices` | Explicit device paths |
|
||||||
|
| `app.sysctls` | Namespaced packet-forwarding sysctls for the app's own network namespace (allow-listed; not with host networking) |
|
||||||
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and [launch hints](#browser-iframe-and-companion-launch-modes) |
|
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and [launch hints](#browser-iframe-and-companion-launch-modes) |
|
||||||
| `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` |
|
| `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` |
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ because a wrong source produces a confident wrong verdict.
|
|||||||
| `environment` | list of string | — | `- KEY=value` pairs (static). |
|
| `environment` | list of string | — | `- KEY=value` pairs (static). |
|
||||||
| `health_check` | HealthCheck | — | `{ type, endpoint/path, interval, timeout, retries }`. `type` is free-form today; `http` is what the monitor exercises. |
|
| `health_check` | HealthCheck | — | `{ type, endpoint/path, interval, timeout, retries }`. `type` is free-form today; `http` is what the monitor exercises. |
|
||||||
| `devices` | list of string | — | Host device paths; must start with `/dev/`. |
|
| `devices` | list of string | — | Host device paths; must start with `/dev/`. |
|
||||||
|
| `sysctls` | map | — | Kernel parameters for the app's **own** network namespace (podman `--sysctl`, Quadlet `Sysctl=`). Allow-list: `net.ipv4.ip_forward`, `net.ipv6.conf.all.forwarding`; values `"0"`/`"1"`. Rejected under host networking. Needed by routing apps because rootless `/proc/sys` is read-only inside the container. |
|
||||||
| `interfaces` | map | — | Launch surfaces, keyed by name (`main`): `{ name, description, type, port, protocol, path }`. |
|
| `interfaces` | map | — | Launch surfaces, keyed by name (`main`): `{ name, description, type, port, protocol, path }`. |
|
||||||
| `hooks` | LifecycleHooks | — | Allow-listed lifecycle hooks. See [Hooks](#hooks). |
|
| `hooks` | LifecycleHooks | — | Allow-listed lifecycle hooks. See [Hooks](#hooks). |
|
||||||
| `upstream` | UpstreamSource | — | Where the app comes from, so release tooling can tell when the pin has fallen behind. See [Upstream tracking](#upstream-tracking). |
|
| `upstream` | UpstreamSource | — | Where the app comes from, so release tooling can tell when the pin has fallen behind. See [Upstream tracking](#upstream-tracking). |
|
||||||
@@ -116,6 +117,9 @@ Validation (enforced at `AppManifest::validate()`):
|
|||||||
FOWNER, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETGID, SETUID, SYS_ADMIN).
|
FOWNER, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETGID, SETUID, SYS_ADMIN).
|
||||||
- `network_policy` must be exactly `isolated`, `bridge`, or `host`.
|
- `network_policy` must be exactly `isolated`, `bridge`, or `host`.
|
||||||
- No `container:`/`ns:` network modes; devices must be `/dev/*`.
|
- No `container:`/`ns:` network modes; devices must be `/dev/*`.
|
||||||
|
- `sysctls` keys must be on `ALLOWED_SYSCTLS` (packet forwarding only) and
|
||||||
|
need the app's own network namespace — never host networking, where they
|
||||||
|
would change the host.
|
||||||
- Bind-mount sources are confined to `/var/lib/archipelago` (reviewed
|
- Bind-mount sources are confined to `/var/lib/archipelago` (reviewed
|
||||||
exceptions: the rootless podman socket and dbus).
|
exceptions: the rootless podman socket and dbus).
|
||||||
- `derived_env` templates may only use the placeholder allow-list;
|
- `derived_env` templates may only use the placeholder allow-list;
|
||||||
|
|||||||
+18
-141
@@ -1,15 +1,11 @@
|
|||||||
# Next OTA and raw ISO after 1.8.21
|
# Next OTA and raw ISO after 1.8.21
|
||||||
|
|
||||||
**Status: COMPLETE — 1.8.22-alpha OTA, compatible signed app catalog and raw ISO published on Git and ngit on 2026-10-01; artifact signatures, public downloads and fleet feed verified. Angor full-chain indexing still awaits dev Bitcoin synchronization.**
|
**Status: implementation and acceptance in progress; NOT ready to release.**
|
||||||
|
|
||||||
Current acceptance evidence: [1.8.22 release acceptance](release-1.8.22-acceptance.md).
|
|
||||||
The chronological notes below retain earlier failures and superseded candidates;
|
|
||||||
the final tested source is `6d5f3ffb`.
|
|
||||||
|
|
||||||
This is the consolidated execution checklist for the operator's chat requests.
|
This is the consolidated execution checklist for the operator's chat requests.
|
||||||
Release acceptance and publication are complete, with live wallet and app data
|
A targeted node repair is not completion of the release. Finish the remaining
|
||||||
preservation checks documented below. No universal absence of future failures
|
acceptance gates, preserve live wallets and app data, and publish both artifacts
|
||||||
is claimed.
|
through git and ngit. No universal absence of future failures is claimed.
|
||||||
|
|
||||||
## Changes already shipped in 1.8.21 or earlier
|
## Changes already shipped in 1.8.21 or earlier
|
||||||
|
|
||||||
@@ -35,12 +31,12 @@ See the Framework incident and 1.8.21 execution records for evidence/limits.
|
|||||||
|
|
||||||
| Task | Implemented/verified | Remaining before release |
|
| Task | Implemented/verified | Remaining before release |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| X250 Bitcoin picker | Inline choices; actual Chromium kiosk selection, readability and pruning layout passed | Final UI/build checks passed |
|
| X250 Bitcoin picker | Inline choices; actual Chromium kiosk selection, readability and pruning layout passed | Include in final UI/build checks |
|
||||||
| App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final candidate lifecycle, hard-refresh and stability checks passed |
|
| App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final lifecycle/reboot gate on candidate |
|
||||||
| X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | OTA and ISO build-context/content checks passed |
|
| X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | Verify both artifact payloads contain all build contexts |
|
||||||
| PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Funded Tor-only candidate purchase, retained change, refund, Files bytes and cached repeat passed; included in signed artifacts |
|
| PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Funded Tor-only candidate purchase, retained change, refund, Files bytes and cached repeat passed; include in signed artifacts |
|
||||||
| Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore, failed-start recovery and reverse installation order passed. Operator confirms production site works through Portainer; production host reboot also preserved network/Git/Compose access; final candidate delivery and integration checks passed |
|
| Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore, failed-start recovery and reverse installation order passed. Operator confirms production site works through Portainer; production host reboot also preserved network/Git/Compose access; final candidate delivery and release checks remain |
|
||||||
| Angor headless store service | Implemented standard Mempool adapter and separate optional relay, official logo, headless store entries and declarative dependency guard. API security/outage/DNS tests and five relay lifecycle cycles passed | Final candidate prerequisite/API, lifecycle and catalog checks passed; real indexing on dev waits for Bitcoin sync |
|
| Angor headless store service | Implemented standard Mempool adapter and separate optional relay, official logo, headless store entries and declarative dependency guard. API security/outage/DNS tests and five relay lifecycle cycles passed | Final candidate prerequisite/install acceptance, management restart/reboot checks and signed catalog delivery; real indexing on dev waits for Bitcoin sync |
|
||||||
|
|
||||||
Durable payment receipts after a lost seller response remain a separately
|
Durable payment receipts after a lost seller response remain a separately
|
||||||
recorded design follow-up. Preserve the truthful unconfirmed-refund warning and
|
recorded design follow-up. Preserve the truthful unconfirmed-refund warning and
|
||||||
@@ -49,23 +45,21 @@ completed. See PR review for the accepted scope and coverage limits.
|
|||||||
|
|
||||||
## Final release checklist
|
## Final release checklist
|
||||||
|
|
||||||
- [x] Finish new-scope implementation and release acceptance; full-chain Angor
|
- [ ] Finish all new-scope implementation and specific acceptance above.
|
||||||
indexing still depends on the dev node finishing initial sync.
|
|
||||||
- [x] Remove disposable fixtures and temporary test overrides; verify native
|
- [x] Remove disposable fixtures and temporary test overrides; verify native
|
||||||
Bitcoin/LND identity and start-state baselines remain protected.
|
Bitcoin/LND identity and start-state baselines remain protected.
|
||||||
- [x] Commit and push completed source changes to git and ngit.
|
- [x] Commit and push completed source changes to git and ngit.
|
||||||
- [x] Run final backend/UI/regression/release gates on the final source; inspect
|
- [ ] Run final backend/UI/regression/release gates on the final source; inspect
|
||||||
skipped tests and report actual hardware/runtime coverage.
|
skipped tests and report actual hardware/runtime coverage.
|
||||||
- [x] Prepare compatible signed app catalog; old runtimes must not apply a
|
- [ ] Prepare compatible signed app catalog; old runtimes must not apply a
|
||||||
migration before they have backup/recovery support.
|
migration before they have backup/recovery support.
|
||||||
- [x] Version/changelog and OTA payload prepared, validated and signed by user.
|
- [ ] Version/changelog and OTA payload prepared, validated and signed by user.
|
||||||
- [x] Raw ISO built; payload hashes/content verified; full installation and
|
- [ ] Raw ISO built; payload hashes/content verified; installer boot tested.
|
||||||
installed-system boot tested in QEMU/KVM without network.
|
- [ ] User signs ISO checksums; publish OTA and ISO plus verification files on
|
||||||
- [x] User signs ISO checksums; publish OTA and ISO plus verification files on
|
|
||||||
git and ngit; independently read back hashes and update discovery.
|
git and ngit; independently read back hashes and update discovery.
|
||||||
- [x] Provide LAN scp command for the new raw ISO.
|
- [ ] Provide LAN scp command for the new raw ISO.
|
||||||
|
|
||||||
Latest backend source verification: 1,617 passed, zero failed, four existing
|
Latest backend source verification: 1,609 passed, zero failed, four existing
|
||||||
ignored tests. This is one layer of evidence, not a substitute for live gates.
|
ignored tests. This is one layer of evidence, not a substitute for live gates.
|
||||||
|
|
||||||
## Angor verification — 2026-09-30
|
## Angor verification — 2026-09-30
|
||||||
@@ -340,120 +334,3 @@ repository branch and Compose content from its own network namespace.
|
|||||||
Version preparation is 1.8.22-alpha. No new release tag or fleet-visible update
|
Version preparation is 1.8.22-alpha. No new release tag or fleet-visible update
|
||||||
manifest is published by the version commit. Optimized candidate deployment,
|
manifest is published by the version commit. Optimized candidate deployment,
|
||||||
artifact inspection, ISO smoke/boot checks and offline signatures follow.
|
artifact inspection, ISO smoke/boot checks and offline signatures follow.
|
||||||
|
|
||||||
### Release blocker discovered during candidate observation: scheduled doctor
|
|
||||||
|
|
||||||
The initial `02b840f2` 1.8.22 candidate is rejected for release. On the X250,
|
|
||||||
2026-09-30 21:10–21:11 UTC, the scheduled `archipelago-doctor.service` explicitly
|
|
||||||
ran `podman stop --all --time 30`, killed rootless network helpers and ran
|
|
||||||
`podman system migrate` after a two-attempt external network probe failed.
|
|
||||||
The journal attributes the stop to that unit, not the app health monitor or a
|
|
||||||
host reboot. All apps restarted, including Bitcoin, LND and the production site.
|
|
||||||
The earlier unchanged-container acceptance applies only to immediate deployment;
|
|
||||||
the later observation failed and must not be represented as a stability pass.
|
|
||||||
No persistent-data loss has been established. Keep this distinct from the closed
|
|
||||||
Framework incident; do not wipe or recreate any wallet as a recovery action.
|
|
||||||
|
|
||||||
Containment: stopped doctor timers on both test boxes, installed a safe diagnostic
|
|
||||||
script into both the executable and runtime payload, and rejected/stopped the
|
|
||||||
old ISO build. Network failure now produces a warning without stopping apps,
|
|
||||||
killing network processes, migrating Podman or deleting network state. Repeated
|
|
||||||
failures remain warnings, never a successful repair/check. Regression cases cover
|
|
||||||
healthy, absent network, non-root invocation, host failure, transient recovery,
|
|
||||||
repeated endpoint failure and namespace access failure, with mutation tripwires.
|
|
||||||
Live scheduled-cycle observation and final rebuilt-artifact acceptance are pending.
|
|
||||||
|
|
||||||
Recovery also exposed retired `git.tx1138.com` nginx base references in six
|
|
||||||
companion UI Dockerfiles. They now use the existing primary registry at the same
|
|
||||||
pinned version. All six images built successfully against that registry; payload
|
|
||||||
validation rejects the retired host before OTA/ISO packaging.
|
|
||||||
|
|
||||||
The post-recovery X250 check passes: Bitcoin authenticated RPC responds and IBD
|
|
||||||
advances; NPM/Gitea/Portainer APIs respond; Portainer's real namespace fetches
|
|
||||||
`demo-portainer` at `3ae171d6b0c728665a860520fe393c0abb772798` and its Compose
|
|
||||||
file; Portainer's original persistent mounts match the earlier backup evidence;
|
|
||||||
LND wallet/channel databases remain present on their persistent mount. No new
|
|
||||||
pre-incident cryptographic wallet-identity baseline was available, so these checks
|
|
||||||
must not be described as an exact identity/balance comparison.
|
|
||||||
|
|
||||||
The dev all-container observation also caught a separate Cuprate UI orphan loop:
|
|
||||||
`companion.rs` removed it because Cuprate was not installed, while generic desired-
|
|
||||||
state recovery resurrected it from an old running snapshot, using a unit without
|
|
||||||
nginx's required capabilities. Generic desired-state recovery now excludes missing companions
|
|
||||||
owned by `companion.rs`; existing companion provisioning/reaping remains the
|
|
||||||
single owner. Running UIs still receive the existing security configuration repairs. Regression runs repeated reconciliation against stale companion
|
|
||||||
snapshots and checks that no image/container lifecycle operations occur.
|
|
||||||
|
|
||||||
Safe-doctor live acceptance: the X250 completed a 12-minute observation with all
|
|
||||||
running container IDs, start times and data mounts unchanged. Its journal records
|
|
||||||
successful doctor runs at 21:22:06, 21:27:51 and 21:33:10 UTC. Both doctor timers
|
|
||||||
are restored with the safe script. Dev's native Bitcoin/LND stayed running;
|
|
||||||
all-container dev acceptance remains pending the companion-loop backend fix.
|
|
||||||
Final-source UI suite: 1,133 passed. Heavy backend compilation is serialized with
|
|
||||||
remaining build steps to reduce memory/IO pressure on the syncing dev node.
|
|
||||||
|
|
||||||
Final source release gates at `96fb5a4f`: 1,613 backend tests passed, zero failed,
|
|
||||||
four explicitly ignored; 1,133 UI tests passed; type-check, production UI build,
|
|
||||||
catalog/trust, shell, pruning, LND readiness, NPM migration and doctor regressions
|
|
||||||
passed. The isolated companion-loop regression passed independently as well.
|
|
||||||
|
|
||||||
ISO cache hardening: the installer now carries the current doctor script and
|
|
||||||
service/timer separately from rootfs.tar and overwrites both historical and active
|
|
||||||
script locations before first boot. This prevents a cached base image restoring
|
|
||||||
the old recovery code. A regression executes the actual installer block against
|
|
||||||
stale disposable files twice and confirms a missing safety payload fails closed.
|
|
||||||
The mounted-ISO smoke test also compares all three overlay files to source.
|
|
||||||
The final ISO build captures the exact newly deployed OTA UI/runtime payload.
|
|
||||||
|
|
||||||
### Final kiosk acceptance found nondeterministic NPM launch selection
|
|
||||||
|
|
||||||
Do not publish the staged `d1bc1273` candidate. NPM itself remains healthy and its
|
|
||||||
API, Portainer integration, site, and native services passed stability checks.
|
|
||||||
However, final kiosk acceptance found its card stuck at "Web UI not ready".
|
|
||||||
The runtime reported bindings in proxy-HTTP, proxy-HTTPS, admin order. The scanner
|
|
||||||
chose the first non-database/SSH binding, then rejected its tunnel-only host port
|
|
||||||
as unreachable on loopback, leaving the launch address empty. Earlier tests had
|
|
||||||
passed with admin first. This is a confirmed order-dependent scanner defect.
|
|
||||||
|
|
||||||
The candidate fix explicitly resolves NPM container port 81 to its actual host
|
|
||||||
allocation. Proxy ports never become the admin URL. Missing/malformed admin
|
|
||||||
bindings do not fall back to another service when published bindings are present.
|
|
||||||
Port parsing handles IPv6 authorities and rejects invalid ports. Regressions
|
|
||||||
cover all six three-port permutations, allocated admin ports, IPv4/IPv6 binding
|
|
||||||
strings, missing admin mappings, missing runtime port information, and malformed
|
|
||||||
or UDP bindings. Full backend regression execution is pending for this change.
|
|
||||||
The ISO build is frozen at installer-environment creation; no release was signed
|
|
||||||
or published. Rebuild/revalidate the OTA and ISO with this correction.
|
|
||||||
|
|
||||||
The companion orphan fix worked live: Cuprate UI was automatically removed and
|
|
||||||
all installed app container IDs remained unchanged. One observation helper raced
|
|
||||||
that expected removal between `podman ps` and `inspect`; it now excludes that
|
|
||||||
known orphan before inspection and repeats the stability check. This was a test
|
|
||||||
snapshot race, not another installed-app restart.
|
|
||||||
|
|
||||||
NPM selector final backend gate passed: 1,617 tests, zero failures, four explicitly
|
|
||||||
ignored, through the isolated runner. This includes all new port-selection cases
|
|
||||||
and the existing companion security/configuration and lifecycle regressions.
|
|
||||||
Rebuild the release binary and UI metadata, deploy those exact OTA bytes to both
|
|
||||||
boxes, and require actual kiosk hard-refresh/Launch acceptance before ISO assembly.
|
|
||||||
|
|
||||||
|
|
||||||
## Final accepted artifacts — 1.8.22-alpha
|
|
||||||
|
|
||||||
Source `6d5f3ffb` passed 1,617 backend tests (four explicit opt-in exclusions),
|
|
||||||
1,133 frontend tests and final release gates. Exact OTA bytes were deployed to
|
|
||||||
both boxes. Actual X250 kiosk NPM Launch, version/pruning, desktop/mobile
|
|
||||||
readiness/AIUI, production Portainer Git/Compose and 12-minute stability checks
|
|
||||||
on both boxes passed. No installed app was restarted by the safe diagnostics,
|
|
||||||
and the Cuprate orphan stayed absent. Native Bitcoin/LND and the production site
|
|
||||||
were preserved during final management deployment.
|
|
||||||
|
|
||||||
The raw ISO passed mounted payload checks and matches all 653 OTA frontend/runtime
|
|
||||||
files plus the backend. Full offline installation and installed UEFI boot to the
|
|
||||||
visible setup screen passed in a disposable QEMU/KVM VM. Both installed doctor
|
|
||||||
paths and the installed backend have the expected hashes. No VM wallet was set up.
|
|
||||||
|
|
||||||
See `release-1.8.22-acceptance.md` for exact artifact hashes, hardware/runtime
|
|
||||||
coverage and limits. Draft upload verification, offline signatures, publication
|
|
||||||
and public readback remain; the fleet still advertises 1.8.21 until those gates
|
|
||||||
finish. Do not confuse a draft asset or source push with completed publication.
|
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
# Post-1.8.22 regressions and retained release checklist
|
|
||||||
|
|
||||||
Status: OPEN. New regressions reported after publication on 2026-10-01.
|
|
||||||
Do not mark complete from source changes alone. Preserve wallets, app state and
|
|
||||||
operator uninstall decisions. Never send a second payment to recover delivery.
|
|
||||||
The earlier Framework startup incident remains separately closed with operator
|
|
||||||
acceptance; this is a new paid-file incident.
|
|
||||||
|
|
||||||
## Current tasks
|
|
||||||
|
|
||||||
- [ ] Recover the Framework's Lightning paid-file purchase without another payment;
|
|
||||||
inspect buyer/seller evidence and verify delivered bytes.
|
|
||||||
- [ ] Correct seller settlement verification when local-node payment skips polling.
|
|
||||||
- [ ] Durable seller entitlements and safe buyer retry after navigation/restart;
|
|
||||||
do not issue another payment on an uncertain or successful attempt.
|
|
||||||
- [ ] Cache Lightning purchases, preserve ownership, optional Files copy, free repeat.
|
|
||||||
- [ ] Diagnose mobile companion uploads on the affected route/device.
|
|
||||||
- [ ] Real progress in the existing compact upload bar; no increased height.
|
|
||||||
- [ ] Preserve uploads/progress across screens and original batch destination.
|
|
||||||
- [ ] Cancel active transfer and queued files; truthful partial/error/server-save status.
|
|
||||||
- [ ] Transparent transaction-filter container; single horizontal scrolling mobile row.
|
|
||||||
- [ ] Immich displayed as one app, internal components hidden; diagnose restarting services.
|
|
||||||
- [ ] Diagnose unwanted CryptPad after upgrade, failed uninstall, and persistent removal.
|
|
||||||
- [ ] Identify the other removed unexpected service from affected-node records.
|
|
||||||
- [ ] Upgrade regression matrix: installed/stopped/restarting/removed/legacy apps,
|
|
||||||
aliases, dependencies, inventory, desired-state reconciliation and data preservation.
|
|
||||||
|
|
||||||
- [ ] Portainer duplicate-network migration: retire the redundant managed repair
|
|
||||||
override, preserve operator settings/state, verify generated command,
|
|
||||||
actual request namespace, dashboard readiness and repeated reconciliation.
|
|
||||||
|
|
||||||
- [ ] Lightning cooperative-close fees: Standard/Medium/Fast/Custom selection,
|
|
||||||
explicit default target, strict backend validation and forwarding, error
|
|
||||||
handling, mobile layout and no real channel closure during tests.
|
|
||||||
|
|
||||||
- [x] Apps search clear control: My Apps, Services and App Store, desktop/mobile,
|
|
||||||
existing design tokens, right-aligned icon, no size change, keyboard focus.
|
|
||||||
|
|
||||||
## Retained release work (previous acceptance is not new-regression acceptance)
|
|
||||||
|
|
||||||
- Mempool patched image/catalog version agreement, update-button clearing, one card.
|
|
||||||
- Minibits PR160, Lightning address availability, concise single-column backup copy.
|
|
||||||
- Framework LND startup/Receive and unknown-vs-zero balance behavior.
|
|
||||||
- Friendly Bitcoin warmup; LND waiting for install/sync; Bitcoin UI during IBD;
|
|
||||||
headless Phoenixd without self-waiting or bogus launch action.
|
|
||||||
- Cashu same-mint paid files, exact amounts/change/refund, errors, stored bytes,
|
|
||||||
Files copy and repeat access without re-payment.
|
|
||||||
- mempool.space public explorer fallback, preserving local/custom configuration.
|
|
||||||
- Optional install pruning and consistent automatic-pruning policy.
|
|
||||||
- X250 kiosk version picker layering/contrast and pruning layout.
|
|
||||||
- AIUI single desktop/mobile background, transparent embedded layers,
|
|
||||||
preserved standalone wallpaper.
|
|
||||||
- PR review/fixes/tests and normal merge/closure (160 previously shipped;
|
|
||||||
161/162 merged and included in 1.8.22).
|
|
||||||
- Installed inventory retained during app restart/hard-refresh.
|
|
||||||
- Correct iframe/browser launch readiness, useful errors and delayed startup.
|
|
||||||
- GitWorkshop payload/build contexts, progress and persistence after refresh.
|
|
||||||
- Gitea/Portainer same-server Git from actual request namespace; URLs, auth,
|
|
||||||
fresh installation in either order, migration/rollback, restart/reboot,
|
|
||||||
Git/SSH/LFS/registry/browser compatibility and data/stack preservation.
|
|
||||||
- NPM correct admin port/URL, malformed URL behavior, bind-aware readiness,
|
|
||||||
persistent backed-up tunnel/LND port-conflict repair on OTA and ISO.
|
|
||||||
- Angor headless indexer on DEV BOX only, full unpruned Bitcoin/Mempool/ElectrumX
|
|
||||||
prerequisites, optional separate relay, official icon with green white areas.
|
|
||||||
- Compact named readiness messages and bottom-aligned app-card actions.
|
|
||||||
- Remove unused integration/build fixtures from Apps/Services, preserve app data.
|
|
||||||
- Safe network doctor, no all-app stop/reset on failed egress probe.
|
|
||||||
- No orphan companion resurrection; retain existing companion security repairs.
|
|
||||||
- Current companion image registry, build contexts, runtime asset promotion order,
|
|
||||||
generated-service argument quoting and graceful Bitcoin/LND shutdown.
|
|
||||||
- OTA + RAW ISO, root signatures/catalog compatibility/checksums, independently
|
|
||||||
verified public files, Git/ngit source/releases and fleet discovery.
|
|
||||||
- Correct LAN SCP command for the new ISO.
|
|
||||||
|
|
||||||
## Explicit boundaries/follow-ups
|
|
||||||
|
|
||||||
- Full-chain Angor indexing awaits development Bitcoin IBD.
|
|
||||||
- Primal automatic comment exceeding Minibits metadata limit: previously accepted
|
|
||||||
upstream limitation, no unsupported local identity/metadata rewrite.
|
|
||||||
- Lost-response ecash seller receipt redesign is a separately accepted follow-up;
|
|
||||||
do not claim an uncertain refund completed or automatically pay twice.
|
|
||||||
- Optional external-provider/hardware tests must be labelled if not exercised.
|
|
||||||
|
|
||||||
## Initial source evidence
|
|
||||||
|
|
||||||
`PeerFiles.vue::payWithLightning` immediately downloaded after buyer payment,
|
|
||||||
while only seller `handle_content_invoice_status` marked a pending invoice paid.
|
|
||||||
Seller download checked only that cached flag. This matches the reported error
|
|
||||||
and is supported by source inspection. An earlier diagnostic's HTTP 404 is not
|
|
||||||
valid confirmation: it incorrectly base64-decoded lncli's already-hex payment
|
|
||||||
hash. The corrected live diagnostic recognizes the settled invoice on the
|
|
||||||
candidate; do not cite the earlier 404 as proof of the original failure sequence.
|
|
||||||
`content_invoice.rs` stored all entitlements only in process memory with a
|
|
||||||
one-hour TTL, losing both pending and paid access on restart/expiry.
|
|
||||||
Lightning download returned transient base64 without the Cashu ownership cache.
|
|
||||||
CloudFolder's view-local spinner had no byte progress/cancel; batch upload read
|
|
||||||
`currentPath` independently for each file, allowing navigation to move destinations.
|
|
||||||
Immich's underscore dependencies are scanner-excluded; hyphen manifest IDs are
|
|
||||||
not. Live inventory confirmed both hyphenated synthetic entries while the
|
|
||||||
actual underscore-named containers had remained running for nine days.
|
|
||||||
|
|
||||||
## Access / acceptance
|
|
||||||
|
|
||||||
Operator provided updated Framework SSH authentication privately in chat.
|
|
||||||
Do not put credentials or deployment addresses in this public document.
|
|
||||||
Framework was reached over SSH. Native Bitcoin, LND and all three Immich
|
|
||||||
container identities/start times were recorded before candidate deployment.
|
|
||||||
The kiosk is at its login page. Dashboard password authentication succeeds but
|
|
||||||
requires the operator's second factor; normal uninstall acceptance remains pending.
|
|
||||||
|
|
||||||
Confirmed live evidence:
|
|
||||||
|
|
||||||
- A 10,000-sat peer-file invoice settled at 12:19:29 UTC. The original status
|
|
||||||
diagnostic used an incorrectly decoded hash; see the correction above. Buyer
|
|
||||||
identity and confirmation that this is the reported sale remain pending.
|
|
||||||
- The matching item currently allows free access; preserve that operator setting.
|
|
||||||
- CryptPad has no container but remains in installed-apps metadata. Uninstall
|
|
||||||
repeatedly aborts because the removed catalog ID has no manifest.
|
|
||||||
- Immich server/database/cache are running; synthetic hyphenated dependencies
|
|
||||||
appear stopped and the recovery overlay briefly advertises restarting.
|
|
||||||
- The other removed service was Core Lightning; uninstall tombstones exist.
|
|
||||||
- No Android resource-upload POST appears in the inspected recent nginx log.
|
|
||||||
This does not establish why the affected companion failed.
|
|
||||||
|
|
||||||
## Candidate implementation and validation
|
|
||||||
|
|
||||||
Source changes persist seller entitlements with atomic writes, verify settlement
|
|
||||||
at delivery, recover older Lightning entitlements from the seller's LND invoice,
|
|
||||||
perform the status handshake for older sellers, and cache delivered Lightning
|
|
||||||
files. Buyer purchase bytes and the shared ownership index now use atomic,
|
|
||||||
synced writes and a serialized read/modify/write transaction; a corrupt index
|
|
||||||
fails the write instead of silently replacing existing ownership. The browser saves the invoice before payment and retries delivery without
|
|
||||||
another payment. Browser receipts are not yet a node-wide recovery store.
|
|
||||||
|
|
||||||
The upload queue now belongs to the shared Cloud store, captures its original
|
|
||||||
folder, reports actual sent bytes and server completion, and cancels its active
|
|
||||||
XHR and remaining queue. The fixed-height bar remains available across routes.
|
|
||||||
Transaction filters use a transparent container and one scrollable row. Immich
|
|
||||||
aliases normalize to their real component names and internal cards are hidden.
|
|
||||||
Unknown catalog entries no longer prevent the regular uninstall flow.
|
|
||||||
|
|
||||||
Validation so far (additional acceptance still pending):
|
|
||||||
|
|
||||||
- Final isolated backend suite: **1,631 passed**, zero failed, four optional
|
|
||||||
tests ignored. This includes invoice settlement/amount boundaries, durable
|
|
||||||
seller records, concurrent buyer ownership, damaged-index preservation,
|
|
||||||
Portainer override retirement/idempotence/customization/backup failures,
|
|
||||||
recovery overlays and channel-close fee forwarding/validation.
|
|
||||||
- Final frontend suite: **1,157 passed** across 142 files. Production build
|
|
||||||
passed. Six payment-recovery and twelve channel-close tests are included.
|
|
||||||
- Real FileBrowser uploads at 1440px and 390px: exact bytes and original folder
|
|
||||||
verified after navigation, 44px bar, cancellation and queue stop passed.
|
|
||||||
- Mobile viewport acceptance is not physical Android companion acceptance.
|
|
||||||
- Final release backend build passed. Candidate backend and dashboard are now
|
|
||||||
deployed on dev and Framework. Another OTA/ISO remains pending; published
|
|
||||||
1.8.22 artifacts remain unchanged.
|
|
||||||
|
|
||||||
Release gates still include actual-node payment recovery/delivery, durable
|
|
||||||
CryptPad removal through normal controls, Immich inventory after refresh/restart,
|
|
||||||
physical companion diagnosis, and remaining upgrade regression acceptance.
|
|
||||||
No new payments, native-service restarts or wallet changes were used in testing.
|
|
||||||
|
|
||||||
## Additional live Portainer regression
|
|
||||||
|
|
||||||
The X250 user service exited 125 because the generated command supplied
|
|
||||||
`--network slirp4netns` twice. The manifest already supplies the network, while
|
|
||||||
an older Archipelago-created `archy-same-node-network.conf` drop-in adds it
|
|
||||||
again. Quadlet's Network directives accumulate; they do not override each other.
|
|
||||||
This repair artifact should have been retired when the declarative fix shipped.
|
|
||||||
|
|
||||||
The live repair backed up the override and Portainer state, removed only the
|
|
||||||
exact redundant override, reloaded user systemd and restarted Portainer. API
|
|
||||||
status returned HTTP 200 with version 2.45.0; the actual kiosk's package state
|
|
||||||
reported running and UI-ready. Bitcoin/LND and the production site's container
|
|
||||||
identities/start times remained unchanged. The source migration now detects
|
|
||||||
this exact managed override before preparing the persistent restart obligation,
|
|
||||||
backs up app state, retires the redundant file with a retained copy, and reloads
|
|
||||||
and restarts through normal reconciliation. Custom overrides are preserved.
|
|
||||||
Automated migration coverage passed; candidate is now deployed on dev and
|
|
||||||
Framework. The X250 retains its verified live repair pending the next OTA.
|
|
||||||
|
|
||||||
## Channel-close fee selection
|
|
||||||
|
|
||||||
The existing close UI sent only the channel point, and the backend forwarded
|
|
||||||
only `force=false`. LND therefore used its lax default confirmation target.
|
|
||||||
The candidate reuses the channel-opening fee choices (six/three/one block target,
|
|
||||||
or custom target/rate), explicitly sends six blocks for legacy clients that omit
|
|
||||||
fees, and validates query parameters before accessing the wallet. Cooperative
|
|
||||||
fees are never silently applied to force closes. Close RPC retries are disabled
|
|
||||||
so a timeout cannot silently repeat this mutation.
|
|
||||||
|
|
||||||
Protocol reference: [LND CloseChannel](https://lightning.engineering/api-docs/api/lnd/lightning/close-channel/).
|
|
||||||
Fee targets are estimates, not guaranteed confirmation times. Tests use mocked
|
|
||||||
requests; no production channel is closed to verify the feature.
|
|
||||||
|
|
||||||
Additional browser acceptance:
|
|
||||||
|
|
||||||
- Transaction filters at 390px: computed transparent background, one row and
|
|
||||||
horizontal overflow verified.
|
|
||||||
- Close-channel selector at 1440px and 390px: preset/custom controls visible,
|
|
||||||
no overflow, custom 25 sat/vB forwarded. The close request was intercepted;
|
|
||||||
no real channel closure or wallet mutation occurred.
|
|
||||||
- All three Apps search screens at both widths: clear icon stays inside the
|
|
||||||
field; click/Escape clear; input retains focus; desktop 40px/mobile 52px heights
|
|
||||||
stay unchanged. Shared design-system search-field classes are retained.
|
|
||||||
- Portainer remained active with zero service restarts and no pending marker.
|
|
||||||
Its real network namespace read smart HTTP Git refs and the Compose file.
|
|
||||||
Original persistent mounts were unchanged. The old integration test containers
|
|
||||||
are absent from dev, Framework and X250. One leftover upload-test folder was
|
|
||||||
removed after checking it contained only this task's test files.
|
|
||||||
|
|
||||||
## Build resource observation
|
|
||||||
|
|
||||||
The final optimized compile coincided with heavy memory/disk pressure and local
|
|
||||||
Bitcoin/LND RPC timeouts on the development node. After pausing the compiler,
|
|
||||||
both authenticated RPCs responded again; Bitcoin reported height 506400 and
|
|
||||||
19.6% verification progress, with LND waiting for chain sync. No native service
|
|
||||||
was restarted. Compilation resumed in a separate user scope limited to one CPU,
|
|
||||||
with nice 19 and idle I/O priority. This is evidence of resource contention,
|
|
||||||
not proof of a new wallet or startup defect. Verify native RPC health again
|
|
||||||
before candidate deployment.
|
|
||||||
|
|
||||||
## Candidate deployment and live acceptance — 2026-10-01
|
|
||||||
|
|
||||||
Source: `f4d34554` (later commits update this checklist only).
|
|
||||||
Backend SHA-256:
|
|
||||||
`8fb6249d1869bb8c9aea26d0f846de5b3eec328113a5c7f573628306e26e652e`.
|
|
||||||
|
|
||||||
- Backed up backend, dashboard and app metadata on both nodes under the root-only
|
|
||||||
support directory `post1822-regressions-20261001`. Deployed assets before
|
|
||||||
promoting the dashboard entry point; manager health passed first.
|
|
||||||
- Only the Archipelago manager restarted. Bitcoin/LND IDs and start times stayed
|
|
||||||
unchanged; Framework's three Immich containers also stayed unchanged.
|
|
||||||
- Dev authenticated Bitcoin/LND RPCs responded after deployment. Bitcoin IBD
|
|
||||||
continued above height 513000; LND correctly reported not yet chain-synced.
|
|
||||||
- Both nodes serve dashboard entry bytes identical to the production build.
|
|
||||||
All six search-clear cases passed against the live dev dashboard (three
|
|
||||||
screens, desktop/mobile), including focus, Escape and unchanged field size.
|
|
||||||
- Framework's stale CryptPad installed claim was removed while the manager was
|
|
||||||
stopped; both legacy IDs were recorded as user-uninstalled. Data was preserved.
|
|
||||||
Removal remained after another management restart. Dashboard uninstall-flow
|
|
||||||
acceptance still awaits authentication; this repair was performed over SSH.
|
|
||||||
- Corrected invoice diagnostic recovered the settled seller entitlement, returned
|
|
||||||
HTTP 200 with `paid: true`, and saved a mode-0600 record. A different item was
|
|
||||||
rejected. Paid status survived another manager restart without native restarts
|
|
||||||
or a second payment.
|
|
||||||
- Delivery acceptance remains OPEN: the catalog's file is absent from both its
|
|
||||||
dedicated content path and FileBrowser path; a privileged filename search of
|
|
||||||
those trees found no copy. Its free-access setting was preserved. Buyer and
|
|
||||||
reported-purchase identity still need confirmation; do not claim the actual
|
|
||||||
buyer received the file.
|
|
||||||
- The malformed-hash diagnostic also exposed that invoice-status currently
|
|
||||||
propagates validation errors as a closed connection. Before release, return a
|
|
||||||
structured 400 for malformed hashes and an explicit retryable response for
|
|
||||||
settlement-service errors, with endpoint coverage.
|
|
||||||
|
|
||||||
Physical companion upload diagnosis and remaining release acceptance stay OPEN.
|
|
||||||
This is a candidate deployment, not a newly signed OTA or ISO.
|
|
||||||
@@ -43,6 +43,8 @@ PublishPort=<bind>:<host>:<container>/<proto>
|
|||||||
Environment=<KEY>=<value> # non-secret env only
|
Environment=<KEY>=<value> # non-secret env only
|
||||||
Secret=<secret_name>,type=env,target=<KEY> # secrets by REFERENCE, never value
|
Secret=<secret_name>,type=env,target=<KEY> # secrets by REFERENCE, never value
|
||||||
Volume=<source>:<target><opts>
|
Volume=<source>:<target><opts>
|
||||||
|
AddDevice=<path> # manifest devices
|
||||||
|
Sysctl=<key>=<value> # manifest sysctls (own netns, allow-listed)
|
||||||
ReadOnly=true # when security.readonly_root
|
ReadOnly=true # when security.readonly_root
|
||||||
NoNewPrivileges=true # when security.no_new_privileges
|
NoNewPrivileges=true # when security.no_new_privileges
|
||||||
HealthCmd=<cmd> # from the health_check block
|
HealthCmd=<cmd> # from the health_check block
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
# Archipelago 1.8.22-alpha acceptance
|
|
||||||
|
|
||||||
Source: `6d5f3ffb850bfd3dcd396bac986ba770935d1daa`.
|
|
||||||
|
|
||||||
## Verified application and runtime changes
|
|
||||||
|
|
||||||
- Full isolated backend suite: 1,617 passed, zero failed, four explicit opt-in exclusions.
|
|
||||||
- Frontend suite: 1,133 passed. Final frontend and AIUI production builds succeeded.
|
|
||||||
- Container suite: 79 passed. Catalog compatibility/trust, release manifest, build contexts, pruning, Lightning readiness, NPM migration, safe doctor, companion recovery and ISO doctor-overlay regressions passed.
|
|
||||||
- Six companion dashboard images built using the current registry.
|
|
||||||
- Final OTA backend SHA-256: `e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b`.
|
|
||||||
- Final OTA frontend SHA-256: `2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41`.
|
|
||||||
|
|
||||||
## Live acceptance
|
|
||||||
|
|
||||||
The exact OTA bytes were deployed to the development box and ThinkPad X250.
|
|
||||||
Native Bitcoin/LND and the X250 production site retained their container identity
|
|
||||||
and start time during these final management deployments. Both boxes completed
|
|
||||||
12-minute observations including scheduled diagnostics with running containers
|
|
||||||
and persistent mounts unchanged. The orphaned Cuprate dashboard stayed absent.
|
|
||||||
|
|
||||||
Actual X250 Chromium kiosk: hard refresh, NPM Launch to the correct admin URL,
|
|
||||||
visible login/admin page, readable inline Bitcoin version choices and pruning
|
|
||||||
checkbox passed. No Bitcoin installation was triggered by this test.
|
|
||||||
|
|
||||||
Final desktop/mobile checks passed for Bitcoin's IBD dashboard, one Mempool card,
|
|
||||||
headless Phoenixd, LND waiting/unknown-balance behavior and all five transparent
|
|
||||||
AIUI embedding layers. Standalone AIUI retains its wallpaper.
|
|
||||||
|
|
||||||
Portainer's actual production network namespace fetched Git refs and the Compose
|
|
||||||
file after final deployment. Original mounts were preserved. Earlier disposable
|
|
||||||
fresh/reverse-install and migration/rollback tests, and the production host's
|
|
||||||
operator-initiated reboot check, passed.
|
|
||||||
|
|
||||||
Live Tor-only Cashu paid-file acceptance verified a one-satoshi net purchase,
|
|
||||||
change, rejected-payment refund, exact file bytes, Files access and free repeat
|
|
||||||
delivery. No native Bitcoin/LND funds were moved. PRs 161/162 are merged and
|
|
||||||
closed; the open pull-request list is empty.
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
- Angor's real dev API, fees, block tip, CORS and rootless/headless configuration
|
|
||||||
passed. Full-chain indexing remains dependent on initial Bitcoin sync finishing.
|
|
||||||
- Optional live AI providers, physical RNode hardware, the opt-in Reticulum TCP
|
|
||||||
subprocess test and creation of a production Minibits profile were not run.
|
|
||||||
- The previously recorded unsafe-doctor incident changed X250 container start
|
|
||||||
times before the final fix. Persistent databases were present after recovery,
|
|
||||||
but no pre-incident cryptographic wallet-identity baseline was available.
|
|
||||||
Do not describe recovery evidence as an exact pre-incident balance comparison.
|
|
||||||
- No claim of perfect behavior on every device, network or future failure is made.
|
|
||||||
|
|
||||||
## Raw ISO acceptance
|
|
||||||
|
|
||||||
The raw ISO is 2,755,072,000 bytes. SHA-256:
|
|
||||||
`cf7be6378dcd52f6f62774523341fa75dd453fa73a9cadff5390846f483e0140`.
|
|
||||||
|
|
||||||
Mounted-artifact smoke checks passed, including BIOS/UEFI boot files, live-boot
|
|
||||||
hooks, build contexts, current doctor overlay, crash-capture configuration,
|
|
||||||
version and frontend payload. The ISO backend and all 653 OTA frontend/runtime
|
|
||||||
files match exactly. AIUI metadata names the tested source commit.
|
|
||||||
|
|
||||||
A disposable QEMU/KVM x86_64 VM with UEFI firmware, 3 GiB RAM, two vCPUs, a fresh
|
|
||||||
64 GiB NVMe virtual disk and no network completed the full installation. This
|
|
||||||
covered partitioning, LUKS2 data encryption, swap, system configuration, UEFI
|
|
||||||
bootloader and initramfs generation. Cold boot with the ISO detached reached the
|
|
||||||
visible Welcome to Archipelago setup screen. The installed backend and both
|
|
||||||
historical/current doctor paths matched source hashes. Backend/nginx were active;
|
|
||||||
health reported RPC/sessions ready, crash recovery complete and version 1.8.22.
|
|
||||||
No wallet was initialized in this disposable VM.
|
|
||||||
|
|
||||||
The first automatic VM reboot selected the still-attached installer ISO. That
|
|
||||||
was corrected in the test configuration by detaching the ISO and explicitly
|
|
||||||
booting NVMe. It was not accepted as an installed-system boot. The subsequent
|
|
||||||
cold boot above is the successful acceptance run.
|
|
||||||
|
|
||||||
The dev native Bitcoin/LND identity/start-time baseline also remained unchanged
|
|
||||||
after the ISO build and VM acceptance.
|
|
||||||
|
|
||||||
## Publication verification
|
|
||||||
|
|
||||||
All three operator signatures verify against the pinned release root. The five
|
|
||||||
Gitea assets match independent server-side SHA-256 checks. Both OTA components
|
|
||||||
also passed complete public HTTPS downloads with exact hashes and sizes; the
|
|
||||||
raw ISO passed public size/range checks and both checksum sidecars read back
|
|
||||||
exactly. Only after these checks were the signed OTA manifest and compatible
|
|
||||||
app catalog promoted. The release tag identifies the tested source above.
|
|
||||||
|
|
||||||
Git and ngit publication completed on 2026-10-01. Both repository relays
|
|
||||||
acknowledged the ngit release; independent `release view` resolved all five
|
|
||||||
assets with exact hashes and sizes and no unresolved asset IDs. Main and the
|
|
||||||
release tag were pushed to both remotes.
|
|
||||||
|
|
||||||
Public main-branch OTA manifests and the app catalog read back byte-for-byte
|
|
||||||
and verified cryptographically. Live `update.check` on the accepted dev node
|
|
||||||
reported 1.8.22-alpha with no further update, as expected for the installed
|
|
||||||
release. Discovery on an older production node was not repeated during this
|
|
||||||
publication step.
|
|
||||||
|
|
||||||
- [Release and verification files](https://source.archipelago-foundation.org/lfg2025/archy/releases/tag/v1.8.22-alpha)
|
|
||||||
- [Raw ISO](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-installer-1.8.22-alpha-unbundled-x86_64_RC1.iso)
|
|
||||||
|
|
||||||
@@ -2607,11 +2607,6 @@ if [ -f "$SCRIPT_DIR/../../scripts/image-versions.sh" ]; then
|
|||||||
echo " ✅ Bundled image-versions.sh"
|
echo " ✅ Bundled image-versions.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Always overlay the current doctor, including when rootfs.tar is cached.
|
|
||||||
cp "$SCRIPT_DIR/../../scripts/container-doctor.sh" "$ARCH_DIR/scripts/"
|
|
||||||
cp "$SCRIPT_DIR/../configs/archipelago-doctor.service" "$ARCH_DIR/scripts/"
|
|
||||||
cp "$SCRIPT_DIR/../configs/archipelago-doctor.timer" "$ARCH_DIR/scripts/"
|
|
||||||
|
|
||||||
# Build-source apps need their complete contexts even on unbundled ISOs.
|
# Build-source apps need their complete contexts even on unbundled ISOs.
|
||||||
# Keep this identical to the OTA runtime payload; a per-app allowlist silently
|
# Keep this identical to the OTA runtime payload; a per-app allowlist silently
|
||||||
# omitted GitWorkshop, FIPS and Cuprate and made fresh installs fail at 70%.
|
# omitted GitWorkshop, FIPS and Cuprate and made fresh installs fail at 70%.
|
||||||
@@ -3235,18 +3230,6 @@ for test_script in run-e2e-tests.sh run-post-install-tests.sh; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# BEGIN DOCTOR OVERLAY
|
|
||||||
# Replace both the active and historical script locations before first boot.
|
|
||||||
# A cached rootfs can contain the unsafe network recovery implementation.
|
|
||||||
mkdir -p /mnt/target/opt/archipelago/scripts /mnt/target/home/archipelago/archy/scripts
|
|
||||||
for doctor_dir in /mnt/target/opt/archipelago/scripts /mnt/target/home/archipelago/archy/scripts; do
|
|
||||||
install -m 755 "$BOOT_MEDIA/archipelago/scripts/container-doctor.sh" "$doctor_dir/container-doctor.sh" || exit 1
|
|
||||||
done
|
|
||||||
for doctor_unit in archipelago-doctor.service archipelago-doctor.timer; do
|
|
||||||
install -m 644 "$BOOT_MEDIA/archipelago/scripts/$doctor_unit" "/mnt/target/etc/systemd/system/$doctor_unit" || exit 1
|
|
||||||
done
|
|
||||||
# END DOCTOR OVERLAY
|
|
||||||
|
|
||||||
# Copy self-update script
|
# Copy self-update script
|
||||||
if [ -f "$BOOT_MEDIA/archipelago/scripts/self-update.sh" ]; then
|
if [ -f "$BOOT_MEDIA/archipelago/scripts/self-update.sh" ]; then
|
||||||
cp "$BOOT_MEDIA/archipelago/scripts/self-update.sh" /mnt/target/opt/archipelago/scripts/
|
cp "$BOOT_MEDIA/archipelago/scripts/self-update.sh" /mnt/target/opt/archipelago/scripts/
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
<AppLauncherOverlay />
|
<AppLauncherOverlay />
|
||||||
<AppCredentialInterstitial />
|
<AppCredentialInterstitial />
|
||||||
|
|
||||||
<UploadProgress v-if="route.name !== 'cloud-folder'" floating />
|
|
||||||
|
|
||||||
<!-- Global toast notifications -->
|
<!-- Global toast notifications -->
|
||||||
<ToastStack />
|
<ToastStack />
|
||||||
|
|
||||||
@@ -98,7 +96,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UploadProgress from '@/components/cloud/UploadProgress.vue'
|
|
||||||
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import SplashScreen from './components/SplashScreen.vue'
|
import SplashScreen from './components/SplashScreen.vue'
|
||||||
|
|||||||
@@ -336,45 +336,3 @@ describe('sanitizePath', () => {
|
|||||||
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
|
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('upload transport progress and cancellation', () => {
|
|
||||||
class UploadXHR {
|
|
||||||
static instances: UploadXHR[] = []
|
|
||||||
upload = { onprogress: null as ((e: { loaded: number }) => void) | null }
|
|
||||||
onload: (() => void) | null = null
|
|
||||||
onabort: (() => void) | null = null
|
|
||||||
onerror: (() => void) | null = null
|
|
||||||
status = 200
|
|
||||||
withCredentials = false
|
|
||||||
contentType = 'application/json'
|
|
||||||
open = vi.fn(); setRequestHeader = vi.fn(); send = vi.fn()
|
|
||||||
abort = vi.fn(() => this.onabort?.())
|
|
||||||
getResponseHeader() { return this.contentType }
|
|
||||||
constructor() { UploadXHR.instances.push(this) }
|
|
||||||
}
|
|
||||||
beforeEach(() => { setAuthenticated(); UploadXHR.instances = []; vi.stubGlobal('XMLHttpRequest', UploadXHR) })
|
|
||||||
it('sends the file bytes, escapes folder names, and waits for server acceptance after 100%', async () => {
|
|
||||||
const controller = new AbortController(); const onProgress = vi.fn()
|
|
||||||
const file = new File(['data'], 'a #.txt')
|
|
||||||
let complete = false
|
|
||||||
const job = fileBrowserClient.upload('/folder #1', file, { signal: controller.signal, onProgress }).then(() => { complete = true })
|
|
||||||
await Promise.resolve(); await Promise.resolve()
|
|
||||||
const xhr = UploadXHR.instances[0]!
|
|
||||||
expect(xhr.open).toHaveBeenCalledWith('POST', expect.stringMatching(/\/app\/filebrowser\/api\/resources\/folder%20%231\/a%20%23.txt\?override=true$/))
|
|
||||||
expect(xhr.send).toHaveBeenCalledWith(file)
|
|
||||||
xhr.upload.onprogress?.({ loaded: 4 }); expect(onProgress).toHaveBeenCalledWith(4)
|
|
||||||
expect(complete).toBe(false)
|
|
||||||
xhr.onload?.(); await job; expect(complete).toBe(true)
|
|
||||||
})
|
|
||||||
it('cancels the actual request and refuses HTML masquerading as upload success', async () => {
|
|
||||||
const controller = new AbortController()
|
|
||||||
const job = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: controller.signal, onProgress: vi.fn() })
|
|
||||||
await Promise.resolve(); await Promise.resolve()
|
|
||||||
controller.abort(); await expect(job).rejects.toMatchObject({ name: 'AbortError' })
|
|
||||||
expect(UploadXHR.instances[0]!.abort).toHaveBeenCalled()
|
|
||||||
const next = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: new AbortController().signal, onProgress: vi.fn() })
|
|
||||||
await Promise.resolve(); await Promise.resolve()
|
|
||||||
const xhr = UploadXHR.instances[1]!; xhr.contentType = 'text/html'; xhr.onload?.()
|
|
||||||
await expect(next).rejects.toThrow('login page')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -201,43 +201,7 @@ class FileBrowserClient {
|
|||||||
URL.revokeObjectURL(blobUrl)
|
URL.revokeObjectURL(blobUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload(dirPath: string, file: File, options?: { signal: AbortSignal; onProgress: (sent: number) => void }): Promise<void> {
|
async upload(dirPath: string, file: File): Promise<void> {
|
||||||
if (options) {
|
|
||||||
await this.ensureAuth()
|
|
||||||
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
|
||||||
const folder = sanitizePath(dirPath).split('/').map(encodeURIComponent).join('/').replace(/\/$/, '')
|
|
||||||
const url = `${this.baseUrl}/api/resources${folder}/${encodeURIComponent(file.name)}?override=true`
|
|
||||||
const send = () => new Promise<number>((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest()
|
|
||||||
const cleanup = () => options.signal.removeEventListener('abort', abort)
|
|
||||||
const abort = () => { xhr.abort(); cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
|
|
||||||
xhr.open('POST', url)
|
|
||||||
xhr.withCredentials = true
|
|
||||||
for (const [key, value] of Object.entries(this.headers())) xhr.setRequestHeader(key, value)
|
|
||||||
xhr.upload.onprogress = (event) => options.onProgress(Math.min(file.size, event.loaded))
|
|
||||||
xhr.onerror = () => { cleanup(); reject(new Error('Upload connection lost. Keep the companion open and check the server connection.')) }
|
|
||||||
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
|
|
||||||
xhr.onload = () => {
|
|
||||||
cleanup()
|
|
||||||
if (xhr.status === 401) { resolve(401); return }
|
|
||||||
if (xhr.status < 200 || xhr.status >= 300) { reject(new Error(`Upload failed (HTTP ${xhr.status})`)); return }
|
|
||||||
if ((xhr.getResponseHeader('Content-Type') || '').includes('text/html')) {
|
|
||||||
reject(new Error('File Browser returned a login page instead of accepting the upload.')); return
|
|
||||||
}
|
|
||||||
resolve(xhr.status)
|
|
||||||
}
|
|
||||||
options.signal.addEventListener('abort', abort, { once: true })
|
|
||||||
if (options.signal.aborted) { abort(); return }
|
|
||||||
xhr.send(file)
|
|
||||||
})
|
|
||||||
if (await send() === 401) {
|
|
||||||
this._authenticated = false
|
|
||||||
await this.ensureAuth()
|
|
||||||
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
|
||||||
if (await send() === 401) throw new Error('Upload authentication expired. Sign in again.')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const sanitized = sanitizePath(dirPath)
|
const sanitized = sanitizePath(dirPath)
|
||||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||||
const encodedName = encodeURIComponent(file.name)
|
const encodedName = encodeURIComponent(file.name)
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="relative min-w-0 flex-1">
|
|
||||||
<input
|
|
||||||
ref="input"
|
|
||||||
v-model="query"
|
|
||||||
type="text"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
:aria-label="label"
|
|
||||||
data-controller-no-submit
|
|
||||||
class="app-header-search w-full pr-10 text-white placeholder-white/50 focus:outline-none transition-colors"
|
|
||||||
@keydown.esc.prevent="clear"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="query.length"
|
|
||||||
type="button"
|
|
||||||
aria-label="Clear search"
|
|
||||||
title="Clear search"
|
|
||||||
class="absolute right-1 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400/60 transition-colors"
|
|
||||||
@click="clear"
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { nextTick, ref } from 'vue'
|
|
||||||
defineProps<{ placeholder: string; label: string }>()
|
|
||||||
const query = defineModel<string>({ default: '' })
|
|
||||||
const input = ref<HTMLInputElement | null>(null)
|
|
||||||
async function clear() {
|
|
||||||
query.value = ''
|
|
||||||
await nextTick()
|
|
||||||
input.value?.focus()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -353,57 +353,15 @@
|
|||||||
|
|
||||||
<!-- Close Confirmation Modal -->
|
<!-- Close Confirmation Modal -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="!closingChannel && (closeTarget = null)">
|
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
|
||||||
<div class="glass-card p-6 w-full max-w-sm mx-4">
|
<div class="glass-card p-6 w-full max-w-sm mx-4">
|
||||||
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
|
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
|
||||||
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
|
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
|
||||||
<!-- Fee selection -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="text-white/60 text-sm block mb-1">Fee</label>
|
|
||||||
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
|
|
||||||
<button
|
|
||||||
v-for="preset in feePresets"
|
|
||||||
:key="preset.key"
|
|
||||||
@click="closeForm.feePreset = preset.key"
|
|
||||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
|
||||||
:class="closeForm.feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
|
|
||||||
>{{ preset.label }}</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="closeForm.feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
|
|
||||||
{{ feePresets.find(p => p.key === closeForm.feePreset)?.hint }}
|
|
||||||
</p>
|
|
||||||
<div v-else class="grid grid-cols-2 gap-3 mt-2">
|
|
||||||
<div>
|
|
||||||
<label class="text-white/60 text-xs block mb-1">Target confirmations</label>
|
|
||||||
<input
|
|
||||||
v-model.number="closeForm.customConfTarget"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="1008"
|
|
||||||
placeholder="6"
|
|
||||||
class="w-full input-glass"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
|
|
||||||
<input
|
|
||||||
v-model.number="closeForm.customSatPerVbyte"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="5000"
|
|
||||||
placeholder="—"
|
|
||||||
class="w-full input-glass"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="text-white/40 text-xs col-span-2">Set one — sats per vByte takes precedence when both are set</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="closeError" class="mb-3 alert-error">
|
<div v-if="closeError" class="mb-3 alert-error">
|
||||||
<p class="text-xs">{{ closeError }}</p>
|
<p class="text-xs">{{ closeError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button @click="closeTarget = null" :disabled="closingChannel" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||||
<button
|
<button
|
||||||
@click="closeChannel"
|
@click="closeChannel"
|
||||||
:disabled="closingChannel"
|
:disabled="closingChannel"
|
||||||
@@ -499,8 +457,8 @@ function closeTypeLabel(ch: ClosedChannel): string {
|
|||||||
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
|
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
|
||||||
|
|
||||||
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
|
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
|
||||||
{ key: 'standard', label: 'Standard', hint: 'Targets ~6 blocks (about an hour)', confTarget: 6 },
|
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
|
||||||
{ key: 'medium', label: 'Medium', hint: 'Targets ~3 blocks (about 30 minutes)', confTarget: 3 },
|
{ key: 'medium', label: 'Medium', hint: 'Confirms within ~3 blocks (about 30 minutes)', confTarget: 3 },
|
||||||
{ key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 },
|
{ key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 },
|
||||||
{ key: 'custom', label: 'Custom' },
|
{ key: 'custom', label: 'Custom' },
|
||||||
]
|
]
|
||||||
@@ -619,24 +577,22 @@ function loadChannels(): Promise<void> {
|
|||||||
return main
|
return main
|
||||||
}
|
}
|
||||||
|
|
||||||
function feeParams(
|
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
|
||||||
form: { feePreset: FeePreset; customSatPerVbyte: number | null; customConfTarget: number | null } = openForm.value,
|
const form = openForm.value
|
||||||
setError: (message: string) => void = message => { openError.value = message },
|
|
||||||
): { target_conf?: number; sat_per_vbyte?: number } | null {
|
|
||||||
if (form.feePreset !== 'custom') {
|
if (form.feePreset !== 'custom') {
|
||||||
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
|
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
|
||||||
}
|
}
|
||||||
const rate = form.customSatPerVbyte
|
const rate = form.customSatPerVbyte
|
||||||
const conf = form.customConfTarget
|
const conf = form.customConfTarget
|
||||||
if (rate != null && rate !== 0) {
|
if (rate != null && rate !== 0) {
|
||||||
if (!Number.isInteger(rate) || rate < 1 || rate > 5000) { setError('Sats per vByte must be a whole number between 1 and 5000'); return null }
|
if (rate < 1 || rate > 5000) { openError.value = 'Sats per vByte must be between 1 and 5000'; return null }
|
||||||
return { sat_per_vbyte: Math.floor(rate) }
|
return { sat_per_vbyte: Math.floor(rate) }
|
||||||
}
|
}
|
||||||
if (conf != null && conf !== 0) {
|
if (conf != null && conf !== 0) {
|
||||||
if (!Number.isInteger(conf) || conf < 1 || conf > 1008) { setError('Target confirmations must be a whole number between 1 and 1008'); return null }
|
if (conf < 1 || conf > 1008) { openError.value = 'Target confirmations must be between 1 and 1008'; return null }
|
||||||
return { target_conf: Math.floor(conf) }
|
return { target_conf: Math.floor(conf) }
|
||||||
}
|
}
|
||||||
setError('Custom fee requires target confirmations or sats per vByte')
|
openError.value = 'Custom fee requires target confirmations or sats per vByte'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,12 +629,7 @@ async function openChannel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultCloseForm = () => ({ feePreset: 'standard' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
|
|
||||||
const closeForm = ref(defaultCloseForm())
|
|
||||||
|
|
||||||
function confirmClose(ch: Channel) {
|
function confirmClose(ch: Channel) {
|
||||||
if (closingChannel.value) return
|
|
||||||
closeForm.value = defaultCloseForm()
|
|
||||||
closeTarget.value = ch
|
closeTarget.value = ch
|
||||||
closeError.value = null
|
closeError.value = null
|
||||||
}
|
}
|
||||||
@@ -686,15 +637,12 @@ function confirmClose(ch: Channel) {
|
|||||||
async function closeChannel() {
|
async function closeChannel() {
|
||||||
if (closingChannel.value || !closeTarget.value) return
|
if (closingChannel.value || !closeTarget.value) return
|
||||||
closeError.value = null
|
closeError.value = null
|
||||||
const fee = feeParams(closeForm.value, message => { closeError.value = message })
|
|
||||||
if (!fee) return
|
|
||||||
closingChannel.value = true
|
closingChannel.value = true
|
||||||
try {
|
try {
|
||||||
await rpcClient.call({
|
await rpcClient.call({
|
||||||
method: 'lnd.closechannel',
|
method: 'lnd.closechannel',
|
||||||
params: { channel_point: closeTarget.value.channel_point, ...fee },
|
params: { channel_point: closeTarget.value.channel_point },
|
||||||
timeout: 45000,
|
timeout: 30000,
|
||||||
maxRetries: 1,
|
|
||||||
})
|
})
|
||||||
closeTarget.value = null
|
closeTarget.value = null
|
||||||
await loadChannels()
|
await loadChannels()
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
<!-- Transparent glass, not a black slab (operator, 2026-08-09): the
|
<!-- Transparent glass, not a black slab (operator, 2026-08-09): the
|
||||||
backdrop blur alone keeps the pinned tabs legible over scrolling
|
backdrop blur alone keeps the pinned tabs legible over scrolling
|
||||||
rows without painting an opaque container onto the modal. -->
|
rows without painting an opaque container onto the modal. -->
|
||||||
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-nowrap overflow-x-auto bg-transparent backdrop-blur-md">
|
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-wrap bg-white/5 backdrop-blur-md">
|
||||||
<button
|
<button
|
||||||
v-for="f in filters"
|
v-for="f in filters"
|
||||||
:key="f.key"
|
:key="f.key"
|
||||||
class="shrink-0 whitespace-nowrap px-2.5 py-1 rounded-full text-xs transition-colors"
|
class="px-2.5 py-1 rounded-full text-xs transition-colors"
|
||||||
:class="activeFilter === f.key
|
:class="activeFilter === f.key
|
||||||
? 'bg-orange-500/25 text-orange-200 border border-orange-400/40'
|
? 'bg-orange-500/25 text-orange-200 border border-orange-400/40'
|
||||||
: 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'"
|
: 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'"
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import LightningChannelsPanel from '../LightningChannelsPanel.vue'
|
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
|
||||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
|
||||||
vi.mock('@/composables/useTxExplorer', () => ({ useTxExplorer: () => ({ openTx: vi.fn() }) }))
|
|
||||||
vi.mock('@/composables/useCachedResource', async () => {
|
|
||||||
const { ref } = await import('vue')
|
|
||||||
return { useCachedResource: () => ({ data: ref(null), loadState: ref('ready'), error: ref(null), refresh: vi.fn().mockResolvedValue(undefined) }) }
|
|
||||||
})
|
|
||||||
const channel = { chan_id: 'test', remote_pubkey: '02' + 'a'.repeat(64), channel_point: 'b'.repeat(64) + ':0', capacity: 100000, local_balance: 50000, remote_balance: 50000, active: true }
|
|
||||||
function open() {
|
|
||||||
const wrapper = mount(LightningChannelsPanel, { global: { stubs: { Teleport: true } } })
|
|
||||||
const vm = (wrapper.vm as any).$.setupState
|
|
||||||
vm.confirmClose(channel)
|
|
||||||
return { wrapper, vm }
|
|
||||||
}
|
|
||||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockResolvedValue({ success: true } as never) })
|
|
||||||
describe('channel closing fee choice', () => {
|
|
||||||
it.each([['standard', 6], ['medium', 3], ['fast', 1]])('forwards %s target and never automatically retries the mutation', async (preset, target) => {
|
|
||||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = preset
|
|
||||||
await vm.closeChannel()
|
|
||||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.closechannel', params: { channel_point: channel.channel_point, target_conf: target }, maxRetries: 1 }))
|
|
||||||
expect(vm.closeTarget).toBeNull(); wrapper.unmount()
|
|
||||||
})
|
|
||||||
it('sends the custom rate instead of a confirmation target', async () => {
|
|
||||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = 25; vm.closeForm.customConfTarget = 3
|
|
||||||
await vm.closeChannel()
|
|
||||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, sat_per_vbyte: 25 } }))
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
it.each([0.5, -1, 5001, NaN, Infinity])('rejects invalid custom rate %s before RPC', async rate => {
|
|
||||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = rate
|
|
||||||
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled(); expect(vm.closeError).toBeTruthy(); wrapper.unmount()
|
|
||||||
})
|
|
||||||
it('requires a custom value and accepts a custom confirmation target', async () => {
|
|
||||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'
|
|
||||||
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled()
|
|
||||||
vm.closeForm.customConfTarget = 2; await vm.closeChannel()
|
|
||||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 2 } })); wrapper.unmount()
|
|
||||||
})
|
|
||||||
it('keeps the chosen fee and error visible when LND rejects a close', async () => {
|
|
||||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Peer is offline'))
|
|
||||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'fast'
|
|
||||||
await vm.closeChannel(); expect(vm.closeTarget).not.toBeNull(); expect(vm.closeError).toBe('Peer is offline'); expect(vm.closeForm.feePreset).toBe('fast'); wrapper.unmount()
|
|
||||||
})
|
|
||||||
it('prevents duplicate submits while a close is pending', async () => {
|
|
||||||
let finish!: (value: unknown) => void
|
|
||||||
vi.mocked(rpcClient.call).mockImplementation(() => new Promise(resolve => { finish = resolve }) as never)
|
|
||||||
const { wrapper, vm } = open(); const pending = vm.closeChannel(); await vm.closeChannel()
|
|
||||||
expect(rpcClient.call).toHaveBeenCalledTimes(1); finish({ success: true }); await pending; await flushPromises(); wrapper.unmount()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="glass-button cloud-toolbar-btn" title="Upload file" :disabled="uploading" @click="triggerUpload">
|
<button class="glass-button cloud-toolbar-btn" title="Upload file" @click="triggerUpload">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -61,7 +61,6 @@
|
|||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
:disabled="uploading"
|
|
||||||
class="hidden"
|
class="hidden"
|
||||||
multiple
|
multiple
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
@@ -75,7 +74,6 @@ import { ref } from 'vue'
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
breadcrumbs: { name: string; path: string }[]
|
breadcrumbs: { name: string; path: string }[]
|
||||||
viewMode: 'list' | 'grid'
|
viewMode: 'list' | 'grid'
|
||||||
uploading?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="task" :class="floating ? 'fixed z-50 top-20 left-4 right-4 md:left-auto md:w-96' : 'mb-3 shrink-0'">
|
|
||||||
<div class="glass-card relative overflow-hidden h-11 px-3 flex items-center gap-2" role="status" aria-live="polite">
|
|
||||||
<div v-if="task.active" class="absolute inset-y-0 left-0 bg-emerald-400/10 transition-[width] duration-200 pointer-events-none" :style="{ width: `${percent}%` }" />
|
|
||||||
<div v-if="task.active" class="absolute bottom-0 left-0 h-0.5 bg-emerald-400 transition-[width] duration-200" :style="{ width: `${percent}%` }" role="progressbar" :aria-valuenow="percent" aria-valuemin="0" aria-valuemax="100" :aria-label="`Uploading ${task.filename}`" />
|
|
||||||
<span class="relative min-w-0 flex-1 text-sm truncate" :class="task.error ? 'text-red-300' : 'text-white/80'" :title="label">{{ label }}</span>
|
|
||||||
<span v-if="task.active" class="relative shrink-0 text-xs tabular-nums text-white/60">{{ percent }}%</span>
|
|
||||||
<button class="relative shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10" :aria-label="task.active ? 'Cancel upload' : 'Dismiss upload'" @click="task.active ? store.cancelUpload() : store.dismissUpload()">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" /></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useCloudStore } from '@/stores/cloud'
|
|
||||||
defineProps<{ floating?: boolean }>()
|
|
||||||
const store = useCloudStore()
|
|
||||||
const task = computed(() => store.upload)
|
|
||||||
const percent = computed(() => !task.value ? 0 : task.value.total ? Math.min(100, Math.floor(task.value.sent * 100 / task.value.total)) : task.value.active ? 0 : 100)
|
|
||||||
const label = computed(() => {
|
|
||||||
const t = task.value
|
|
||||||
if (!t) return ''
|
|
||||||
if (t.error) return t.error
|
|
||||||
if (t.cancelled) return `Upload stopped · ${t.completed}/${t.count} saved`
|
|
||||||
if (!t.active) return `${t.count === 1 ? t.filename : `${t.count} files`} uploaded`
|
|
||||||
return `${percent.value === 100 ? 'Saving' : 'Uploading'} ${t.filename}${t.count > 1 ? ` · ${t.completed + 1}/${t.count}` : ''}`
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -231,50 +231,3 @@ describe('useCloudStore', () => {
|
|||||||
expect(store.error).toBeNull()
|
expect(store.error).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('persistent upload batch', () => {
|
|
||||||
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
|
|
||||||
it('retains the destination and byte progress across folder navigation', async () => {
|
|
||||||
const store = useCloudStore(); store.authenticated = true; store.currentPath = '/original'
|
|
||||||
let release!: () => void
|
|
||||||
mockedClient.upload.mockImplementationOnce(async (_path, _file, options) => {
|
|
||||||
options!.onProgress(2)
|
|
||||||
await new Promise<void>(resolve => { release = resolve })
|
|
||||||
}).mockResolvedValueOnce(undefined)
|
|
||||||
mockedClient.listDirectory.mockResolvedValue([])
|
|
||||||
const job = store.uploadFiles([new File(['abcd'], 'one'), new File(['ef'], 'two')])
|
|
||||||
expect(store.upload?.sent).toBe(2)
|
|
||||||
expect(store.upload?.total).toBe(6)
|
|
||||||
await store.navigate('/elsewhere')
|
|
||||||
expect(useCloudStore().upload?.active).toBe(true)
|
|
||||||
release(); await job
|
|
||||||
expect(mockedClient.upload.mock.calls.map(call => call[0])).toEqual(['/original', '/original'])
|
|
||||||
expect(store.currentPath).toBe('/elsewhere')
|
|
||||||
expect(store.upload).toMatchObject({ active: false, sent: 6, completed: 2, error: null })
|
|
||||||
})
|
|
||||||
it('aborts the active request, stops the queue and preserves completed files', async () => {
|
|
||||||
const store = useCloudStore(); store.authenticated = true
|
|
||||||
mockedClient.listDirectory.mockResolvedValue([])
|
|
||||||
mockedClient.upload.mockResolvedValueOnce(undefined).mockImplementationOnce((_path, _file, options) => new Promise((_resolve, reject) => {
|
|
||||||
options!.signal.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')))
|
|
||||||
}))
|
|
||||||
const files = ['one', 'two', 'three'].map(name => new File(['abc'], name))
|
|
||||||
const job = store.uploadFiles(files)
|
|
||||||
await Promise.resolve(); await Promise.resolve()
|
|
||||||
store.cancelUpload(); await job
|
|
||||||
expect(mockedClient.upload).toHaveBeenCalledTimes(2)
|
|
||||||
expect(store.upload).toMatchObject({ active: false, completed: 1, cancelled: true, error: null })
|
|
||||||
store.dismissUpload(); expect(store.upload).toBeNull()
|
|
||||||
})
|
|
||||||
it('keeps failures visible and does not start a second overlapping batch', async () => {
|
|
||||||
const store = useCloudStore(); store.authenticated = true
|
|
||||||
mockedClient.listDirectory.mockResolvedValue([])
|
|
||||||
let fail!: (error: Error) => void
|
|
||||||
mockedClient.upload.mockImplementationOnce(() => new Promise((_resolve, reject) => { fail = reject }))
|
|
||||||
const file = new File(['x'], 'one')
|
|
||||||
const job = store.uploadFiles([file]); await store.uploadFiles([file])
|
|
||||||
expect(mockedClient.upload).toHaveBeenCalledTimes(1)
|
|
||||||
fail(new Error('No space')); await job
|
|
||||||
expect(store.upload).toMatchObject({ active: false, error: 'No space', completed: 0 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -8,41 +8,6 @@ export const useCloudStore = defineStore('cloud', () => {
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const authenticated = ref(false)
|
const authenticated = ref(false)
|
||||||
const upload = ref<{ active: boolean; filename: string; destination: string; sent: number; total: number; completed: number; count: number; error: string | null; cancelled: boolean } | null>(null)
|
|
||||||
let uploadController: AbortController | null = null
|
|
||||||
function cancelUpload() { uploadController?.abort() }
|
|
||||||
function dismissUpload() { if (!upload.value?.active) upload.value = null }
|
|
||||||
async function uploadFiles(files: File[]) {
|
|
||||||
if (!files.length || upload.value?.active) return
|
|
||||||
const destination = currentPath.value
|
|
||||||
const controller = new AbortController()
|
|
||||||
uploadController = controller
|
|
||||||
const task = { active: true, filename: files[0]!.name, destination, sent: 0, total: files.reduce((n, f) => n + f.size, 0), completed: 0, count: files.length, error: null as string | null, cancelled: false }
|
|
||||||
upload.value = task
|
|
||||||
let completedBytes = 0
|
|
||||||
try {
|
|
||||||
for (const file of files) {
|
|
||||||
if (controller.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
|
||||||
upload.value.filename = file.name
|
|
||||||
await fileBrowserClient.upload(destination, file, { signal: controller.signal, onProgress: (sent) => {
|
|
||||||
if (upload.value?.active) upload.value.sent = completedBytes + sent
|
|
||||||
} })
|
|
||||||
completedBytes += file.size
|
|
||||||
upload.value.sent = completedBytes
|
|
||||||
upload.value.completed++
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
upload.value.cancelled = controller.signal.aborted
|
|
||||||
if (!upload.value.cancelled) upload.value.error = error instanceof Error ? error.message : 'Upload failed'
|
|
||||||
} finally {
|
|
||||||
upload.value.active = false
|
|
||||||
uploadController = null
|
|
||||||
// Navigation must never redirect subsequent files into the new folder.
|
|
||||||
pathCache.delete(destination)
|
|
||||||
if (currentPath.value === destination) await refresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-path listing cache: re-entering a folder paints the last listing
|
// Per-path listing cache: re-entering a folder paints the last listing
|
||||||
// immediately (no spinner) while the fresh listing loads behind it.
|
// immediately (no spinner) while the fresh listing loads behind it.
|
||||||
const pathCache = new Map<string, FileBrowserItem[]>()
|
const pathCache = new Map<string, FileBrowserItem[]>()
|
||||||
@@ -166,7 +131,6 @@ export const useCloudStore = defineStore('cloud', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
upload, uploadFiles, cancelUpload, dismissUpload,
|
|
||||||
currentPath,
|
currentPath,
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -56,7 +56,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-header-search-wrap flex items-center gap-2">
|
<div class="app-header-search-wrap flex items-center gap-2">
|
||||||
<AppSearchField v-model="searchQuery" :placeholder="t('apps.searchPlaceholder')" :label="t('apps.searchLabel')" />
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('apps.searchPlaceholder')"
|
||||||
|
:aria-label="t('apps.searchLabel')"
|
||||||
|
data-controller-no-submit
|
||||||
|
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="sideload-icon-btn"
|
class="sideload-icon-btn"
|
||||||
@@ -99,7 +106,14 @@
|
|||||||
>{{ category.name }}</button>
|
>{{ category.name }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<AppSearchField v-model="searchQuery" :placeholder="t('apps.searchPlaceholder')" :label="t('apps.searchLabel')" />
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('apps.searchPlaceholder')"
|
||||||
|
:aria-label="t('apps.searchLabel')"
|
||||||
|
data-controller-no-submit
|
||||||
|
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="sideload-icon-btn sideload-icon-btn-mobile"
|
class="sideload-icon-btn sideload-icon-btn-mobile"
|
||||||
@@ -360,7 +374,6 @@ let appsAnimationDone = false
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppSearchField from '@/components/AppSearchField.vue'
|
|
||||||
import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
|
import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|||||||
@@ -39,8 +39,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UploadProgress />
|
|
||||||
|
|
||||||
<!-- App Not Installed -->
|
<!-- App Not Installed -->
|
||||||
<div v-if="!appRunning" class="glass-card p-12 text-center flex-1 flex flex-col items-center justify-center">
|
<div v-if="!appRunning" class="glass-card p-12 text-center flex-1 flex flex-col items-center justify-center">
|
||||||
<svg class="w-20 h-20 text-white/15 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-20 h-20 text-white/15 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -77,11 +75,19 @@
|
|||||||
<p class="text-sm text-white/50">Files will be added to the current folder</p>
|
<p class="text-sm text-white/50">Files will be added to the current folder</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Upload progress -->
|
||||||
|
<div v-if="uploading" class="glass-card p-3 mb-3 flex items-center gap-3">
|
||||||
|
<div class="w-5 h-5 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||||
|
<span class="text-sm text-white/70">Uploading...</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="uploadError" class="glass-card p-3 mb-3 flex items-center gap-3 border border-red-500/30">
|
||||||
|
<span class="text-sm text-red-400">{{ uploadError }}</span>
|
||||||
|
<button class="text-xs text-white/50 hover:text-white ml-auto" @click="uploadError = null">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CloudToolbar
|
<CloudToolbar
|
||||||
:breadcrumbs="cloudStore.breadcrumbs"
|
:breadcrumbs="cloudStore.breadcrumbs"
|
||||||
:view-mode="viewMode"
|
:view-mode="viewMode"
|
||||||
:uploading="!!cloudStore.upload?.active"
|
|
||||||
@navigate="navigateCloudPath"
|
@navigate="navigateCloudPath"
|
||||||
@refresh="cloudStore.refresh()"
|
@refresh="cloudStore.refresh()"
|
||||||
@upload="handleUpload"
|
@upload="handleUpload"
|
||||||
@@ -99,7 +105,6 @@
|
|||||||
:items="cloudStore.sortedItems"
|
:items="cloudStore.sortedItems"
|
||||||
:loading="cloudStore.loading"
|
:loading="cloudStore.loading"
|
||||||
:view-mode="viewMode"
|
:view-mode="viewMode"
|
||||||
:uploading="!!cloudStore.upload?.active"
|
|
||||||
@navigate="navigateCloudPath"
|
@navigate="navigateCloudPath"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
@play="handlePlay"
|
@play="handlePlay"
|
||||||
@@ -154,7 +159,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UploadProgress from '@/components/cloud/UploadProgress.vue'
|
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
@@ -189,6 +193,7 @@ watch(() => cloudStore.currentPath, (path) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const iframeLoaded = ref(false)
|
const iframeLoaded = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
const folderId = computed(() => route.params.folderId as string)
|
const folderId = computed(() => route.params.folderId as string)
|
||||||
const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/'))
|
const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/'))
|
||||||
|
|
||||||
@@ -343,6 +348,7 @@ function handleShare(path: string, name: string, isDir: boolean) {
|
|||||||
shareTarget.value = { path, name, isDir }
|
shareTarget.value = { path, name, isDir }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadError = ref<string | null>(null)
|
||||||
const draggingOver = ref(false)
|
const draggingOver = ref(false)
|
||||||
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
|
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
@@ -366,7 +372,17 @@ function onDrop(e: DragEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpload(files: File[]) {
|
async function handleUpload(files: File[]) {
|
||||||
await cloudStore.uploadFiles(files)
|
uploading.value = true
|
||||||
|
uploadError.value = null
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
await cloudStore.uploadFile(file)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(path: string) {
|
async function handleDelete(path: string) {
|
||||||
|
|||||||
@@ -49,7 +49,14 @@
|
|||||||
{{ section.name }}
|
{{ section.name }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<AppSearchField v-model="searchQuery" placeholder="Search apps..." label="Search apps" />
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search apps..."
|
||||||
|
aria-label="Search apps"
|
||||||
|
data-controller-no-submit
|
||||||
|
class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
|
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,7 +82,14 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>{{ section.name }}</button>
|
>{{ section.name }}</button>
|
||||||
</div>
|
</div>
|
||||||
<AppSearchField v-model="searchQuery" placeholder="Search apps..." label="Search apps" />
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search apps..."
|
||||||
|
aria-label="Search apps"
|
||||||
|
data-controller-no-submit
|
||||||
|
class="app-header-search w-full text-white placeholder-white/50 focus:outline-none transition-colors"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -331,7 +345,6 @@ let discoverAnimationDone = false
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppSearchField from '@/components/AppSearchField.vue'
|
|
||||||
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
||||||
import { useRouter, RouterLink } from 'vue-router'
|
import { useRouter, RouterLink } from 'vue-router'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|||||||
@@ -396,7 +396,7 @@
|
|||||||
accepts for this item are offered -->
|
accepts for this item are offered -->
|
||||||
<div v-if="payMode === 'choose'" class="space-y-3">
|
<div v-if="payMode === 'choose'" class="space-y-3">
|
||||||
<button
|
<button
|
||||||
v-if="!lnReceipt && (acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint'))"
|
v-if="acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint')"
|
||||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||||
:disabled="ecashPreparing || downloading === payItem.id"
|
:disabled="ecashPreparing || downloading === payItem.id"
|
||||||
@click="prepareEcashPay"
|
@click="prepareEcashPay"
|
||||||
@@ -420,8 +420,8 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span>
|
||||||
<span class="block text-base text-white">{{ lnPaying ? (lnReceipt ? 'Checking payment…' : 'Paying…') : (lnReceipt ? 'Retry paid download' : 'Pay with my Lightning node') }}</span>
|
<span class="block text-base text-white">{{ lnPaying ? 'Paying…' : 'Pay with my Lightning node' }}</span>
|
||||||
<span class="block text-sm text-white/50">{{ lnReceipt ? 'Uses the saved payment; does not send more sats' : 'Pays the seller’s invoice from your node’s Lightning wallet' }}</span>
|
<span class="block text-sm text-white/50">Pays the seller’s invoice from your node’s Lightning wallet</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -843,22 +843,6 @@ const onchainError = ref('')
|
|||||||
const onchainCopied = ref(false)
|
const onchainCopied = ref(false)
|
||||||
const lnPaying = ref(false)
|
const lnPaying = ref(false)
|
||||||
const lnError = ref('')
|
const lnError = ref('')
|
||||||
type LightningReceipt = { bolt11: string; payment_hash: string; price_sats: number }
|
|
||||||
const lnReceipt = ref<LightningReceipt | null>(null)
|
|
||||||
function receiptKey(onion: string, id: string) { return `peer-file-lightning:${onion}:${id}` }
|
|
||||||
function readReceipt(onion: string, id: string): LightningReceipt | null {
|
|
||||||
const raw = localStorage.getItem(receiptKey(onion, id))
|
|
||||||
if (!raw) return null
|
|
||||||
const receipt = JSON.parse(raw) as LightningReceipt
|
|
||||||
if (!receipt.bolt11 || !/^[a-f0-9]{64}$/i.test(receipt.payment_hash)) throw new Error('Saved payment needs recovery. Do not pay again.')
|
|
||||||
return receipt
|
|
||||||
}
|
|
||||||
function keepReceipt(onion: string, id: string, receipt: LightningReceipt) {
|
|
||||||
// Must succeed before handing an invoice to a payer. A failed transfer or
|
|
||||||
// navigation must never turn Retry into a second payment.
|
|
||||||
localStorage.setItem(receiptKey(onion, id), JSON.stringify(receipt))
|
|
||||||
lnReceipt.value = receipt
|
|
||||||
}
|
|
||||||
const onchainPaying = ref(false)
|
const onchainPaying = ref(false)
|
||||||
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
|
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
|
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -1111,8 +1095,6 @@ function openPayModal(item: CatalogItem) {
|
|||||||
onchainCopied.value = false
|
onchainCopied.value = false
|
||||||
lnPaying.value = false
|
lnPaying.value = false
|
||||||
lnError.value = ''
|
lnError.value = ''
|
||||||
try { lnReceipt.value = readReceipt(props.peerId || currentPeer.value?.onion || '', item.id) }
|
|
||||||
catch { lnError.value = 'Saved payment could not be read. Do not pay again.' }
|
|
||||||
onchainPaying.value = false
|
onchainPaying.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1134,8 +1116,6 @@ function closePayModal() {
|
|||||||
* immediately for any external wallet).
|
* immediately for any external wallet).
|
||||||
*/
|
*/
|
||||||
function openQrPay() {
|
function openQrPay() {
|
||||||
payMode.value = 'qr'
|
|
||||||
if (lnReceipt.value) { qrTab.value = 'lightning'; void payWithInvoice(); return }
|
|
||||||
payMode.value = 'qr'
|
payMode.value = 'qr'
|
||||||
invoiceData.value = null
|
invoiceData.value = null
|
||||||
invoiceQr.value = ''
|
invoiceQr.value = ''
|
||||||
@@ -1159,10 +1139,6 @@ function openQrPay() {
|
|||||||
* forth doesn't silently stop watching for payment). */
|
* forth doesn't silently stop watching for payment). */
|
||||||
function selectQrTab(tab: 'onchain' | 'lightning') {
|
function selectQrTab(tab: 'onchain' | 'lightning') {
|
||||||
if (qrTab.value === tab) return
|
if (qrTab.value === tab) return
|
||||||
if (tab === 'onchain' && lnReceipt.value) {
|
|
||||||
invoiceError.value = 'A Lightning payment is saved. Recover it before choosing another payment method.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
qrTab.value = tab
|
qrTab.value = tab
|
||||||
if (tab === 'onchain') {
|
if (tab === 'onchain') {
|
||||||
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
||||||
@@ -1362,27 +1338,20 @@ async function prepareEcashPay() {
|
|||||||
* mobile companion ("paid but never unlocked"); the viewer's Save button
|
* mobile companion ("paid but never unlocked"); the viewer's Save button
|
||||||
* still offers an explicit download.
|
* still offers an explicit download.
|
||||||
*/
|
*/
|
||||||
function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeType?: string, seller?: string) {
|
function openPurchased(item: CatalogItem, base64Data: string, mimeType?: string) {
|
||||||
const onion = seller || props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
const url = base64Data !== undefined
|
|
||||||
? URL.createObjectURL(base64ToBlob(base64Data, mimeType || item.mime_type))
|
|
||||||
: `/api/peer-content/${encodeURIComponent(onion || "")}/${encodeURIComponent(item.id)}`
|
|
||||||
if (onion) {
|
|
||||||
try { localStorage.removeItem(receiptKey(onion, item.id)) } catch { /* owned cache remains authoritative */ }
|
|
||||||
lnReceipt.value = null
|
|
||||||
}
|
|
||||||
if (onion) ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
|
if (onion) ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
|
||||||
const mime = mimeType || item.mime_type
|
const mime = mimeType || item.mime_type
|
||||||
if (mime.startsWith('audio/')) {
|
if (mime.startsWith('audio/')) {
|
||||||
// Straight to the bottom-bar player — the blob URL intentionally stays
|
// Straight to the bottom-bar player — the blob URL intentionally stays
|
||||||
// alive while the bar owns playback.
|
// alive while the bar owns playback.
|
||||||
audioPlayer.play(
|
audioPlayer.play(
|
||||||
url,
|
URL.createObjectURL(base64ToBlob(base64Data, mime)),
|
||||||
item.filename.split('/').pop() || item.filename,
|
item.filename.split('/').pop() || item.filename,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
releaseViewerUrl()
|
releaseViewerUrl()
|
||||||
viewerUrl.value = url
|
viewerUrl.value = URL.createObjectURL(base64ToBlob(base64Data, mime))
|
||||||
viewerMime.value = mime
|
viewerMime.value = mime
|
||||||
viewerItem.value = item
|
viewerItem.value = item
|
||||||
}
|
}
|
||||||
@@ -1430,7 +1399,7 @@ async function payWithInvoice() {
|
|||||||
invoiceError.value = ''
|
invoiceError.value = ''
|
||||||
invoiceWaiting.value = true
|
invoiceWaiting.value = true
|
||||||
try {
|
try {
|
||||||
const res = readReceipt(onion, item.id) as (LightningReceipt & { error?: string }) | null || await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
||||||
method: 'content.request-invoice',
|
method: 'content.request-invoice',
|
||||||
params: { onion, content_id: item.id },
|
params: { onion, content_id: item.id },
|
||||||
timeout: 45000,
|
timeout: 45000,
|
||||||
@@ -1441,7 +1410,6 @@ async function payWithInvoice() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
|
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
|
||||||
keepReceipt(onion, item.id, invoiceData.value)
|
|
||||||
try {
|
try {
|
||||||
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
|
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1456,46 +1424,54 @@ async function payWithInvoice() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Pay the seller's invoice straight from THIS node's Lightning wallet, then
|
* Pay the seller's invoice straight from THIS node's Lightning wallet, then
|
||||||
* release the file. Keep the invoice before payment so uncertain outcomes can
|
* release the file. payLightningInvoice resolves to a real terminal state, so
|
||||||
* retry seller verification and delivery without sending a second payment.
|
* on success the payment_hash is immediately valid as the download gate token.
|
||||||
*/
|
*/
|
||||||
async function payWithLightning() {
|
async function payWithLightning() {
|
||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!item || !onion || lnPaying.value) return
|
if (!item || !onion || lnPaying.value) return
|
||||||
|
|
||||||
lnPaying.value = true
|
lnPaying.value = true
|
||||||
lnError.value = ''
|
lnError.value = ''
|
||||||
try {
|
try {
|
||||||
let inv = readReceipt(onion, item.id)
|
// 1. Ask the seller to mint a bolt11 (also records the pending entitlement).
|
||||||
if (!inv) {
|
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
||||||
const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
method: 'content.request-invoice',
|
||||||
method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000,
|
params: { onion, content_id: item.id },
|
||||||
})
|
timeout: 45000,
|
||||||
if (!result?.bolt11 || !result.payment_hash) throw new Error(result?.error || 'The seller could not create an invoice.')
|
|
||||||
inv = { bolt11: result.bolt11, payment_hash: result.payment_hash, price_sats: getItemPrice(item.access) }
|
|
||||||
keepReceipt(onion, item.id, inv)
|
|
||||||
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
|
|
||||||
if (pay.status === 'failed') {
|
|
||||||
localStorage.removeItem(receiptKey(onion, item.id)); lnReceipt.value = null
|
|
||||||
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (pay.status === 'pending') {
|
|
||||||
lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lnReceipt.value = inv
|
|
||||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
|
|
||||||
method: 'content.download-peer-invoice',
|
|
||||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
|
||||||
timeout: 960000,
|
|
||||||
})
|
})
|
||||||
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion)
|
if (!inv?.bolt11 || !inv?.payment_hash) {
|
||||||
else lnError.value = dl?.error || 'Download unavailable. Retry uses this payment without sending more sats.'
|
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 2. Pay it from our own node. Tracked to a REAL terminal state — a slow
|
||||||
|
// multi-hop route resolves via status polling instead of a false failure.
|
||||||
|
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
|
||||||
|
if (pay.status === 'failed') {
|
||||||
|
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pay.status === 'pending') {
|
||||||
|
lnError.value = 'Payment is still settling — this can take a few minutes. Check your wallet transactions before paying again.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 3. Settled — pull the file using the payment hash as the gate token.
|
||||||
|
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
|
||||||
|
method: 'content.download-peer-invoice',
|
||||||
|
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||||
|
timeout: 120000,
|
||||||
|
})
|
||||||
|
if (dl?.data) {
|
||||||
|
openPurchased(item, dl.data, dl.mime_type)
|
||||||
|
} else {
|
||||||
|
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
lnError.value = (e instanceof Error ? e.message : 'Payment or download could not be confirmed') + ' Retry checks the saved payment; do not pay again.'
|
lnError.value = e instanceof Error ? e.message : 'Could not pay from your Lightning node'
|
||||||
} finally { lnPaying.value = false }
|
} finally {
|
||||||
|
lnPaying.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleInvoicePoll() {
|
function scheduleInvoicePoll() {
|
||||||
@@ -1511,19 +1487,19 @@ async function pollInvoice() {
|
|||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<{ paid?: boolean }>({
|
const res = await rpcClient.call<{ paid?: boolean }>({
|
||||||
method: 'content.invoice-status',
|
method: 'content.invoice-status',
|
||||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
})
|
})
|
||||||
if (res?.paid) {
|
if (res?.paid) {
|
||||||
// Settled — pull the file using the payment hash as the gate token.
|
// Settled — pull the file using the payment hash as the gate token.
|
||||||
invoiceWaiting.value = false
|
invoiceWaiting.value = false
|
||||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
|
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
|
||||||
method: 'content.download-peer-invoice',
|
method: 'content.download-peer-invoice',
|
||||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||||
timeout: 960000,
|
timeout: 120000,
|
||||||
})
|
})
|
||||||
if (dl?.data !== undefined || dl?.owned === true) {
|
if (dl?.data) {
|
||||||
openPurchased(item, dl.data, dl.mime_type, onion)
|
openPurchased(item, dl.data, dl.mime_type)
|
||||||
} else {
|
} else {
|
||||||
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import PeerFiles from '../PeerFiles.vue'
|
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
|
||||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
|
||||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } }))
|
|
||||||
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
|
|
||||||
const hash = 'a'.repeat(64)
|
|
||||||
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning'] } } }
|
|
||||||
const receiptKey = 'peer-file-lightning:peer.onion:paid-file'
|
|
||||||
const download = vi.fn()
|
|
||||||
async function open() {
|
|
||||||
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
|
|
||||||
await flushPromises()
|
|
||||||
// Drive the actual component payment handlers, asserting RPC effects rather
|
|
||||||
// than a duplicate implementation of the payment state machine.
|
|
||||||
const vm = (wrapper.vm as any).$.setupState
|
|
||||||
vm.openPayModal(item)
|
|
||||||
return { wrapper, vm }
|
|
||||||
}
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear(); vi.clearAllMocks()
|
|
||||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] } as never)
|
|
||||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
|
||||||
if (method === 'content.request-invoice') return { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
|
|
||||||
if (method === 'content.download-peer-invoice') return download()
|
|
||||||
return { items: [] }
|
|
||||||
})
|
|
||||||
vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never)
|
|
||||||
})
|
|
||||||
describe('Lightning file delivery recovery', () => {
|
|
||||||
it('retries delivery after a seller rejection without paying or requesting another invoice', async () => {
|
|
||||||
download.mockResolvedValue({ error: 'Seller has not registered this payment yet' })
|
|
||||||
const { wrapper, vm } = await open()
|
|
||||||
await vm.payWithLightning()
|
|
||||||
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash })
|
|
||||||
vm.closePayModal(); vm.openPayModal(item)
|
|
||||||
await vm.payWithLightning()
|
|
||||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
|
||||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(1)
|
|
||||||
expect(download).toHaveBeenCalledTimes(2)
|
|
||||||
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ payment_hash: hash, filename: 'bought.txt', price_sats: 5 })
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
it('restores an uncertain payment on a newly mounted page and only checks/downloads', async () => {
|
|
||||||
vi.mocked(rpcClient.payLightningInvoice).mockRejectedValue(new Error('Connection lost'))
|
|
||||||
download.mockResolvedValue({ error: 'Pending' })
|
|
||||||
const first = await open(); await first.vm.payWithLightning(); first.wrapper.unmount()
|
|
||||||
const second = await open(); await second.vm.payWithLightning()
|
|
||||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
|
||||||
expect(download).toHaveBeenCalledTimes(1)
|
|
||||||
second.wrapper.unmount()
|
|
||||||
})
|
|
||||||
it('opens cached delivery through HTTP without a base64 file in the response', async () => {
|
|
||||||
download.mockResolvedValue({ owned: true, mime_type: 'video/mp4', size_bytes: 206165161 })
|
|
||||||
const { wrapper, vm } = await open()
|
|
||||||
await vm.payWithLightning()
|
|
||||||
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
|
|
||||||
expect(vm.viewerMime).toBe('video/mp4')
|
|
||||||
expect(localStorage.getItem(receiptKey)).toBeNull()
|
|
||||||
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ cache_only: true })
|
|
||||||
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
it('never pays again when the saved receipt is corrupt', async () => {
|
|
||||||
localStorage.setItem(receiptKey, '{broken')
|
|
||||||
const { wrapper, vm } = await open()
|
|
||||||
await vm.payWithLightning()
|
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
|
||||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(0)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
it('keeps QR recovery on the saved Lightning payment instead of creating another rail', async () => {
|
|
||||||
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }))
|
|
||||||
const { wrapper, vm } = await open()
|
|
||||||
vm.openQrPay(); await flushPromises()
|
|
||||||
expect(vm.payMode).toBe('qr')
|
|
||||||
expect(vm.qrTab).toBe('lightning')
|
|
||||||
vm.selectQrTab('onchain'); await flushPromises()
|
|
||||||
expect(vm.qrTab).toBe('lightning')
|
|
||||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => ['content.request-invoice', 'content.request-onchain'].includes(v.method))).toHaveLength(0)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
it('does not send payment if the recovery record cannot be saved', async () => {
|
|
||||||
const { wrapper, vm } = await open()
|
|
||||||
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage full') })
|
|
||||||
await vm.payWithLightning()
|
|
||||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
|
||||||
save.mockRestore(); wrapper.unmount()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -246,9 +246,3 @@ it('does not display a stale Mempool frontend alias beside its live package', ()
|
|||||||
expect(shown.map(([id]) => id)).toEqual(['mempool'])
|
expect(shown.map(([id]) => id)).toEqual(['mempool'])
|
||||||
expect(filterEntriesForTab([['mempool-web', alias]], 'apps', 'all').map(([id]) => id)).toEqual(['mempool-web'])
|
expect(filterEntriesForTab([['mempool-web', alias]], 'apps', 'all').map(([id]) => id)).toEqual(['mempool-web'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows Immich as one app without internal database/cache cards in either tab', () => {
|
|
||||||
const entries: [string, PackageDataEntry][] = ['immich', 'immich-postgres', 'immich-redis', 'immich_postgres', 'immich_redis'].map(id => [id, makePkg(id, id, 'media')])
|
|
||||||
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['immich'])
|
|
||||||
expect(filterEntriesForTab(entries, 'services', 'all')).toEqual([])
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ export const isServiceContainer = sharedIsServiceContainer
|
|||||||
|
|
||||||
const INTERNAL_TOOLING_NAMES = new Set([
|
const INTERNAL_TOOLING_NAMES = new Set([
|
||||||
'buildx_buildkit_default',
|
'buildx_buildkit_default',
|
||||||
// Stack internals belong to their parent app, including cached inventories
|
|
||||||
// from nodes predating backend alias normalization.
|
|
||||||
'immich-postgres', 'immich-redis', 'immich_postgres', 'immich_redis',
|
|
||||||
// Cuprate's dashboard is bundled as a companion of the primary cuprate
|
// Cuprate's dashboard is bundled as a companion of the primary cuprate
|
||||||
// package; showing the generated container as a second Services entry
|
// package; showing the generated container as a second Services entry
|
||||||
// defeats the one-app presentation.
|
// defeats the one-app presentation.
|
||||||
|
|||||||
@@ -369,10 +369,6 @@ init()
|
|||||||
<span class="text-xs text-white/40">September 30, 2026</span>
|
<span class="text-xs text-white/40">September 30, 2026</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
<p>Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.</p>
|
|
||||||
<p>Network diagnostic failures no longer stop all apps or rebuild shared container networking.</p>
|
|
||||||
<p>Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.</p>
|
|
||||||
<p>Fixed companion dashboard builds still referencing a retired image registry.</p>
|
|
||||||
<p>Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.</p>
|
<p>Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.</p>
|
||||||
<p>Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.</p>
|
<p>Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.</p>
|
||||||
<p>Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.</p>
|
<p>Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.</p>
|
||||||
|
|||||||
+17
-33
@@ -1,46 +1,30 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.",
|
"Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
|
||||||
"Network diagnostic failures no longer stop all apps or rebuild shared container networking.",
|
"Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
|
||||||
"Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.",
|
"Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
|
||||||
"Fixed companion dashboard builds still referencing a retired image registry.",
|
"Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
|
||||||
"Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.",
|
|
||||||
"Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.",
|
|
||||||
"Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.",
|
|
||||||
"Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync.",
|
|
||||||
"Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps.",
|
|
||||||
"Finished runtime app-file promotion before manifest loading, preventing startup catalog refresh from forgetting disk-only apps.",
|
|
||||||
"Named the app in compact readiness messages and kept app-card actions aligned at the bottom.",
|
|
||||||
"Removed duplicate Mempool cards caused by frontend container aliases in restored inventory.",
|
|
||||||
"Kept installed apps visible through restarts and hard refreshes, and delayed app launches until their web interface is ready.",
|
|
||||||
"Made Bitcoin version selection readable and usable in the ThinkPad kiosk, above the pruning settings.",
|
|
||||||
"Restored GitWorkshop build files in installation/update payloads and made slow image-pull progress clearer.",
|
|
||||||
"Fixed same-node Gitea access from Portainer, with persistent runtime migration, state backups and recovery after failed restarts.",
|
|
||||||
"Preserved Gitea configuration and SSH operation during fresh setup and upgrades.",
|
|
||||||
"Improved paid-file delivery, saved-file permissions and repeat-download compatibility; verified Tor-only payment with change, rejection refunds and free repeat downloads.",
|
|
||||||
"Added a headless Angor Indexer service using the existing Mempool/ElectrumX stack, and an optional separate Angor relay.",
|
|
||||||
"Prevented manifest command arguments containing apostrophes from being corrupted in generated services."
|
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.8.22-alpha",
|
"current_version": "1.8.21-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.8.22-alpha",
|
"new_version": "1.8.21-alpha",
|
||||||
"sha256": "e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b",
|
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
|
||||||
"size_bytes": 65627704
|
"size_bytes": 64748176
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.8.22-alpha",
|
"current_version": "1.8.21-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-frontend-1.8.22-alpha.tar.gz",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||||
"name": "archipelago-frontend-1.8.22-alpha.tar.gz",
|
"name": "archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||||
"new_version": "1.8.22-alpha",
|
"new_version": "1.8.21-alpha",
|
||||||
"sha256": "2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41",
|
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
|
||||||
"size_bytes": 98131119
|
"size_bytes": 97152546
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-09-30",
|
"release_date": "2026-09-30",
|
||||||
"signature": "34e9e3902d5960c977b528c4edbb4366ad862f761076755632296840604c8a84ae1bbb503d8d26b2fe7622ca1eccfaa15cb8d23935bcec6dbecdaf6c53f7f50e",
|
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||||
"version": "1.8.22-alpha"
|
"version": "1.8.21-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-437
@@ -141,198 +141,6 @@
|
|||||||
},
|
},
|
||||||
"version": "1.23.0"
|
"version": "1.23.0"
|
||||||
},
|
},
|
||||||
"angor-indexer": {
|
|
||||||
"manifest": {
|
|
||||||
"app": {
|
|
||||||
"bitcoin_integration": {
|
|
||||||
"pruning_support": false,
|
|
||||||
"rpc_access": "none",
|
|
||||||
"sync_required": true
|
|
||||||
},
|
|
||||||
"category": "money",
|
|
||||||
"container": {
|
|
||||||
"image": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1",
|
|
||||||
"network": "archy-net",
|
|
||||||
"pull_policy": "if-not-present"
|
|
||||||
},
|
|
||||||
"dependencies": [
|
|
||||||
{
|
|
||||||
"app_id": "mempool-api",
|
|
||||||
"version": ">=3.0.0"
|
|
||||||
},
|
|
||||||
"bitcoin:archival"
|
|
||||||
],
|
|
||||||
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
|
|
||||||
"health_check": {
|
|
||||||
"endpoint": "http://127.0.0.1:8080",
|
|
||||||
"interval": "30s",
|
|
||||||
"path": "/health",
|
|
||||||
"retries": 3,
|
|
||||||
"timeout": "8s",
|
|
||||||
"type": "http"
|
|
||||||
},
|
|
||||||
"id": "angor-indexer",
|
|
||||||
"install_prerequisites": [
|
|
||||||
"mempool-api"
|
|
||||||
],
|
|
||||||
"interfaces": {
|
|
||||||
"main": {
|
|
||||||
"description": "Use this origin as Angor’s custom mainnet indexer URL. HTTPS is required for browser clients.",
|
|
||||||
"name": "Angor Indexer API",
|
|
||||||
"path": "/",
|
|
||||||
"port": 8998,
|
|
||||||
"protocol": "http",
|
|
||||||
"type": "api"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"features": [
|
|
||||||
"Angor mainnet API",
|
|
||||||
"Reuses existing Mempool indexing",
|
|
||||||
"No separate blockchain database",
|
|
||||||
"Optional independent relay"
|
|
||||||
],
|
|
||||||
"icon": "/assets/img/app-icons/angor-green.png",
|
|
||||||
"repo": "https://github.com/block-core/angor",
|
|
||||||
"tier": "optional"
|
|
||||||
},
|
|
||||||
"name": "Angor Indexer",
|
|
||||||
"ports": [
|
|
||||||
{
|
|
||||||
"auth": "open",
|
|
||||||
"auth_rationale": "Public Bitcoin chain-data API and validated transaction broadcast for Angor clients; no wallet keys or node RPC credentials are exposed. Browser cookie login would break machine clients.",
|
|
||||||
"bind": "127.0.0.1",
|
|
||||||
"container": 8080,
|
|
||||||
"host": 8998,
|
|
||||||
"protocol": "tcp"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"resources": {
|
|
||||||
"cpu_limit": 1,
|
|
||||||
"disk_limit": "128Mi",
|
|
||||||
"memory_limit": "128Mi"
|
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"capabilities": [],
|
|
||||||
"network_policy": "isolated",
|
|
||||||
"no_new_privileges": true,
|
|
||||||
"readonly_root": true,
|
|
||||||
"user": 101
|
|
||||||
},
|
|
||||||
"upstream": {
|
|
||||||
"kind": "github",
|
|
||||||
"repo": "block-core/angor"
|
|
||||||
},
|
|
||||||
"version": "1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": "1.0.1"
|
|
||||||
},
|
|
||||||
"angor-relay": {
|
|
||||||
"manifest": {
|
|
||||||
"app": {
|
|
||||||
"category": "nostr",
|
|
||||||
"container": {
|
|
||||||
"image": "source.archipelago-foundation.org/chaum/angor-relay:1.1.2",
|
|
||||||
"pull_policy": "if-not-present"
|
|
||||||
},
|
|
||||||
"dependencies": [
|
|
||||||
{
|
|
||||||
"storage": "5Gi"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Optional dedicated Nostr relay for Angor project metadata. Separate storage and access settings keep the node’s internal relay private. Add this service’s address to Angor’s relay settings; use WSS for browser clients.",
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"content": "##\n## Default strfry config\n##\n\n# Directory that contains the strfry LMDB database (restart required)\ndb = \"./strfry-db/\"\n\ndbParams {\n # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)\n maxreaders = 256\n\n # Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required)\n mapsize = 10995116277760\n\n # Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required)\n noReadAhead = false\n}\n\nevents {\n # Maximum size of normalised JSON, in bytes\n maxEventSize = 65536\n\n # Events newer than this will be rejected\n rejectEventsNewerThanSeconds = 900\n\n # Events older than this will be rejected\n rejectEventsOlderThanSeconds = 94608000\n\n # Ephemeral events older than this will be rejected\n rejectEphemeralEventsOlderThanSeconds = 60\n\n # Ephemeral events will be deleted from the DB when older than this\n ephemeralEventsLifetimeSeconds = 300\n\n # Maximum number of tags allowed\n maxNumTags = 2000\n\n # Maximum size for tag values, in bytes\n maxTagValSize = 1024\n}\n\nrelay {\n # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required)\n bind = \"0.0.0.0\"\n\n # Port to open for the nostr websocket protocol (restart required)\n port = 7777\n\n # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)\n nofiles = 0\n\n # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)\n realIpHeader = \"\"\n\n info {\n # NIP-11: Name of this server. Short/descriptive (< 30 characters)\n name = \"Angor Relay\"\n\n # NIP-11: Detailed information about relay, free-form\n description = \"Dedicated public relay for Angor project metadata.\"\n\n # NIP-11: Administrative nostr pubkey, for contact purposes\n pubkey = \"\"\n\n # NIP-11: Alternative administrative contact (email, website, etc)\n contact = \"\"\n\n # NIP-11: URL pointing to an image to be used as an icon for the relay\n icon = \"\"\n\n # List of supported lists as JSON array, or empty string to use default. Example: \"[1,2]\"\n nips = \"\"\n }\n\n # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)\n maxWebsocketPayloadSize = 131072\n\n # Maximum number of filters allowed in a REQ\n maxReqFilterSize = 200\n\n # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required)\n autoPingSeconds = 55\n\n # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy)\n enableTcpKeepalive = false\n\n # How much uninterrupted CPU time a REQ query should get during its DB scan\n queryTimesliceBudgetMicroseconds = 10000\n\n # Maximum records that can be returned per filter\n maxFilterLimit = 500\n\n # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time\n maxSubsPerConnection = 20\n\n writePolicy {\n # If non-empty, path to an executable script that implements the writePolicy plugin logic\n plugin = \"\"\n }\n\n compression {\n # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required)\n enabled = true\n\n # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required)\n slidingWindow = true\n }\n\n logging {\n # Dump all incoming messages\n dumpInAll = false\n\n # Dump all incoming EVENT messages\n dumpInEvents = false\n\n # Dump all incoming REQ/CLOSE messages\n dumpInReqs = false\n\n # Log performance metrics for initial REQ database scans\n dbScanPerf = false\n\n # Log reason for invalid event rejection? Can be disabled to silence excessive logging\n invalidEvents = true\n }\n\n numThreads {\n # Ingester threads: route incoming requests, validate events/sigs (restart required)\n ingester = 3\n\n # reqWorker threads: Handle initial DB scan for events (restart required)\n reqWorker = 3\n\n # reqMonitor threads: Handle filtering of new events (restart required)\n reqMonitor = 3\n\n # negentropy threads: Handle negentropy protocol messages (restart required)\n negentropy = 2\n }\n\n negentropy {\n # Support negentropy protocol messages\n enabled = true\n\n # Maximum records that sync will process before returning an error\n maxSyncEvents = 1000000\n }\n}\n",
|
|
||||||
"overwrite": false,
|
|
||||||
"path": "/var/lib/archipelago/angor-relay-config/angor-relay.conf"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"health_check": {
|
|
||||||
"endpoint": "http://127.0.0.1:7777",
|
|
||||||
"interval": "30s",
|
|
||||||
"path": "/health",
|
|
||||||
"retries": 3,
|
|
||||||
"timeout": "5s",
|
|
||||||
"type": "http"
|
|
||||||
},
|
|
||||||
"id": "angor-relay",
|
|
||||||
"interfaces": {
|
|
||||||
"main": {
|
|
||||||
"description": "Nostr WebSocket endpoint; use ws:// for LAN or wss:// through your HTTPS domain.",
|
|
||||||
"name": "Angor Relay",
|
|
||||||
"path": "/",
|
|
||||||
"port": 8091,
|
|
||||||
"protocol": "http",
|
|
||||||
"type": "api"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"features": [
|
|
||||||
"Angor project metadata",
|
|
||||||
"Separate from the node relay",
|
|
||||||
"Persistent Nostr event storage"
|
|
||||||
],
|
|
||||||
"icon": "/assets/img/app-icons/angor-green.png",
|
|
||||||
"repo": "https://github.com/hoytech/strfry",
|
|
||||||
"tier": "optional"
|
|
||||||
},
|
|
||||||
"name": "Angor Relay",
|
|
||||||
"nostr_integration": {
|
|
||||||
"monetization_enabled": false,
|
|
||||||
"relay_type": "public"
|
|
||||||
},
|
|
||||||
"ports": [
|
|
||||||
{
|
|
||||||
"auth": "open",
|
|
||||||
"auth_rationale": "Dedicated public Nostr relay for Angor project metadata; strfry verifies event signatures. It has separate storage from the private node relay and no wallet or node credentials.",
|
|
||||||
"bind": "127.0.0.1",
|
|
||||||
"container": 7777,
|
|
||||||
"host": 8091,
|
|
||||||
"protocol": "tcp"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"resources": {
|
|
||||||
"cpu_limit": 1,
|
|
||||||
"disk_limit": "5Gi",
|
|
||||||
"memory_limit": "512Mi"
|
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"apparmor_profile": "nostr-relay",
|
|
||||||
"capabilities": [],
|
|
||||||
"network_policy": "isolated",
|
|
||||||
"no_new_privileges": true,
|
|
||||||
"readonly_root": true,
|
|
||||||
"seccomp_profile": "default"
|
|
||||||
},
|
|
||||||
"upstream": {
|
|
||||||
"kind": "github",
|
|
||||||
"repo": "hoytech/strfry"
|
|
||||||
},
|
|
||||||
"version": "1.1.2",
|
|
||||||
"volumes": [
|
|
||||||
{
|
|
||||||
"options": [
|
|
||||||
"rw"
|
|
||||||
],
|
|
||||||
"source": "/var/lib/archipelago/angor-relay",
|
|
||||||
"target": "/app/strfry-db",
|
|
||||||
"type": "bind"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"options": [
|
|
||||||
"ro"
|
|
||||||
],
|
|
||||||
"source": "/var/lib/archipelago/angor-relay-config/angor-relay.conf",
|
|
||||||
"target": "/etc/strfry.conf",
|
|
||||||
"type": "bind"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"version": "1.1.2"
|
|
||||||
},
|
|
||||||
"archipelago-source": {
|
"archipelago-source": {
|
||||||
"manifest": {
|
"manifest": {
|
||||||
"app": {
|
"app": {
|
||||||
@@ -2387,142 +2195,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"manifest_variants": [
|
|
||||||
{
|
|
||||||
"manifest": {
|
|
||||||
"app": {
|
|
||||||
"backup_before_runtime_change": true,
|
|
||||||
"category": "development",
|
|
||||||
"container": {
|
|
||||||
"image": "source.archipelago-foundation.org/lfg2025/gitea:1.27.3",
|
|
||||||
"pull_policy": "if-not-present"
|
|
||||||
},
|
|
||||||
"dependencies": [
|
|
||||||
{
|
|
||||||
"storage": "50Gi"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
|
|
||||||
"environment": [
|
|
||||||
"GITEA__database__DB_TYPE=sqlite3",
|
|
||||||
"GITEA__server__SSH_PORT=2222",
|
|
||||||
"GITEA__server__SSH_LISTEN_PORT=22",
|
|
||||||
"GITEA__server__LFS_START_SERVER=true",
|
|
||||||
"GITEA__packages__ENABLED=true",
|
|
||||||
"GITEA__packages__LIMIT_TOTAL_OWNER_SIZE=-1",
|
|
||||||
"GITEA__packages__LIMIT_SIZE_CONTAINER=-1",
|
|
||||||
"GITEA__repository_0x2Erelease__FILE_MAX_SIZE=10240",
|
|
||||||
"GITEA__repository_0x2Erelease__MAX_FILES=20",
|
|
||||||
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
|
|
||||||
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true"
|
|
||||||
],
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"content": "[server]\nDOMAIN = {{HOST_IP}}\nSSH_DOMAIN = {{HOST_IP}}\nROOT_URL = http://{{HOST_IP}}:3001/\n",
|
|
||||||
"overwrite": false,
|
|
||||||
"path": "/var/lib/archipelago/gitea/data/gitea/conf/app.ini"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"health_check": {
|
|
||||||
"endpoint": "http://localhost:3000",
|
|
||||||
"interval": "120s",
|
|
||||||
"path": "/",
|
|
||||||
"retries": 5,
|
|
||||||
"timeout": "30s",
|
|
||||||
"type": "http"
|
|
||||||
},
|
|
||||||
"id": "gitea",
|
|
||||||
"interfaces": {
|
|
||||||
"main": {
|
|
||||||
"description": "Gitea web interface",
|
|
||||||
"name": "Web UI",
|
|
||||||
"path": "/",
|
|
||||||
"port": 3001,
|
|
||||||
"protocol": "http",
|
|
||||||
"type": "ui"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"features": [
|
|
||||||
"Git repositories with web UI",
|
|
||||||
"Built-in container/package registry",
|
|
||||||
"Issue tracking and pull requests",
|
|
||||||
"CI/CD via Gitea Actions",
|
|
||||||
"Lightweight SQLite deployment"
|
|
||||||
],
|
|
||||||
"icon": "/assets/img/app-icons/gitea.svg",
|
|
||||||
"launch": {
|
|
||||||
"open_in_new_tab": true
|
|
||||||
},
|
|
||||||
"repo": "https://gitea.com",
|
|
||||||
"tier": "optional"
|
|
||||||
},
|
|
||||||
"name": "Gitea",
|
|
||||||
"ports": [
|
|
||||||
{
|
|
||||||
"auth": "open",
|
|
||||||
"auth_rationale": "Gitea enforces its own account login on every page and API route; git clients authenticate with basic-auth/tokens and cannot complete a browser login challenge.",
|
|
||||||
"bind": "127.0.0.1",
|
|
||||||
"container": 3000,
|
|
||||||
"host": 3001,
|
|
||||||
"protocol": "tcp"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"auth": "none",
|
|
||||||
"auth_rationale": "Git over SSH, authenticated by the user's own SSH keypair. Not HTTP, so the gate cannot serve a login page here.",
|
|
||||||
"container": 22,
|
|
||||||
"host": 2222,
|
|
||||||
"protocol": "tcp"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"resources": {
|
|
||||||
"disk_limit": "50Gi",
|
|
||||||
"memory_limit": "256Mi"
|
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"capabilities": [
|
|
||||||
"CHOWN",
|
|
||||||
"FOWNER",
|
|
||||||
"SETUID",
|
|
||||||
"SETGID",
|
|
||||||
"DAC_OVERRIDE",
|
|
||||||
"NET_BIND_SERVICE",
|
|
||||||
"SYS_CHROOT"
|
|
||||||
],
|
|
||||||
"network_policy": "bridge",
|
|
||||||
"no_new_privileges": false,
|
|
||||||
"readonly_root": false
|
|
||||||
},
|
|
||||||
"upstream": {
|
|
||||||
"kind": "github",
|
|
||||||
"repo": "go-gitea/gitea"
|
|
||||||
},
|
|
||||||
"version": "1.27.3",
|
|
||||||
"volumes": [
|
|
||||||
{
|
|
||||||
"options": [
|
|
||||||
"rw"
|
|
||||||
],
|
|
||||||
"source": "/var/lib/archipelago/gitea/data",
|
|
||||||
"target": "/data",
|
|
||||||
"type": "bind"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"options": [
|
|
||||||
"rw"
|
|
||||||
],
|
|
||||||
"source": "/var/lib/archipelago/gitea/config",
|
|
||||||
"target": "/etc/gitea",
|
|
||||||
"type": "bind"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"requires": [
|
|
||||||
"runtime-migration-backup-v1"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version": "1.27.3"
|
"version": "1.27.3"
|
||||||
},
|
},
|
||||||
"grafana": {
|
"grafana": {
|
||||||
@@ -4419,11 +4091,11 @@
|
|||||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
|
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
|
||||||
"environment": [],
|
"environment": [],
|
||||||
"health_check": {
|
"health_check": {
|
||||||
"endpoint": "http://127.0.0.1:81/api/",
|
"endpoint": "localhost:81",
|
||||||
"interval": "30s",
|
"interval": "30s",
|
||||||
"retries": 3,
|
"retries": 3,
|
||||||
"timeout": "5s",
|
"timeout": "5s",
|
||||||
"type": "http"
|
"type": "tcp"
|
||||||
},
|
},
|
||||||
"id": "nginx-proxy-manager",
|
"id": "nginx-proxy-manager",
|
||||||
"interfaces": {
|
"interfaces": {
|
||||||
@@ -5324,111 +4996,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"manifest_variants": [
|
|
||||||
{
|
|
||||||
"manifest": {
|
|
||||||
"app": {
|
|
||||||
"backup_before_runtime_change": true,
|
|
||||||
"category": "development",
|
|
||||||
"container": {
|
|
||||||
"data_uid": "1000:1000",
|
|
||||||
"image": "source.archipelago-foundation.org/lfg2025/portainer:2.45.0",
|
|
||||||
"network": "slirp4netns",
|
|
||||||
"pull_policy": "if-not-present"
|
|
||||||
},
|
|
||||||
"dependencies": [
|
|
||||||
{
|
|
||||||
"storage": "1Gi"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Container management web UI for the local Podman socket.",
|
|
||||||
"environment": [],
|
|
||||||
"id": "portainer",
|
|
||||||
"interfaces": {
|
|
||||||
"main": {
|
|
||||||
"description": "Portainer web interface",
|
|
||||||
"name": "Web UI",
|
|
||||||
"path": "/",
|
|
||||||
"port": 9000,
|
|
||||||
"protocol": "http",
|
|
||||||
"type": "ui"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"features": [
|
|
||||||
"Container management dashboard",
|
|
||||||
"Local Podman socket access",
|
|
||||||
"Compose stack storage"
|
|
||||||
],
|
|
||||||
"icon": "/assets/img/app-icons/portainer.webp",
|
|
||||||
"launch": {
|
|
||||||
"open_in_new_tab": true
|
|
||||||
},
|
|
||||||
"tier": "optional"
|
|
||||||
},
|
|
||||||
"name": "Portainer",
|
|
||||||
"ports": [
|
|
||||||
{
|
|
||||||
"auth": "gated",
|
|
||||||
"bind": "127.0.0.1",
|
|
||||||
"container": 9000,
|
|
||||||
"host": 9000,
|
|
||||||
"protocol": "tcp"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"resources": {
|
|
||||||
"disk_limit": "1Gi",
|
|
||||||
"memory_limit": "256Mi"
|
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"capabilities": [
|
|
||||||
"CHOWN",
|
|
||||||
"SETUID",
|
|
||||||
"SETGID",
|
|
||||||
"DAC_OVERRIDE"
|
|
||||||
],
|
|
||||||
"network_policy": "isolated",
|
|
||||||
"no_new_privileges": true,
|
|
||||||
"readonly_root": false
|
|
||||||
},
|
|
||||||
"upstream": {
|
|
||||||
"kind": "github",
|
|
||||||
"repo": "portainer/portainer"
|
|
||||||
},
|
|
||||||
"version": "2.45.0",
|
|
||||||
"volumes": [
|
|
||||||
{
|
|
||||||
"options": [
|
|
||||||
"rw"
|
|
||||||
],
|
|
||||||
"source": "/var/lib/archipelago/portainer",
|
|
||||||
"target": "/data",
|
|
||||||
"type": "bind"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"options": [
|
|
||||||
"rw"
|
|
||||||
],
|
|
||||||
"source": "/var/lib/archipelago/portainer/compose",
|
|
||||||
"target": "/data/compose",
|
|
||||||
"type": "bind"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"options": [
|
|
||||||
"rw"
|
|
||||||
],
|
|
||||||
"source": "/run/user/1000/podman/podman.sock",
|
|
||||||
"target": "/var/run/docker.sock",
|
|
||||||
"type": "bind"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"requires": [
|
|
||||||
"runtime-migration-backup-v1"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version": "2.45.0"
|
"version": "2.45.0"
|
||||||
},
|
},
|
||||||
"router": {
|
"router": {
|
||||||
@@ -5958,7 +5525,7 @@
|
|||||||
"tag": "NOSTR IDENTITY // YOUR NODE"
|
"tag": "NOSTR IDENTITY // YOUR NODE"
|
||||||
},
|
},
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"signature": "66b78a5bc60992222b01ae901c5f4a40802667332a5ae47bdad7f34e149669261012ff6fd543478a4f318e850b0339be497af71a50266d829395a872ad386704",
|
"signature": "bbcc938b855c1cb5d803e4510e1aac3259fbf3eabf6f36294c7773634047a3d5edb5b37a17d01d62d1407e5701c62853e15e20e15cc7f486b8975b22eeb94c07",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||||
"storefront": {
|
"storefront": {
|
||||||
"popular": [
|
"popular": [
|
||||||
@@ -5984,5 +5551,5 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"updated": "2026-09-30"
|
"updated": "2026-09-29"
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-33
@@ -1,46 +1,30 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.",
|
"Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
|
||||||
"Network diagnostic failures no longer stop all apps or rebuild shared container networking.",
|
"Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
|
||||||
"Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.",
|
"Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
|
||||||
"Fixed companion dashboard builds still referencing a retired image registry.",
|
"Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
|
||||||
"Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.",
|
|
||||||
"Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.",
|
|
||||||
"Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.",
|
|
||||||
"Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync.",
|
|
||||||
"Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps.",
|
|
||||||
"Finished runtime app-file promotion before manifest loading, preventing startup catalog refresh from forgetting disk-only apps.",
|
|
||||||
"Named the app in compact readiness messages and kept app-card actions aligned at the bottom.",
|
|
||||||
"Removed duplicate Mempool cards caused by frontend container aliases in restored inventory.",
|
|
||||||
"Kept installed apps visible through restarts and hard refreshes, and delayed app launches until their web interface is ready.",
|
|
||||||
"Made Bitcoin version selection readable and usable in the ThinkPad kiosk, above the pruning settings.",
|
|
||||||
"Restored GitWorkshop build files in installation/update payloads and made slow image-pull progress clearer.",
|
|
||||||
"Fixed same-node Gitea access from Portainer, with persistent runtime migration, state backups and recovery after failed restarts.",
|
|
||||||
"Preserved Gitea configuration and SSH operation during fresh setup and upgrades.",
|
|
||||||
"Improved paid-file delivery, saved-file permissions and repeat-download compatibility; verified Tor-only payment with change, rejection refunds and free repeat downloads.",
|
|
||||||
"Added a headless Angor Indexer service using the existing Mempool/ElectrumX stack, and an optional separate Angor relay.",
|
|
||||||
"Prevented manifest command arguments containing apostrophes from being corrupted in generated services."
|
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.8.22-alpha",
|
"current_version": "1.8.21-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.8.22-alpha",
|
"new_version": "1.8.21-alpha",
|
||||||
"sha256": "e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b",
|
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
|
||||||
"size_bytes": 65627704
|
"size_bytes": 64748176
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.8.22-alpha",
|
"current_version": "1.8.21-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-frontend-1.8.22-alpha.tar.gz",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||||
"name": "archipelago-frontend-1.8.22-alpha.tar.gz",
|
"name": "archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||||
"new_version": "1.8.22-alpha",
|
"new_version": "1.8.21-alpha",
|
||||||
"sha256": "2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41",
|
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
|
||||||
"size_bytes": 98131119
|
"size_bytes": 97152546
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-09-30",
|
"release_date": "2026-09-30",
|
||||||
"signature": "34e9e3902d5960c977b528c4edbb4366ad862f761076755632296840604c8a84ae1bbb503d8d26b2fe7622ca1eccfaa15cb8d23935bcec6dbecdaf6c53f7f50e",
|
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||||
"version": "1.8.22-alpha"
|
"version": "1.8.21-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ def check(root: Path) -> int:
|
|||||||
dockerfile = (context / build.get('dockerfile', 'Dockerfile')).resolve()
|
dockerfile = (context / build.get('dockerfile', 'Dockerfile')).resolve()
|
||||||
if not dockerfile.is_relative_to(context) or not dockerfile.is_file():
|
if not dockerfile.is_relative_to(context) or not dockerfile.is_file():
|
||||||
raise ValueError(f'{app["id"]}: missing or out-of-context Dockerfile: {dockerfile}')
|
raise ValueError(f'{app["id"]}: missing or out-of-context Dockerfile: {dockerfile}')
|
||||||
if 'git.tx1138.com/' in dockerfile.read_text():
|
|
||||||
raise ValueError(f'{app["id"]}: Dockerfile references retired registry git.tx1138.com')
|
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|||||||
+102
-31
@@ -32,8 +32,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
|
|
||||||
FIXES_APPLIED=0
|
FIXES_APPLIED=0
|
||||||
CHECKS_PASSED=0
|
CHECKS_PASSED=0
|
||||||
CHECKS_WARNED=0
|
|
||||||
WARNING_NAMES=()
|
|
||||||
FIX_NAMES=()
|
FIX_NAMES=()
|
||||||
|
|
||||||
log() { echo "[$(date +%H:%M:%S)] DOCTOR: $*"; }
|
log() { echo "[$(date +%H:%M:%S)] DOCTOR: $*"; }
|
||||||
@@ -85,13 +83,7 @@ run_fix() {
|
|||||||
FIXES_APPLIED=$((FIXES_APPLIED + 1))
|
FIXES_APPLIED=$((FIXES_APPLIED + 1))
|
||||||
FIX_NAMES+=("$name")
|
FIX_NAMES+=("$name")
|
||||||
else
|
else
|
||||||
local status=$?
|
CHECKS_PASSED=$((CHECKS_PASSED + 1))
|
||||||
if [ "$status" = 1 ]; then
|
|
||||||
CHECKS_PASSED=$((CHECKS_PASSED + 1))
|
|
||||||
else
|
|
||||||
CHECKS_WARNED=$((CHECKS_WARNED + 1))
|
|
||||||
WARNING_NAMES+=("$name")
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,33 +446,114 @@ print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]]))
|
|||||||
[ ${#fixed_names[@]} -gt 0 ] && return 0 || return 1
|
[ ${#fixed_names[@]} -gt 0 ] && return 0 || return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Check 8: Rootless network egress (diagnostic only) ──────
|
# ── Fix 8: Rootless netns egress lost ────────────────────────
|
||||||
# A single external endpoint or nsenter failure cannot establish that the
|
# Rootless podman uses pasta to give containers internet egress. If pasta's
|
||||||
# containers have lost connectivity. In particular, entering only the network
|
# tap vanishes (host link flap, mount churn, pasta dying during a boot-time
|
||||||
# namespace can fail for rootless user namespaces. Never stop apps, kill network
|
# restart storm), the rootless-netns keeps inter-container traffic working
|
||||||
# helpers, migrate Podman, or remove network state in response to this probe.
|
# but silently loses outbound. Bitcoin IBD stalls at 0 peers; package pulls
|
||||||
# Return 1 for healthy/not applicable and 2 for an inconclusive warning.
|
# fail. The repair must rebuild the netns from scratch: merely cycling the
|
||||||
check_rootless_netns_egress() {
|
# containers reuses the existing (broken) netns because its holders
|
||||||
|
# (aardvark-dns, podman's pause process) survive — observed on a test node
|
||||||
|
# 2026-07-10, where the old stop/start-only cycle bounced all 35 containers
|
||||||
|
# every timer run for ~an hour without ever restoring egress. So: stop the
|
||||||
|
# containers, kill the netns holders, `podman system migrate`, clear the
|
||||||
|
# stale netns state, then start everything back up.
|
||||||
|
#
|
||||||
|
# Destructive-action latch: cycling the whole fleet is a last resort. After
|
||||||
|
# NETNS_CYCLE_MAX consecutive failed repairs we stop cycling (and log loudly)
|
||||||
|
# until a run observes egress healthy again, which resets the counter.
|
||||||
|
NETNS_CYCLE_STATE="/var/lib/archipelago/doctor-netns-cycle-failures"
|
||||||
|
NETNS_CYCLE_MAX=3
|
||||||
|
fix_rootless_netns_egress() {
|
||||||
|
# Needs root for nsenter. When doctor runs as the rootless container owner,
|
||||||
|
# a failed nsenter probe is a permissions artifact, not evidence of broken
|
||||||
|
# egress; do not cycle the fleet from that context.
|
||||||
[ "$(id -u)" = "0" ] || return 1
|
[ "$(id -u)" = "0" ] || return 1
|
||||||
local archi_uid aardvark_pid
|
|
||||||
archi_uid=$(id -u archipelago 2>/dev/null) || return 1
|
|
||||||
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
|
||||||
[ -n "$aardvark_pid" ] || return 1
|
|
||||||
|
|
||||||
|
local archi_uid
|
||||||
|
archi_uid=$(id -u archipelago 2>/dev/null) || return 1
|
||||||
|
|
||||||
|
# Locate the rootless-netns via aardvark-dns (it lives inside it).
|
||||||
|
local aardvark_pid
|
||||||
|
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
||||||
|
[ -z "$aardvark_pid" ] && return 1 # no rootless network active
|
||||||
|
|
||||||
|
# Host precheck: if the host itself can't reach the internet, no point
|
||||||
|
# cycling containers — this is an upstream problem.
|
||||||
if ! timeout 3 bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
if ! timeout 3 bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||||
log "WARNING: host connectivity probe failed; external endpoint may be unavailable. Apps left running."
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Probe egress from inside the rootless-netns. One probe is noisy;
|
||||||
|
# require two consecutive failures 10s apart to rule out transients.
|
||||||
|
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||||
|
rm -f "$NETNS_CYCLE_STATE" # healthy again — re-arm the latch
|
||||||
|
return 1 # first probe succeeded
|
||||||
|
fi
|
||||||
sleep 10
|
sleep 10
|
||||||
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
||||||
if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
[ -z "$aardvark_pid" ] && return 1
|
||||||
|
if timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||||
|
rm -f "$NETNS_CYCLE_STATE"
|
||||||
|
return 1 # recovered on its own
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Latch: don't keep bouncing the fleet when the rebuild demonstrably
|
||||||
|
# isn't fixing it.
|
||||||
|
local failures
|
||||||
|
failures=$(cat "$NETNS_CYCLE_STATE" 2>/dev/null || echo 0)
|
||||||
|
case "$failures" in *[!0-9]*|"") failures=0;; esac
|
||||||
|
if [ "$failures" -ge "$NETNS_CYCLE_MAX" ]; then
|
||||||
|
log "Rootless-netns egress still broken but $failures rebuilds already failed — NOT cycling again (manual intervention needed; rm $NETNS_CYCLE_STATE to re-arm)"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
log "WARNING: rootless network probe inconclusive (endpoint, connectivity, or namespace access). Inspect affected apps before repair. Apps left running."
|
|
||||||
return 2
|
log "Rootless-netns egress is broken (host online, container netns unreachable) — rebuilding netns"
|
||||||
|
|
||||||
|
local PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/$archi_uid podman"
|
||||||
|
local running
|
||||||
|
running=$($PODMANCMD ps --format '{{.Names}}' 2>/dev/null)
|
||||||
|
if [ -z "$running" ]; then
|
||||||
|
log " No running containers to cycle — skipping"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local count
|
||||||
|
count=$(echo "$running" | wc -l)
|
||||||
|
log " Stopping $count running containers (graceful, 30s)..."
|
||||||
|
$PODMANCMD stop --all --time 30 >/dev/null 2>&1
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Tear the broken netns down for real: kill its holders and drop the
|
||||||
|
# stale state so the first container start rebuilds pasta + aardvark-dns
|
||||||
|
# from scratch. Without this, podman re-enters the old netns and the
|
||||||
|
# missing pasta tap never comes back.
|
||||||
|
log " Rebuilding rootless netns (killing holders, clearing state)..."
|
||||||
|
pkill -U "$archi_uid" -x aardvark-dns 2>/dev/null
|
||||||
|
pkill -U "$archi_uid" -x pasta 2>/dev/null
|
||||||
|
pkill -U "$archi_uid" -x pasta.avx2 2>/dev/null
|
||||||
|
pkill -U "$archi_uid" -x slirp4netns 2>/dev/null
|
||||||
|
sleep 2
|
||||||
|
$PODMANCMD system migrate >/dev/null 2>&1
|
||||||
|
rm -rf "/run/user/$archi_uid/containers/networks"
|
||||||
|
|
||||||
|
log " Starting containers back up..."
|
||||||
|
for c in $running; do
|
||||||
|
$PODMANCMD start "$c" >/dev/null 2>&1 &
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
aardvark_pid=$(pgrep -U "$archi_uid" -f '^/usr/lib/podman/aardvark-dns' 2>/dev/null | head -1)
|
||||||
|
if [ -n "$aardvark_pid" ] && timeout 3 nsenter -t "$aardvark_pid" -n bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
|
||||||
|
log " Rootless-netns egress restored ($count containers cycled)"
|
||||||
|
rm -f "$NETNS_CYCLE_STATE"
|
||||||
|
else
|
||||||
|
failures=$((failures + 1))
|
||||||
|
echo "$failures" > "$NETNS_CYCLE_STATE"
|
||||||
|
log " WARN: egress still broken after rebuild (failure $failures/$NETNS_CYCLE_MAX) — may need manual intervention"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Fix 9: Restart stopped core containers ──────────────────
|
# ── Fix 9: Restart stopped core containers ──────────────────
|
||||||
@@ -658,7 +731,7 @@ run_fix "tor-permissions" fix_tor_permissions
|
|||||||
run_fix "searxng" fix_searxng
|
run_fix "searxng" fix_searxng
|
||||||
run_fix "bitcoin-txindex" fix_bitcoin_txindex
|
run_fix "bitcoin-txindex" fix_bitcoin_txindex
|
||||||
run_fix "exit-127" fix_exit_127
|
run_fix "exit-127" fix_exit_127
|
||||||
run_fix "netns-egress" check_rootless_netns_egress
|
run_fix "netns-egress" fix_rootless_netns_egress
|
||||||
run_fix "stopped-core" fix_stopped_core_containers
|
run_fix "stopped-core" fix_stopped_core_containers
|
||||||
run_fix "rootless-ports" fix_missing_rootless_ports
|
run_fix "rootless-ports" fix_missing_rootless_ports
|
||||||
run_fix "npm-public-hosts" fix_npm_public_hosts
|
run_fix "npm-public-hosts" fix_npm_public_hosts
|
||||||
@@ -667,9 +740,7 @@ run_fix "catatonit" fix_missing_catatonit
|
|||||||
run_fix "dialout" fix_archipelago_dialout
|
run_fix "dialout" fix_archipelago_dialout
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$CHECKS_WARNED" -gt 0 ]; then
|
if [ $FIXES_APPLIED -gt 0 ]; then
|
||||||
log "Done: $CHECKS_WARNED unresolved warnings (${WARNING_NAMES[*]}), $FIXES_APPLIED fixes applied, $CHECKS_PASSED checks passed"
|
|
||||||
elif [ $FIXES_APPLIED -gt 0 ]; then
|
|
||||||
log "Done: $FIXES_APPLIED fixes applied (${FIX_NAMES[*]}), $CHECKS_PASSED checks passed"
|
log "Done: $FIXES_APPLIED fixes applied (${FIX_NAMES[*]}), $CHECKS_PASSED checks passed"
|
||||||
else
|
else
|
||||||
log "Done: all $CHECKS_PASSED checks passed — no fixes needed"
|
log "Done: all $CHECKS_PASSED checks passed — no fixes needed"
|
||||||
|
|||||||
@@ -71,25 +71,6 @@ else
|
|||||||
bad "incomplete app build payload"
|
bad "incomplete app build payload"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The cached rootfs must never restore the unsafe historical doctor on boot.
|
|
||||||
for doctor_file in container-doctor.sh archipelago-doctor.service archipelago-doctor.timer; do
|
|
||||||
if [[ "$doctor_file" == container-doctor.sh ]]; then
|
|
||||||
doctor_source="$REPO/scripts/$doctor_file"
|
|
||||||
else
|
|
||||||
doctor_source="$REPO/image-recipe/configs/$doctor_file"
|
|
||||||
fi
|
|
||||||
if cmp -s "$doctor_source" "$MNT/archipelago/scripts/$doctor_file"; then
|
|
||||||
ok "current doctor payload: $doctor_file"
|
|
||||||
else
|
|
||||||
bad "missing/stale doctor overlay: $doctor_file"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if grep -Fq '# BEGIN DOCTOR OVERLAY' "$MNT/archipelago/auto-install.sh"; then
|
|
||||||
ok "installer replaces cached doctor before first boot"
|
|
||||||
else
|
|
||||||
bad "installer lacks cached doctor replacement"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── GRUB must boot the live system ───────────────────────────────────
|
# ── GRUB must boot the live system ───────────────────────────────────
|
||||||
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
|
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
|
||||||
ok "grub.cfg has boot=live"
|
ok "grub.cfg has boot=live"
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ class BuildPayloadTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, 'out-of-payload'):
|
with self.assertRaisesRegex(ValueError, 'out-of-payload'):
|
||||||
contexts.check(self.root)
|
contexts.check(self.root)
|
||||||
|
|
||||||
def test_retired_registry_rejected(self):
|
|
||||||
target = self.root / 'docker/lnd-ui/Dockerfile'
|
|
||||||
target.write_text('FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine\n')
|
|
||||||
with self.assertRaisesRegex(ValueError, 'lnd-ui.*retired registry'):
|
|
||||||
contexts.check(self.root)
|
|
||||||
|
|
||||||
def test_empty_payload_rejected(self):
|
def test_empty_payload_rejected(self):
|
||||||
shutil.rmtree(self.root / 'apps')
|
shutil.rmtree(self.root / 'apps')
|
||||||
with self.assertRaisesRegex(ValueError, 'No app manifests'):
|
with self.assertRaisesRegex(ValueError, 'No app manifests'):
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Simulate failures without namespaces, network access, or real repair commands.
|
|
||||||
set -euo pipefail
|
|
||||||
source "$(dirname "$0")/../../scripts/container-doctor.sh"
|
|
||||||
id() { if [[ "$*" == '-u archipelago' ]]; then echo 1000; else echo "${TEST_UID:-0}"; fi; }
|
|
||||||
pgrep() { if [[ "$HAS_NETWORK" == 1 ]]; then echo 123; else return 1; fi; }
|
|
||||||
sleep() { :; }
|
|
||||||
# Any mutation fails the test immediately, including within command substitution.
|
|
||||||
tripwire() { echo 'FAIL: diagnostic attempted a mutation' >&2; exit 99; }
|
|
||||||
podman() { tripwire; }
|
|
||||||
podman_rootless() { tripwire; }
|
|
||||||
sudo() { tripwire; }
|
|
||||||
systemctl() { tripwire; }
|
|
||||||
pkill() { tripwire; }
|
|
||||||
kill() { tripwire; }
|
|
||||||
rm() { tripwire; }
|
|
||||||
mkdir() { tripwire; }
|
|
||||||
timeout() {
|
|
||||||
if [[ "$2" == bash ]]; then return "$HOST_STATUS"; fi
|
|
||||||
[[ "$2" == nsenter ]] || exit 98
|
|
||||||
PROBES=$((PROBES + 1))
|
|
||||||
if [[ "$PROBES" == 1 ]]; then return "$FIRST_STATUS"; fi
|
|
||||||
return "$SECOND_STATUS"
|
|
||||||
}
|
|
||||||
check_case() {
|
|
||||||
local label=$1 expected=$2 expected_probes=$3
|
|
||||||
PROBES=0
|
|
||||||
local status=0
|
|
||||||
check_rootless_netns_egress > /dev/null || status=$?
|
|
||||||
[[ "$status" == "$expected" && "$PROBES" == "$expected_probes" ]] || {
|
|
||||||
echo "FAIL: $label status=$status probes=$PROBES"; exit 1;
|
|
||||||
}
|
|
||||||
echo "PASS: $label"
|
|
||||||
}
|
|
||||||
HAS_NETWORK=1 HOST_STATUS=0 FIRST_STATUS=0 SECOND_STATUS=0
|
|
||||||
check_case healthy 1 1
|
|
||||||
TEST_UID=1000 check_case rootless-caller 1 0
|
|
||||||
HAS_NETWORK=0 check_case no-network 1 0
|
|
||||||
HOST_STATUS=1 check_case host-offline 2 0
|
|
||||||
FIRST_STATUS=1 check_case transient-recovery 1 2
|
|
||||||
FIRST_STATUS=1 SECOND_STATUS=1 check_case repeated-egress-failure 2 2
|
|
||||||
FIRST_STATUS=126 SECOND_STATUS=126 check_case namespace-access-failure 2 2
|
|
||||||
# Failure must remain an unresolved warning on every scheduled invocation.
|
|
||||||
FIRST_STATUS=1 SECOND_STATUS=1
|
|
||||||
for attempt in 1 2 3 4 5; do
|
|
||||||
PROBES=0
|
|
||||||
run_fix netns-egress check_rootless_netns_egress > /dev/null
|
|
||||||
done
|
|
||||||
[[ "$CHECKS_WARNED" == 5 && "$FIXES_APPLIED" == 0 && "$CHECKS_PASSED" == 0 ]]
|
|
||||||
FIRST_STATUS=0 PROBES=0
|
|
||||||
run_fix netns-egress check_rootless_netns_egress > /dev/null
|
|
||||||
[[ "$CHECKS_PASSED" == 1 && "$FIXES_APPLIED" == 0 ]]
|
|
||||||
echo 'PASS: repeated failure warnings never trigger repair or report a successful check'
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Execute the installer's actual overlay against a stale disposable rootfs."""
|
|
||||||
import pathlib, subprocess, tempfile, shutil, unittest
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
|
||||||
class DoctorOverlayTests(unittest.TestCase):
|
|
||||||
def test_cached_rootfs_and_missing_payload(self):
|
|
||||||
source = (ROOT / 'image-recipe/_archived/build-auto-installer-iso.sh').read_text()
|
|
||||||
block = source.split('# BEGIN DOCTOR OVERLAY\n', 1)[1].split('# END DOCTOR OVERLAY', 1)[0]
|
|
||||||
with tempfile.TemporaryDirectory() as temp:
|
|
||||||
base = pathlib.Path(temp)
|
|
||||||
target = base / 'target'
|
|
||||||
media = base / 'media'
|
|
||||||
payload = media / 'archipelago/scripts'
|
|
||||||
payload.mkdir(parents=True)
|
|
||||||
units = target / 'etc/systemd/system'
|
|
||||||
units.mkdir(parents=True)
|
|
||||||
for directory in ['opt/archipelago/scripts', 'home/archipelago/archy/scripts']:
|
|
||||||
dest = target / directory
|
|
||||||
dest.mkdir(parents=True)
|
|
||||||
(dest / 'container-doctor.sh').write_text('UNSAFE OLD SCRIPT')
|
|
||||||
files = [ROOT / 'scripts/container-doctor.sh',
|
|
||||||
ROOT / 'image-recipe/configs/archipelago-doctor.service',
|
|
||||||
ROOT / 'image-recipe/configs/archipelago-doctor.timer']
|
|
||||||
for path in files:
|
|
||||||
shutil.copyfile(path, payload / path.name)
|
|
||||||
script = block.replace('/mnt/target', str(target))
|
|
||||||
for _ in range(2):
|
|
||||||
subprocess.run(['bash', '-c', script], env={'BOOT_MEDIA': str(media), 'PATH': '/usr/bin:/bin'}, check=True)
|
|
||||||
for directory in ['opt/archipelago/scripts', 'home/archipelago/archy/scripts']:
|
|
||||||
dest = target / directory / 'container-doctor.sh'
|
|
||||||
self.assertEqual(dest.read_bytes(), files[0].read_bytes())
|
|
||||||
self.assertEqual(dest.stat().st_mode & 0o777, 0o755)
|
|
||||||
for path in files[1:]:
|
|
||||||
self.assertEqual((units / path.name).read_bytes(), path.read_bytes())
|
|
||||||
(payload / 'container-doctor.sh').unlink()
|
|
||||||
failed = subprocess.run(['bash', '-c', script], env={'BOOT_MEDIA': str(media), 'PATH': '/usr/bin:/bin'}, capture_output=True)
|
|
||||||
self.assertNotEqual(failed.returncode, 0, 'Missing safety overlay must fail installation')
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -74,8 +74,6 @@ stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml -
|
|||||||
stage "app-build-contexts" python3 tests/regression/app-build-contexts.py
|
stage "app-build-contexts" python3 tests/regression/app-build-contexts.py
|
||||||
stage "manifest-shell" python3 scripts/check-manifest-shell.py
|
stage "manifest-shell" python3 scripts/check-manifest-shell.py
|
||||||
stage "npm-tunnel-migration" python3 -m unittest discover -s scripts/tests -p test_repair_npm_tunnel.py
|
stage "npm-tunnel-migration" python3 -m unittest discover -s scripts/tests -p test_repair_npm_tunnel.py
|
||||||
stage "iso-doctor-overlay" python3 tests/regression/iso-doctor-overlay.py
|
|
||||||
stage "doctor-egress" bash tests/regression/container-doctor-egress.sh
|
|
||||||
stage "doctor-ports" bash tests/regression/container-doctor-ports.sh
|
stage "doctor-ports" bash tests/regression/container-doctor-ports.sh
|
||||||
stage "bitcoin-pruning" python3 tests/regression/bitcoin-prune-entrypoint.py
|
stage "bitcoin-pruning" python3 tests/regression/bitcoin-prune-entrypoint.py
|
||||||
stage "lnd-ui-readiness" node --test tests/regression/lnd-ui-readiness.cjs
|
stage "lnd-ui-readiness" node --test tests/regression/lnd-ui-readiness.cjs
|
||||||
|
|||||||
Reference in New Issue
Block a user