Merge pull request 'Release merge: btcpay plugins/LND + doctor netns fix + tx1138 purge + kiosk audio + cashu v4 + tollgate' (#73) from release/merge-all-20260710 into main
This commit is contained in:
commit
13c593b3c2
2
.gitmodules
vendored
2
.gitmodules
vendored
@ -1,3 +1,3 @@
|
|||||||
[submodule "indeedhub"]
|
[submodule "indeedhub"]
|
||||||
path = indeedhub
|
path = indeedhub
|
||||||
url = https://git.tx1138.com/lfg2025/indeehub.git
|
url = http://146.59.87.168:3000/lfg2025/indeehub.git
|
||||||
|
|||||||
@ -21,7 +21,7 @@ Add an entry to `catalog.json`:
|
|||||||
"icon": "/assets/img/app-icons/my-app.svg",
|
"icon": "/assets/img/app-icons/my-app.svg",
|
||||||
"author": "Author",
|
"author": "Author",
|
||||||
"category": "data",
|
"category": "data",
|
||||||
"dockerImage": "git.tx1138.com/lfg2025/my-app:1.0.0",
|
"dockerImage": "146.59.87.168:3000/lfg2025/my-app:1.0.0",
|
||||||
"repoUrl": "https://github.com/...",
|
"repoUrl": "https://github.com/...",
|
||||||
"containerConfig": {
|
"containerConfig": {
|
||||||
"ports": ["8080:8080"],
|
"ports": ["8080:8080"],
|
||||||
|
|||||||
@ -172,7 +172,7 @@
|
|||||||
"author": "File Browser",
|
"author": "File Browser",
|
||||||
"category": "data",
|
"category": "data",
|
||||||
"tier": "core",
|
"tier": "core",
|
||||||
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
|
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
|
||||||
"repoUrl": "https://github.com/filebrowser/filebrowser",
|
"repoUrl": "https://github.com/filebrowser/filebrowser",
|
||||||
"containerConfig": {
|
"containerConfig": {
|
||||||
"ports": [
|
"ports": [
|
||||||
@ -285,7 +285,7 @@
|
|||||||
"icon": "/assets/img/app-icons/fedimint.png",
|
"icon": "/assets/img/app-icons/fedimint.png",
|
||||||
"author": "Fedimint",
|
"author": "Fedimint",
|
||||||
"category": "money",
|
"category": "money",
|
||||||
"dockerImage": "git.tx1138.com/lfg2025/gatewayd:v0.10.0",
|
"dockerImage": "146.59.87.168:3000/lfg2025/gatewayd:v0.10.0",
|
||||||
"repoUrl": "https://github.com/fedimint/fedimint",
|
"repoUrl": "https://github.com/fedimint/fedimint",
|
||||||
"containerConfig": {
|
"containerConfig": {
|
||||||
"ports": [
|
"ports": [
|
||||||
|
|||||||
@ -13,6 +13,12 @@ app:
|
|||||||
secret_file: bitcoin-rpc-password
|
secret_file: bitcoin-rpc-password
|
||||||
- key: BTCPAY_DB_PASS
|
- key: BTCPAY_DB_PASS
|
||||||
secret_file: btcpay-db-password
|
secret_file: btcpay-db-password
|
||||||
|
# Internal LND node. Generated by the daemon (lnd macaroon as hex +
|
||||||
|
# tls.cert thumbprint) — see container::lnd::ensure_btcpay_lnd_connection_secret.
|
||||||
|
# Optional: nodes without LND run btcpay without an internal node.
|
||||||
|
- key: BTCPAY_BTCLIGHTNING
|
||||||
|
secret_file: btcpay-lnd-connection
|
||||||
|
optional: true
|
||||||
derived_env:
|
derived_env:
|
||||||
- key: BTCPAY_HOST
|
- key: BTCPAY_HOST
|
||||||
template: "{{HOST_IP}}:23000"
|
template: "{{HOST_IP}}:23000"
|
||||||
@ -50,6 +56,10 @@ app:
|
|||||||
- ASPNETCORE_URLS=http://0.0.0.0:49392
|
- ASPNETCORE_URLS=http://0.0.0.0:49392
|
||||||
- BTCPAY_PROTOCOL=http
|
- BTCPAY_PROTOCOL=http
|
||||||
- BTCPAY_CHAINS=btc
|
- BTCPAY_CHAINS=btc
|
||||||
|
# Plugins must live on the persistent volume: the image default
|
||||||
|
# (/root/.btcpayserver/Plugins) is container-local, so every recreate
|
||||||
|
# silently wiped installed plugins.
|
||||||
|
- BTCPAY_PLUGINDIR=/datadir/Plugins
|
||||||
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
|
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
|
||||||
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
|
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
|
||||||
- BTCPAY_BTCRPCUSER=archipelago
|
- BTCPAY_BTCRPCUSER=archipelago
|
||||||
|
|||||||
@ -12,7 +12,7 @@ set -e
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}"
|
FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}"
|
||||||
VERSION="${1:-latest}"
|
VERSION="${1:-latest}"
|
||||||
REGISTRY="${REGISTRY:-git.tx1138.com}"
|
REGISTRY="${REGISTRY:-146.59.87.168:3000}"
|
||||||
NAMESPACE="${NAMESPACE:-lfg2025}"
|
NAMESPACE="${NAMESPACE:-lfg2025}"
|
||||||
IMAGE_NAME="indeedhub"
|
IMAGE_NAME="indeedhub"
|
||||||
RUNTIME="${RUNTIME:-podman}"
|
RUNTIME="${RUNTIME:-podman}"
|
||||||
|
|||||||
1
core/Cargo.lock
generated
1
core/Cargo.lock
generated
@ -140,6 +140,7 @@ dependencies = [
|
|||||||
"reqwest 0.11.27",
|
"reqwest 0.11.27",
|
||||||
"sd-notify",
|
"sd-notify",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_bytes",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"serial2-tokio",
|
"serial2-tokio",
|
||||||
|
|||||||
@ -110,6 +110,7 @@ hkdf = "0.12.4"
|
|||||||
|
|
||||||
# Transport abstraction (Phase 2: mesh as federation transport)
|
# Transport abstraction (Phase 2: mesh as federation transport)
|
||||||
ciborium = "0.2.2"
|
ciborium = "0.2.2"
|
||||||
|
serde_bytes = "0.11"
|
||||||
reed-solomon-erasure = "6.0"
|
reed-solomon-erasure = "6.0"
|
||||||
mdns-sd = "0.18"
|
mdns-sd = "0.18"
|
||||||
|
|
||||||
|
|||||||
@ -126,15 +126,15 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Server-side fetch of the upstream app catalog so the browser can
|
/// Server-side fetch of the upstream app catalog so the browser can
|
||||||
/// load it without fighting CORS (git.tx1138.com emits no ACAO) or
|
/// load it without fighting CORS (upstream Gitea emits no ACAO) or
|
||||||
/// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream
|
/// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream
|
||||||
/// list is derived from the operator's configured container registries
|
/// list is derived from the operator's configured container registries
|
||||||
/// so switching mirrors in Settings changes the App Store source too —
|
/// so switching mirrors in Settings changes the App Store source too —
|
||||||
/// each active registry contributes one Gitea `raw/branch/main/catalog.json`
|
/// each active registry contributes one Gitea `raw/branch/main/catalog.json`
|
||||||
/// URL (http or https per `tls_verify`), tried in priority order.
|
/// URL (http or https per `tls_verify`), tried in priority order.
|
||||||
/// If registry config can't be loaded, falls back to the legacy
|
/// If registry config can't be loaded, falls back to the hardcoded OVH
|
||||||
/// hardcoded pair so the App Store still renders on nodes that haven't
|
/// URL so the App Store still renders on nodes that haven't persisted
|
||||||
/// persisted a registry config yet. 15s total timeout.
|
/// a registry config yet. 15s total timeout.
|
||||||
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
|
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
|
||||||
let mut upstreams: Vec<String> = Vec::new();
|
let mut upstreams: Vec<String> = Vec::new();
|
||||||
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
|
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
|
||||||
@ -155,10 +155,6 @@ impl ApiHandler {
|
|||||||
"http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
|
"http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
upstreams.push(
|
|
||||||
"https://git.tx1138.com/lfg2025/app-catalog/raw/branch/main/catalog.json"
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
@ -527,7 +523,7 @@ impl ApiHandler {
|
|||||||
|
|
||||||
// App-catalog proxy — fetches catalog.json from the configured
|
// App-catalog proxy — fetches catalog.json from the configured
|
||||||
// upstream URLs server-side so the browser doesn't hit CORS
|
// upstream URLs server-side so the browser doesn't hit CORS
|
||||||
// (git.tx1138.com has no ACAO header) or CSP (IP-port upstream
|
// (upstream Gitea has no ACAO header) or CSP (IP-port upstream
|
||||||
// falls outside `connect-src`). Session-authenticated so only
|
// falls outside `connect-src`). Session-authenticated so only
|
||||||
// the logged-in node owner can spin up fetches.
|
// the logged-in node owner can spin up fetches.
|
||||||
(Method::GET, "/api/app-catalog") => {
|
(Method::GET, "/api/app-catalog") => {
|
||||||
|
|||||||
@ -5,11 +5,12 @@
|
|||||||
//! 1.8.0 hardening plan).
|
//! 1.8.0 hardening plan).
|
||||||
|
|
||||||
/// Registries images may be pulled from with an explicit host part.
|
/// Registries images may be pulled from with an explicit host part.
|
||||||
|
/// (git.tx1138.com was removed 2026-07-10: the host is retired and must
|
||||||
|
/// never be pulled through again.)
|
||||||
pub const TRUSTED_REGISTRIES: &[&str] = &[
|
pub const TRUSTED_REGISTRIES: &[&str] = &[
|
||||||
"docker.io",
|
"docker.io",
|
||||||
"ghcr.io",
|
"ghcr.io",
|
||||||
"localhost",
|
"localhost",
|
||||||
"git.tx1138.com",
|
|
||||||
"146.59.87.168:3000",
|
"146.59.87.168:3000",
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -58,13 +59,19 @@ mod tests {
|
|||||||
"docker.io/library/nginx:1.25",
|
"docker.io/library/nginx:1.25",
|
||||||
"ghcr.io/owner/app:latest",
|
"ghcr.io/owner/app:latest",
|
||||||
"localhost/archy-dev:1",
|
"localhost/archy-dev:1",
|
||||||
"git.tx1138.com/lfg2025/x:2",
|
|
||||||
"146.59.87.168:3000/archy/bitcoin-knots:28.1",
|
"146.59.87.168:3000/archy/bitcoin-knots:28.1",
|
||||||
] {
|
] {
|
||||||
assert!(is_valid_docker_image(img), "{img} should be accepted");
|
assert!(is_valid_docker_image(img), "{img} should be accepted");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_retired_tx1138_registry() {
|
||||||
|
// Retired 2026-07-10 — refs through the dead host must be refused
|
||||||
|
// at the pull site, not time out against it.
|
||||||
|
assert!(!is_valid_docker_image("git.tx1138.com/lfg2025/x:2"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_docker_hub_shorthand() {
|
fn accepts_docker_hub_shorthand() {
|
||||||
for img in ["nginx", "grafana/grafana:11.2.0", "lightninglabs/lnd:v0.18"] {
|
for img in ["nginx", "grafana/grafana:11.2.0", "lightninglabs/lnd:v0.18"] {
|
||||||
|
|||||||
@ -234,7 +234,7 @@ pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extract version tag from a full image reference.
|
/// Extract version tag from a full image reference.
|
||||||
/// e.g. "git.tx1138.com/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
|
/// e.g. "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta" → "v0.18.4-beta"
|
||||||
/// Returns "latest" if no tag or tag is empty.
|
/// Returns "latest" if no tag or tag is empty.
|
||||||
pub fn extract_version_from_image(image: &str) -> String {
|
pub fn extract_version_from_image(image: &str) -> String {
|
||||||
// Split off the tag after the last colon, but only if it comes after the last slash
|
// Split off the tag after the last colon, but only if it comes after the last slash
|
||||||
@ -328,11 +328,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_extract_version() {
|
fn test_extract_version() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_version_from_image("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"),
|
extract_version_from_image("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"),
|
||||||
"v0.18.4-beta"
|
"v0.18.4-beta"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_version_from_image("git.tx1138.com/lfg2025/grafana:10.2.0"),
|
extract_version_from_image("146.59.87.168:3000/lfg2025/grafana:10.2.0"),
|
||||||
"10.2.0"
|
"10.2.0"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -340,7 +340,7 @@ mod tests {
|
|||||||
"latest"
|
"latest"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_version_from_image("git.tx1138.com/lfg2025/bitcoin-knots:latest"),
|
extract_version_from_image("146.59.87.168:3000/lfg2025/bitcoin-knots:latest"),
|
||||||
"latest"
|
"latest"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -352,7 +352,7 @@ mod tests {
|
|||||||
"lfg2025/lnd"
|
"lfg2025/lnd"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
image_without_registry_or_tag("git.tx1138.com/lfg2025/lnd:v0.18.4-beta"),
|
image_without_registry_or_tag("146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta"),
|
||||||
"lfg2025/lnd"
|
"lfg2025/lnd"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -369,7 +369,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
available_update_for_images(
|
available_update_for_images(
|
||||||
"146.59.87.168:3000/lfg2025/nextcloud:29",
|
"146.59.87.168:3000/lfg2025/nextcloud:29",
|
||||||
"git.tx1138.com/lfg2025/nextcloud:29",
|
"146.59.87.168:3000/lfg2025/nextcloud:29",
|
||||||
),
|
),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
@ -389,7 +389,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_parse_image_versions() {
|
fn test_parse_image_versions() {
|
||||||
let content = r#"
|
let content = r#"
|
||||||
ARCHY_REGISTRY="git.tx1138.com/lfg2025"
|
ARCHY_REGISTRY="146.59.87.168:3000/lfg2025"
|
||||||
LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
|
LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
|
||||||
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
|
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
|
||||||
# comment
|
# comment
|
||||||
@ -398,11 +398,11 @@ NOT_AN_IMAGE="something"
|
|||||||
let parsed = parse_image_versions(content);
|
let parsed = parse_image_versions(content);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.get("LND_IMAGE"),
|
parsed.get("LND_IMAGE"),
|
||||||
Some(&"git.tx1138.com/lfg2025/lnd:v0.18.4-beta".to_string())
|
Some(&"146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed.get("GRAFANA_IMAGE"),
|
parsed.get("GRAFANA_IMAGE"),
|
||||||
Some(&"git.tx1138.com/lfg2025/grafana:10.2.0".to_string())
|
Some(&"146.59.87.168:3000/lfg2025/grafana:10.2.0".to_string())
|
||||||
);
|
);
|
||||||
assert!(!parsed.contains_key("NOT_AN_IMAGE"));
|
assert!(!parsed.contains_key("NOT_AN_IMAGE"));
|
||||||
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
|
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
|
||||||
|
|||||||
@ -670,6 +670,73 @@ fn has_required_lnd_flags(conf: &str, rpc_pass: &str, bitcoin_host: &str) -> boo
|
|||||||
.all(|needle| conf.lines().any(|line| line.trim() == *needle))
|
.all(|needle| conf.lines().any(|line| line.trim() == *needle))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Secret file consumed by btcpay-server's optional `BTCPAY_BTCLIGHTNING`
|
||||||
|
/// secret_env (see apps/btcpay-server/manifest.yml).
|
||||||
|
const BTCPAY_LND_CONNECTION_SECRET: &str = "btcpay-lnd-connection";
|
||||||
|
|
||||||
|
/// Materialise the BTCPay→internal-LND connection-string secret.
|
||||||
|
///
|
||||||
|
/// LND's datadir is owned by its container subuid (100999 on a stock node),
|
||||||
|
/// so btcpay cannot bind-mount the macaroon — EACCES across the userns
|
||||||
|
/// boundary. The connection string therefore carries the macaroon inline as
|
||||||
|
/// hex, delivered by reference through the podman secret store.
|
||||||
|
///
|
||||||
|
/// No-op when LND isn't provisioned yet (missing tls.cert or macaroon) —
|
||||||
|
/// btcpay's secret_env entry is `optional`, so it simply starts without an
|
||||||
|
/// internal Lightning node and picks it up on a later reconcile tick.
|
||||||
|
/// Rewrites when the pinned cert thumbprint no longer matches (LND TLS cert
|
||||||
|
/// rotation). Macaroon rotation without cert rotation is not auto-detected
|
||||||
|
/// (reading the macaroon needs sudo; probing it every tick is not worth the
|
||||||
|
/// churn) — delete the secret file once to force regeneration.
|
||||||
|
pub async fn ensure_btcpay_lnd_connection_secret(secrets_dir: &std::path::Path) -> Result<()> {
|
||||||
|
let cert_path = format!("{DEFAULT_DATA_DIR}/tls.cert");
|
||||||
|
let pem = match fs::read_to_string(&cert_path).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return Ok(()), // LND not installed/provisioned yet
|
||||||
|
};
|
||||||
|
let thumbprint =
|
||||||
|
cert_sha256_thumbprint(&pem).context("computing LND tls.cert thumbprint")?;
|
||||||
|
|
||||||
|
let target = secrets_dir.join(BTCPAY_LND_CONNECTION_SECRET);
|
||||||
|
// Fast path (no sudo): existing secret already pins the current cert.
|
||||||
|
if let Ok(existing) = fs::read_to_string(&target).await {
|
||||||
|
if !existing.trim().is_empty()
|
||||||
|
&& existing.contains(&format!("certthumbprint={thumbprint}"))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let macaroon_path = format!("{DEFAULT_DATA_DIR}/data/chain/bitcoin/mainnet/admin.macaroon");
|
||||||
|
if !file_exists_as_root(&macaroon_path).await {
|
||||||
|
return Ok(()); // wallet not created yet; next tick retries
|
||||||
|
}
|
||||||
|
let macaroon = read_file_as_root(&macaroon_path).await?;
|
||||||
|
let value = format!(
|
||||||
|
"type=lnd-rest;server=https://lnd:8080/;macaroon={};certthumbprint={}",
|
||||||
|
hex::encode(macaroon),
|
||||||
|
thumbprint
|
||||||
|
);
|
||||||
|
crate::container::secrets::write_secret_file(&target, &value)
|
||||||
|
.context("writing btcpay-lnd-connection secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA256 over the DER certificate body (matches
|
||||||
|
/// `openssl x509 -fingerprint -sha256` without colons) — the format BTCPay's
|
||||||
|
/// `certthumbprint=` connection-string parameter expects.
|
||||||
|
fn cert_sha256_thumbprint(pem: &str) -> Result<String> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let b64: String = pem
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.starts_with("-----"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
let der = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(b64.trim())
|
||||||
|
.context("decoding tls.cert PEM body")?;
|
||||||
|
Ok(hex::encode_upper(Sha256::digest(&der)))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -2251,7 +2251,7 @@ impl ProdContainerOrchestrator {
|
|||||||
// Reconcile recreates route through here too: an unreachable
|
// Reconcile recreates route through here too: an unreachable
|
||||||
// registry must not brick an app whose exact image:tag is
|
// registry must not brick an app whose exact image:tag is
|
||||||
// already in local storage (boot reconcile of archy-btcpay-db
|
// already in local storage (boot reconcile of archy-btcpay-db
|
||||||
// /archy-nbxplorer with git.tx1138.com dead, 2026-07-10).
|
// /archy-nbxplorer with the (since-retired) upstream registry dead, 2026-07-10).
|
||||||
// Same exists-first semantics as
|
// Same exists-first semantics as
|
||||||
// ensure_resolved_source_available and the quadlets'
|
// ensure_resolved_source_available and the quadlets'
|
||||||
// Pull=never.
|
// Pull=never.
|
||||||
@ -3162,6 +3162,17 @@ impl ProdContainerOrchestrator {
|
|||||||
.await
|
.await
|
||||||
.context("ensuring bitcoin tx-relay credentials")?;
|
.context("ensuring bitcoin tx-relay credentials")?;
|
||||||
}
|
}
|
||||||
|
if app_id == "btcpay-server" {
|
||||||
|
// Derived secret (LND macaroon + cert thumbprint), consumed by an
|
||||||
|
// `optional` secret_env — btcpay must still start when LND is
|
||||||
|
// absent or the derivation fails, so log-and-continue.
|
||||||
|
if let Err(e) =
|
||||||
|
crate::container::lnd::ensure_btcpay_lnd_connection_secret(&self.secrets_dir)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "btcpay-lnd-connection secret not generated; btcpay will run without the internal LND node");
|
||||||
|
}
|
||||||
|
}
|
||||||
// Other app secrets (fmcd-password, fedimint-gateway-hash, …) are now
|
// Other app secrets (fmcd-password, fedimint-gateway-hash, …) are now
|
||||||
// declared as `generated_secrets` in their manifests and materialised
|
// declared as `generated_secrets` in their manifests and materialised
|
||||||
// generically in `resolve_dynamic_env` — no per-app code here.
|
// generically in `resolve_dynamic_env` — no per-app code here.
|
||||||
@ -4837,7 +4848,7 @@ app:
|
|||||||
name: File Browser
|
name: File Browser
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
container:
|
container:
|
||||||
image: git.tx1138.com/lfg2025/filebrowser:v2.27.0
|
image: 146.59.87.168:3000/lfg2025/filebrowser:v2.27.0
|
||||||
custom_args:
|
custom_args:
|
||||||
- --config
|
- --config
|
||||||
- /data/.filebrowser.json
|
- /data/.filebrowser.json
|
||||||
@ -4861,7 +4872,7 @@ app:
|
|||||||
name: LND
|
name: LND
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
container:
|
container:
|
||||||
image: git.tx1138.com/lfg2025/lnd:v0.18.4-beta
|
image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta
|
||||||
secret_env:
|
secret_env:
|
||||||
- key: BITCOIND_RPCPASS
|
- key: BITCOIND_RPCPASS
|
||||||
secret_file: bitcoin-rpc-password
|
secret_file: bitcoin-rpc-password
|
||||||
@ -4948,7 +4959,7 @@ app:
|
|||||||
async fn install_fresh_skips_pull_when_image_local() {
|
async fn install_fresh_skips_pull_when_image_local() {
|
||||||
// An unreachable registry must not brick a reconcile recreate whose
|
// An unreachable registry must not brick a reconcile recreate whose
|
||||||
// exact image:tag is already in local storage (archy-btcpay-db /
|
// exact image:tag is already in local storage (archy-btcpay-db /
|
||||||
// archy-nbxplorer boot reconcile with git.tx1138.com dead,
|
// archy-nbxplorer boot reconcile with the (since-retired) upstream registry dead,
|
||||||
// 2026-07-10).
|
// 2026-07-10).
|
||||||
let rt = Arc::new(MockRuntime::default());
|
let rt = Arc::new(MockRuntime::default());
|
||||||
rt.mark_image_present("docker.io/bitcoin/knots:28");
|
rt.mark_image_present("docker.io/bitcoin/knots:28");
|
||||||
|
|||||||
@ -11,12 +11,17 @@ use tokio::fs;
|
|||||||
|
|
||||||
const REGISTRY_FILE: &str = "config/registries.json";
|
const REGISTRY_FILE: &str = "config/registries.json";
|
||||||
const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025";
|
const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025";
|
||||||
const TX1138_REGISTRY_URL: &str = "git.tx1138.com/lfg2025";
|
/// Retired registry host (release server retired 2026-06-13; the registry
|
||||||
|
/// frontend was fully dead by 2026-07-10 — 500 on every /v2 manifest read).
|
||||||
|
/// Never a default, never force-enabled; stripped from saved configs on
|
||||||
|
/// load. The literal exists ONLY so the strip can match — nothing may pull
|
||||||
|
/// through this host.
|
||||||
|
const RETIRED_TX1138_HOST: &str = "git.tx1138.com";
|
||||||
|
|
||||||
/// A single container registry.
|
/// A single container registry.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Registry {
|
pub struct Registry {
|
||||||
/// Registry URL (e.g., "git.tx1138.com/lfg2025" or "146.59.87.168:3000/lfg2025").
|
/// Registry URL (e.g., "146.59.87.168:3000/lfg2025").
|
||||||
pub url: String,
|
pub url: String,
|
||||||
/// Human-readable name.
|
/// Human-readable name.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@ -43,22 +48,13 @@ pub struct RegistryConfig {
|
|||||||
impl Default for RegistryConfig {
|
impl Default for RegistryConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
registries: vec![
|
registries: vec![Registry {
|
||||||
Registry {
|
|
||||||
url: OVH_REGISTRY_URL.to_string(),
|
url: OVH_REGISTRY_URL.to_string(),
|
||||||
name: "Server 1 (OVH)".to_string(),
|
name: "Server 1 (OVH)".to_string(),
|
||||||
tls_verify: false,
|
tls_verify: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
},
|
}],
|
||||||
Registry {
|
|
||||||
url: TX1138_REGISTRY_URL.to_string(),
|
|
||||||
name: "Server 2 (tx1138)".to_string(),
|
|
||||||
tls_verify: true,
|
|
||||||
enabled: true,
|
|
||||||
priority: 10,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -72,7 +68,7 @@ impl RegistryConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrite an image reference to use a specific registry.
|
/// Rewrite an image reference to use a specific registry.
|
||||||
/// E.g., "git.tx1138.com/lfg2025/bitcoin-knots:latest" with registry "146.59.87.168:3000/lfg2025"
|
/// E.g., "docker.io/lfg2025/bitcoin-knots:latest" with registry "146.59.87.168:3000/lfg2025"
|
||||||
/// becomes "146.59.87.168:3000/lfg2025/bitcoin-knots:latest".
|
/// becomes "146.59.87.168:3000/lfg2025/bitcoin-knots:latest".
|
||||||
pub fn rewrite_image(&self, image: &str, registry: &Registry) -> String {
|
pub fn rewrite_image(&self, image: &str, registry: &Registry) -> String {
|
||||||
// Extract the image name (last component after the org/namespace)
|
// Extract the image name (last component after the org/namespace)
|
||||||
@ -83,7 +79,7 @@ impl RegistryConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the image name from a full image reference.
|
/// Extract the image name from a full image reference.
|
||||||
/// "git.tx1138.com/lfg2025/bitcoin-knots:latest" -> "bitcoin-knots:latest"
|
/// "146.59.87.168:3000/lfg2025/bitcoin-knots:latest" -> "bitcoin-knots:latest"
|
||||||
/// "docker.io/gitea/gitea:1.23" -> "gitea:1.23"
|
/// "docker.io/gitea/gitea:1.23" -> "gitea:1.23"
|
||||||
fn extract_image_name(image: &str) -> &str {
|
fn extract_image_name(image: &str) -> &str {
|
||||||
// Split by '/' and take the last segment (image:tag)
|
// Split by '/' and take the last segment (image:tag)
|
||||||
@ -118,6 +114,12 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
|||||||
config
|
config
|
||||||
.registries
|
.registries
|
||||||
.retain(|r| !r.url.contains("23.182.128.160"));
|
.retain(|r| !r.url.contains("23.182.128.160"));
|
||||||
|
// Same treatment for the retired tx1138 registry (was Server 2 in older
|
||||||
|
// defaults): strip it on load so nothing ever pulls through the dead
|
||||||
|
// host again.
|
||||||
|
config
|
||||||
|
.registries
|
||||||
|
.retain(|r| !r.url.contains(RETIRED_TX1138_HOST));
|
||||||
let mut changed = config.registries.len() != before;
|
let mut changed = config.registries.len() != before;
|
||||||
|
|
||||||
// Migrate: any default registry URL that isn't already in the
|
// Migrate: any default registry URL that isn't already in the
|
||||||
@ -178,12 +180,6 @@ fn force_ovh_registry_primary(config: &mut RegistryConfig) {
|
|||||||
registry.enabled = true;
|
registry.enabled = true;
|
||||||
registry.priority = 0;
|
registry.priority = 0;
|
||||||
}
|
}
|
||||||
TX1138_REGISTRY_URL => {
|
|
||||||
registry.name = "Server 2 (tx1138)".to_string();
|
|
||||||
registry.tls_verify = true;
|
|
||||||
registry.enabled = true;
|
|
||||||
registry.priority = 10;
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
if registry.priority <= 10 {
|
if registry.priority <= 10 {
|
||||||
registry.priority = registry.priority.saturating_add(20);
|
registry.priority = registry.priority.saturating_add(20);
|
||||||
@ -216,7 +212,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_extract_image_name() {
|
fn test_extract_image_name() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_image_name("git.tx1138.com/lfg2025/bitcoin-knots:latest"),
|
extract_image_name("146.59.87.168:3000/lfg2025/bitcoin-knots:latest"),
|
||||||
"bitcoin-knots:latest"
|
"bitcoin-knots:latest"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -229,11 +225,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_rewrite_image() {
|
fn test_rewrite_image() {
|
||||||
let config = RegistryConfig::default();
|
let config = RegistryConfig::default();
|
||||||
// Default primary is now the OVH VPS (index 0). A tx1138-hardcoded
|
// An image hardcoded to some other registry rewrites to OVH when
|
||||||
// image rewrites to OVH when asked for the primary mirror.
|
// asked for the primary mirror.
|
||||||
let primary = &config.registries[0];
|
let primary = &config.registries[0];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", primary),
|
config.rewrite_image("docker.io/lfg2025/bitcoin-knots:latest", primary),
|
||||||
"146.59.87.168:3000/lfg2025/bitcoin-knots:latest"
|
"146.59.87.168:3000/lfg2025/bitcoin-knots:latest"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -242,15 +238,51 @@ mod tests {
|
|||||||
fn test_active_registries_sorted() {
|
fn test_active_registries_sorted() {
|
||||||
let config = RegistryConfig::default();
|
let config = RegistryConfig::default();
|
||||||
let active = config.active_registries();
|
let active = config.active_registries();
|
||||||
assert_eq!(active.len(), 2);
|
assert_eq!(active.len(), 1);
|
||||||
assert!(active[0].priority <= active[1].priority);
|
assert_eq!(active[0].url, OVH_REGISTRY_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_load_default() {
|
async fn test_load_default() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let config = load_registries(tmp.path()).await.unwrap();
|
let config = load_registries(tmp.path()).await.unwrap();
|
||||||
assert_eq!(config.registries.len(), 2);
|
assert_eq!(config.registries.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_load_strips_retired_tx1138_registry() {
|
||||||
|
// Nodes provisioned before the retirement have the tx1138 registry
|
||||||
|
// baked into their saved config (was Server 2). It must be stripped
|
||||||
|
// on load and never re-added by the defaults merge.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let config = RegistryConfig {
|
||||||
|
registries: vec![
|
||||||
|
Registry {
|
||||||
|
url: format!("{RETIRED_TX1138_HOST}/lfg2025"),
|
||||||
|
name: "Server 2 (tx1138)".into(),
|
||||||
|
tls_verify: true,
|
||||||
|
enabled: true,
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
Registry {
|
||||||
|
url: OVH_REGISTRY_URL.into(),
|
||||||
|
name: "Server 1 (OVH)".into(),
|
||||||
|
tls_verify: false,
|
||||||
|
enabled: true,
|
||||||
|
priority: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
save_registries(tmp.path(), &config).await.unwrap();
|
||||||
|
let loaded = load_registries(tmp.path()).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
!loaded
|
||||||
|
.registries
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.url.contains(RETIRED_TX1138_HOST)),
|
||||||
|
"retired tx1138 registry must be stripped on load; got {:?}",
|
||||||
|
loaded.registries
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -266,6 +298,6 @@ mod tests {
|
|||||||
});
|
});
|
||||||
save_registries(tmp.path(), &config).await.unwrap();
|
save_registries(tmp.path(), &config).await.unwrap();
|
||||||
let loaded = load_registries(tmp.path()).await.unwrap();
|
let loaded = load_registries(tmp.path()).await.unwrap();
|
||||||
assert_eq!(loaded.registries.len(), 3);
|
assert_eq!(loaded.registries.len(), 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -102,6 +102,17 @@ fn random_base64(bytes: usize) -> String {
|
|||||||
base64::engine::general_purpose::STANDARD.encode(buf)
|
base64::engine::general_purpose::STANDARD.encode(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write an externally computed secret value (0600, atomic). For derived
|
||||||
|
/// secrets that aren't random generators — e.g. the btcpay internal-LND
|
||||||
|
/// connection string assembled in `container::lnd`.
|
||||||
|
pub(crate) fn write_secret_file(path: &Path, value: &str) -> Result<()> {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
fs::create_dir_all(dir)
|
||||||
|
.with_context(|| format!("creating secrets dir {}", dir.display()))?;
|
||||||
|
}
|
||||||
|
write_secret(path, value)
|
||||||
|
}
|
||||||
|
|
||||||
/// Atomically write a `0600` secret: a temp file in the same dir (so the rename
|
/// Atomically write a `0600` secret: a temp file in the same dir (so the rename
|
||||||
/// is atomic), fsynced, then renamed over the target.
|
/// is atomic), fsynced, then renamed over the target.
|
||||||
fn write_secret(path: &Path, value: &str) -> Result<()> {
|
fn write_secret(path: &Path, value: &str) -> Result<()> {
|
||||||
|
|||||||
@ -72,6 +72,7 @@ mod state;
|
|||||||
mod storage_crypto;
|
mod storage_crypto;
|
||||||
mod streaming;
|
mod streaming;
|
||||||
mod swarm;
|
mod swarm;
|
||||||
|
mod tollgate_sweep;
|
||||||
mod totp;
|
mod totp;
|
||||||
mod transport;
|
mod transport;
|
||||||
mod trust;
|
mod trust;
|
||||||
|
|||||||
@ -537,6 +537,28 @@ impl Server {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Periodic TollGate ecash sweep. tollgate-wrt keeps its own separate
|
||||||
|
// Cashu wallet on the router — customer payments never land in this
|
||||||
|
// node's wallet on their own, and its Lightning auto-payout config is
|
||||||
|
// independent and easy to leave misconfigured. This drains whatever
|
||||||
|
// TollGate has collected straight into the local wallet on a timer,
|
||||||
|
// sidestepping Lightning payout configuration entirely.
|
||||||
|
{
|
||||||
|
let data_dir = config.data_dir.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
match crate::tollgate_sweep::sweep_once(&data_dir).await {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(swept) => info!(sats = swept, "tollgate wallet sweep complete"),
|
||||||
|
Err(e) => debug!(error = %e, "tollgate wallet sweep (non-fatal)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize container scanner — discovers installed apps from Podman/Docker
|
// Initialize container scanner — discovers installed apps from Podman/Docker
|
||||||
{
|
{
|
||||||
let scanner = create_docker_scanner(&config).await?;
|
let scanner = create_docker_scanner(&config).await?;
|
||||||
|
|||||||
81
core/archipelago/src/tollgate_sweep.rs
Normal file
81
core/archipelago/src/tollgate_sweep.rs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use archipelago_openwrt::router::Router;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::network::router as net_router;
|
||||||
|
use crate::wallet::ecash;
|
||||||
|
|
||||||
|
/// Pull whatever TollGate has collected in its on-router Cashu wallet into
|
||||||
|
/// this node's own wallet.
|
||||||
|
///
|
||||||
|
/// `tollgate-wrt` keeps a completely separate Cashu wallet on the router
|
||||||
|
/// (`/etc/tollgate/wallet.db`) — customer payments land there, never in this
|
||||||
|
/// node's wallet directly. Its own Lightning auto-payout is configured
|
||||||
|
/// independently in `/etc/tollgate/identities.json`, which is easy to leave
|
||||||
|
/// pointed at the wrong (or a placeholder) address and easy to lose track of.
|
||||||
|
/// This sweep sidesteps that entirely: periodically drain the router's
|
||||||
|
/// TollGate wallet to Cashu tokens (`tollgate wallet drain cashu`, over SSH)
|
||||||
|
/// and receive them straight into the local wallet via the same path the
|
||||||
|
/// "Receive ecash" UI uses.
|
||||||
|
///
|
||||||
|
/// Returns the total sats swept in (0 if there was nothing to do, including
|
||||||
|
/// when no router is configured or it doesn't have TollGate installed).
|
||||||
|
pub async fn sweep_once(data_dir: &Path) -> Result<u64> {
|
||||||
|
let cfg = net_router::load_router_config(data_dir).await?;
|
||||||
|
if !cfg.configured {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let ssh_user = cfg.username.clone().unwrap_or_else(|| "root".to_string());
|
||||||
|
let ssh_password = cfg.password.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
let router = Router::connect_password(&cfg.address, 22, &ssh_user, &ssh_password)
|
||||||
|
.context("connect to router for tollgate wallet sweep")?;
|
||||||
|
|
||||||
|
// `tollgate` CLI missing (no TollGate installed here) is a normal,
|
||||||
|
// expected case, not an error — just nothing to sweep.
|
||||||
|
let (balance_out, code) = router.run("tollgate wallet balance 2>/dev/null")?;
|
||||||
|
if code != 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let balance_sats = balance_out
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.trim().strip_prefix("balance_sats:"))
|
||||||
|
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
if balance_sats == 0 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (drain_out, drain_code) = router.run("tollgate wallet drain cashu 2>&1")?;
|
||||||
|
if drain_code != 0 {
|
||||||
|
anyhow::bail!("tollgate wallet drain cashu failed: {}", drain_out.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CLI has no machine-readable output mode; pull tokens out of its
|
||||||
|
// " Token: cashuB..." lines.
|
||||||
|
let mut received_total = 0u64;
|
||||||
|
for line in drain_out.lines() {
|
||||||
|
let Some(token) = line.trim().strip_prefix("Token:") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let token = token.trim();
|
||||||
|
if token.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match ecash::receive_token(data_dir, token).await {
|
||||||
|
Ok(amount) => {
|
||||||
|
received_total += amount;
|
||||||
|
info!(amount_sats = amount, "swept TollGate ecash into local wallet");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// The token is still in this log line if this happens — not
|
||||||
|
// silently lost, just needs manual `wallet.ecash-receive`.
|
||||||
|
warn!(error = %e, token, "failed to receive swept TollGate token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(received_total)
|
||||||
|
}
|
||||||
@ -2132,9 +2132,9 @@ mod tests {
|
|||||||
fn test_manifest_origin_parses_https() {
|
fn test_manifest_origin_parses_https() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest_origin(
|
manifest_origin(
|
||||||
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
"https://releases.example.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||||
),
|
),
|
||||||
Some("https://git.tx1138.com".to_string())
|
Some("https://releases.example.com".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2151,7 +2151,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_manifest_origin_rejects_garbage() {
|
fn test_manifest_origin_rejects_garbage() {
|
||||||
assert_eq!(manifest_origin("not a url"), None);
|
assert_eq!(manifest_origin("not a url"), None);
|
||||||
assert_eq!(manifest_origin("ftp://git.tx1138.com/x"), None);
|
assert_eq!(manifest_origin("ftp://releases.example.com/x"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -2165,7 +2165,7 @@ mod tests {
|
|||||||
name: "archipelago".into(),
|
name: "archipelago".into(),
|
||||||
current_version: "1.7.25-alpha".into(),
|
current_version: "1.7.25-alpha".into(),
|
||||||
new_version: "1.7.26-alpha".into(),
|
new_version: "1.7.26-alpha".into(),
|
||||||
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago".into(),
|
download_url: "https://releases.example.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago".into(),
|
||||||
sha256: "x".into(),
|
sha256: "x".into(),
|
||||||
size_bytes: 1,
|
size_bytes: 1,
|
||||||
blake3: None,
|
blake3: None,
|
||||||
@ -2174,7 +2174,7 @@ mod tests {
|
|||||||
name: "frontend".into(),
|
name: "frontend".into(),
|
||||||
current_version: "1.7.25-alpha".into(),
|
current_version: "1.7.25-alpha".into(),
|
||||||
new_version: "1.7.26-alpha".into(),
|
new_version: "1.7.26-alpha".into(),
|
||||||
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz".into(),
|
download_url: "https://releases.example.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz".into(),
|
||||||
sha256: "y".into(),
|
sha256: "y".into(),
|
||||||
size_bytes: 2,
|
size_bytes: 2,
|
||||||
blake3: None,
|
blake3: None,
|
||||||
@ -2218,6 +2218,8 @@ mod tests {
|
|||||||
label: "Server 1 (OVH)".to_string(),
|
label: "Server 1 (OVH)".to_string(),
|
||||||
},
|
},
|
||||||
UpdateMirror {
|
UpdateMirror {
|
||||||
|
// Deliberately the retired host: this fixture exists to prove
|
||||||
|
// load_mirrors strips it.
|
||||||
url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
label: "Server 2 (tx1138)".to_string(),
|
label: "Server 2 (tx1138)".to_string(),
|
||||||
@ -2406,7 +2408,7 @@ mod tests {
|
|||||||
rollback_available: false,
|
rollback_available: false,
|
||||||
schedule: UpdateSchedule::Manual,
|
schedule: UpdateSchedule::Manual,
|
||||||
manifest_mirror: Some(
|
manifest_mirror: Some(
|
||||||
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
"https://releases.example.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
manifest_signed: false,
|
manifest_signed: false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
//! Cashu token format (NUT-00) — serialization and deserialization.
|
//! Cashu token format (NUT-00) — serialization and deserialization.
|
||||||
//!
|
//!
|
||||||
//! Supports the cashuA (V3) token format:
|
//! Emits the cashuA (V3) token format:
|
||||||
//! cashuA<base64url_encoded_json>
|
//! cashuA<base64url_encoded_json>
|
||||||
//!
|
//!
|
||||||
//! Token JSON structure:
|
//! Token JSON structure:
|
||||||
@ -8,13 +8,54 @@
|
|||||||
//! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }],
|
//! "token": [{ "mint": "<url>", "proofs": [{ "amount": u64, "id": "<keyset>", "secret": "<str>", "C": "<hex>" }] }],
|
||||||
//! "memo": "<optional>"
|
//! "memo": "<optional>"
|
||||||
//! }
|
//! }
|
||||||
|
//!
|
||||||
|
//! Also accepts (decode-only) the cashuB (V4) CBOR format many wallets emit
|
||||||
|
//! by default now:
|
||||||
|
//! cashuB<base64url_encoded_cbor>
|
||||||
|
//! CBOR map keys are the spec's single-letter names (t/i/p/a/s/c/m/u/d/w),
|
||||||
|
//! not the JSON names above. `i` (keyset id) and `c` (signature) are raw
|
||||||
|
//! bytes on the wire; we hex-encode them into `Proof` to match the V3
|
||||||
|
//! convention so the rest of the wallet doesn't need to know which version
|
||||||
|
//! a token arrived in.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use bitcoin::secp256k1::PublicKey;
|
use bitcoin::secp256k1::PublicKey;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Prefix for V3 tokens.
|
/// Prefix for V3 (JSON) tokens.
|
||||||
const CASHU_A_PREFIX: &str = "cashuA";
|
const CASHU_A_PREFIX: &str = "cashuA";
|
||||||
|
/// Prefix for V4 (CBOR) tokens.
|
||||||
|
const CASHU_B_PREFIX: &str = "cashuB";
|
||||||
|
|
||||||
|
/// Raw CBOR shape of a V4 proof — field names are the spec's map keys.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ProofV4 {
|
||||||
|
a: u64,
|
||||||
|
s: String,
|
||||||
|
#[serde(with = "serde_bytes")]
|
||||||
|
c: Vec<u8>,
|
||||||
|
// DLEQ proof ("d") and witness ("w") aren't verified or stored by this
|
||||||
|
// wallet; accept and discard them rather than fail on the field.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw CBOR shape of a V4 token entry (one keyset's worth of proofs).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TokenEntryV4 {
|
||||||
|
#[serde(with = "serde_bytes")]
|
||||||
|
i: Vec<u8>,
|
||||||
|
p: Vec<ProofV4>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw CBOR shape of a full V4 token — single mint per token, unlike V3.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TokenV4 {
|
||||||
|
t: Vec<TokenEntryV4>,
|
||||||
|
m: String,
|
||||||
|
#[serde(default)]
|
||||||
|
u: Option<String>,
|
||||||
|
#[serde(default, rename = "d")]
|
||||||
|
memo: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// A single Cashu proof (a signed token for a specific denomination).
|
/// A single Cashu proof (a signed token for a specific denomination).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -100,31 +141,65 @@ impl CashuToken {
|
|||||||
Ok(format!("{}{}", CASHU_A_PREFIX, encoded))
|
Ok(format!("{}{}", CASHU_A_PREFIX, encoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode a cashuA token string.
|
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
|
||||||
pub fn deserialize(token_str: &str) -> Result<Self> {
|
pub fn deserialize(token_str: &str) -> Result<Self> {
|
||||||
let payload = token_str
|
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
|
||||||
.strip_prefix(CASHU_A_PREFIX)
|
return Self::deserialize_v4(payload);
|
||||||
.ok_or_else(|| anyhow::anyhow!("Token must start with '{}'", CASHU_A_PREFIX))?;
|
}
|
||||||
|
|
||||||
use base64::Engine;
|
let payload = token_str.strip_prefix(CASHU_A_PREFIX).ok_or_else(|| {
|
||||||
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
anyhow::anyhow!(
|
||||||
.decode(payload)
|
"Token must start with '{}' or '{}'",
|
||||||
.or_else(|_| {
|
CASHU_A_PREFIX,
|
||||||
// Try standard base64 as fallback (some implementations use it)
|
CASHU_B_PREFIX
|
||||||
base64::engine::general_purpose::URL_SAFE.decode(payload)
|
)
|
||||||
})
|
})?;
|
||||||
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload))
|
|
||||||
.context("Invalid base64 in cashuA token")?;
|
|
||||||
|
|
||||||
|
let decoded = decode_token_base64(payload).context("Invalid base64 in cashuA token")?;
|
||||||
let json_str = String::from_utf8(decoded).context("Invalid UTF-8 in decoded token")?;
|
let json_str = String::from_utf8(decoded).context("Invalid UTF-8 in decoded token")?;
|
||||||
let token: CashuToken =
|
let token: CashuToken =
|
||||||
serde_json::from_str(&json_str).context("Invalid JSON in cashuA token")?;
|
serde_json::from_str(&json_str).context("Invalid JSON in cashuA token")?;
|
||||||
|
|
||||||
// Structural validation
|
token.validate()?;
|
||||||
if token.token.is_empty() {
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a cashuB (V4 CBOR) token payload (prefix already stripped).
|
||||||
|
fn deserialize_v4(payload: &str) -> Result<Self> {
|
||||||
|
let decoded = decode_token_base64(payload).context("Invalid base64 in cashuB token")?;
|
||||||
|
|
||||||
|
let v4: TokenV4 =
|
||||||
|
ciborium::from_reader(decoded.as_slice()).context("Invalid CBOR in cashuB token")?;
|
||||||
|
|
||||||
|
let proofs = v4
|
||||||
|
.t
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|entry| {
|
||||||
|
let keyset_id = hex::encode(&entry.i);
|
||||||
|
entry.p.into_iter().map(move |p| Proof {
|
||||||
|
amount: p.a,
|
||||||
|
id: keyset_id.clone(),
|
||||||
|
secret: p.s,
|
||||||
|
c: hex::encode(&p.c),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let token = CashuToken {
|
||||||
|
token: vec![TokenEntry { mint: v4.m, proofs }],
|
||||||
|
memo: v4.memo,
|
||||||
|
unit: v4.u,
|
||||||
|
};
|
||||||
|
token.validate()?;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structural validation shared by both token versions.
|
||||||
|
fn validate(&self) -> Result<()> {
|
||||||
|
if self.token.is_empty() {
|
||||||
anyhow::bail!("Token has no entries");
|
anyhow::bail!("Token has no entries");
|
||||||
}
|
}
|
||||||
for entry in &token.token {
|
for entry in &self.token {
|
||||||
if entry.mint.is_empty() {
|
if entry.mint.is_empty() {
|
||||||
anyhow::bail!("Token entry has empty mint URL");
|
anyhow::bail!("Token entry has empty mint URL");
|
||||||
}
|
}
|
||||||
@ -143,11 +218,20 @@ impl CashuToken {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
Ok(token)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode a token's base64 payload, trying URL-safe-no-pad first (the spec
|
||||||
|
/// default) and falling back to other alphabets some implementations use.
|
||||||
|
fn decode_token_base64(payload: &str) -> Result<Vec<u8>, base64::DecodeError> {
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(payload)
|
||||||
|
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload))
|
||||||
|
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload))
|
||||||
|
}
|
||||||
|
|
||||||
/// Keyset info returned by a mint.
|
/// Keyset info returned by a mint.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct KeysetInfo {
|
pub struct KeysetInfo {
|
||||||
@ -303,11 +387,63 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_deserialize_rejects_invalid_prefix() {
|
fn test_deserialize_rejects_unknown_prefix() {
|
||||||
|
let result = CashuToken::deserialize("cashuZabc123");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_rejects_malformed_v4_cbor() {
|
||||||
let result = CashuToken::deserialize("cashuBabc123");
|
let result = CashuToken::deserialize("cashuBabc123");
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_v4_cbor_token() {
|
||||||
|
// Hand-built (not via our own encoder) to verify we actually match
|
||||||
|
// the NUT-00 V4 wire format: single-letter CBOR map keys, raw-byte
|
||||||
|
// keyset id ("i") and signature ("c").
|
||||||
|
use base64::Engine;
|
||||||
|
use ciborium::value::Value;
|
||||||
|
|
||||||
|
let keyset_id = vec![0x00u8, 0x9a, 0x1f, 0x29, 0x32, 0x53, 0xe4, 0x1e];
|
||||||
|
let sig = hex::decode(
|
||||||
|
"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let proof = Value::Map(vec![
|
||||||
|
(Value::from("a"), Value::from(8u64)),
|
||||||
|
(Value::from("s"), Value::from("abcdef1234567890")),
|
||||||
|
(Value::from("c"), Value::from(sig.clone())),
|
||||||
|
]);
|
||||||
|
let entry = Value::Map(vec![
|
||||||
|
(Value::from("i"), Value::from(keyset_id.clone())),
|
||||||
|
(Value::from("p"), Value::Array(vec![proof])),
|
||||||
|
]);
|
||||||
|
let token = Value::Map(vec![
|
||||||
|
(Value::from("t"), Value::Array(vec![entry])),
|
||||||
|
(Value::from("m"), Value::from("http://127.0.0.1:8175")),
|
||||||
|
(Value::from("u"), Value::from("sat")),
|
||||||
|
(Value::from("d"), Value::from("test token")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
ciborium::into_writer(&token, &mut buf).unwrap();
|
||||||
|
let encoded = format!(
|
||||||
|
"cashuB{}",
|
||||||
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf)
|
||||||
|
);
|
||||||
|
|
||||||
|
let decoded = CashuToken::deserialize(&encoded).unwrap();
|
||||||
|
assert_eq!(decoded.total_amount(), 8);
|
||||||
|
assert_eq!(decoded.token[0].mint, "http://127.0.0.1:8175");
|
||||||
|
assert_eq!(decoded.token[0].proofs[0].secret, "abcdef1234567890");
|
||||||
|
assert_eq!(decoded.token[0].proofs[0].id, hex::encode(&keyset_id));
|
||||||
|
assert_eq!(decoded.token[0].proofs[0].c, hex::encode(&sig));
|
||||||
|
assert_eq!(decoded.memo, Some("test token".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_amount_to_denominations() {
|
fn test_amount_to_denominations() {
|
||||||
assert_eq!(amount_to_denominations(0), Vec::<u64>::new());
|
assert_eq!(amount_to_denominations(0), Vec::<u64>::new());
|
||||||
|
|||||||
@ -279,6 +279,12 @@ pub struct DerivedEnv {
|
|||||||
pub struct SecretEnv {
|
pub struct SecretEnv {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub secret_file: String,
|
pub secret_file: String,
|
||||||
|
/// When true, a missing/unreadable/empty secret skips this entry instead
|
||||||
|
/// of failing the whole resolution. For integrations that exist on some
|
||||||
|
/// nodes only (btcpay's internal-LND connection string: nodes without
|
||||||
|
/// LND must still run btcpay, just without the internal node).
|
||||||
|
#[serde(default)]
|
||||||
|
pub optional: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fully resolved secret env entry, produced at apply time. `value` lives
|
/// A fully resolved secret env entry, produced at apply time. `value` lives
|
||||||
@ -1353,10 +1359,18 @@ impl ContainerConfig {
|
|||||||
) -> Result<Vec<(String, String)>, ManifestError> {
|
) -> Result<Vec<(String, String)>, ManifestError> {
|
||||||
let mut out = Vec::with_capacity(self.secret_env.len());
|
let mut out = Vec::with_capacity(self.secret_env.len());
|
||||||
for e in &self.secret_env {
|
for e in &self.secret_env {
|
||||||
let v = provider.read(&e.secret_file)?;
|
let v = match provider.read(&e.secret_file) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) if e.optional => continue,
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
// An empty secret produces e.g. `-rpcpassword=` and crashes
|
// An empty secret produces e.g. `-rpcpassword=` and crashes
|
||||||
// the container on auth before logs are useful. Fail loud.
|
// the container on auth before logs are useful. Fail loud —
|
||||||
|
// unless the entry is optional, where empty means "not set".
|
||||||
if v.trim().is_empty() {
|
if v.trim().is_empty() {
|
||||||
|
if e.optional {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
return Err(ManifestError::Invalid(format!(
|
return Err(ManifestError::Invalid(format!(
|
||||||
"secret_env {} resolved to empty value (file: {})",
|
"secret_env {} resolved to empty value (file: {})",
|
||||||
e.key, e.secret_file
|
e.key, e.secret_file
|
||||||
@ -2034,10 +2048,12 @@ app:
|
|||||||
SecretEnv {
|
SecretEnv {
|
||||||
key: "FM_BITCOIND_PASSWORD".to_string(),
|
key: "FM_BITCOIND_PASSWORD".to_string(),
|
||||||
secret_file: "bitcoin-rpc-password".to_string(),
|
secret_file: "bitcoin-rpc-password".to_string(),
|
||||||
|
optional: false,
|
||||||
},
|
},
|
||||||
SecretEnv {
|
SecretEnv {
|
||||||
key: "FM_GATEWAY_PASSWORD".to_string(),
|
key: "FM_GATEWAY_PASSWORD".to_string(),
|
||||||
secret_file: "fedimint-gateway-password".to_string(),
|
secret_file: "fedimint-gateway-password".to_string(),
|
||||||
|
optional: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
generated_secrets: vec![],
|
generated_secrets: vec![],
|
||||||
@ -2079,6 +2095,7 @@ app:
|
|||||||
secret_env: vec![SecretEnv {
|
secret_env: vec![SecretEnv {
|
||||||
key: "BITCOIN_RPC_PASS".to_string(),
|
key: "BITCOIN_RPC_PASS".to_string(),
|
||||||
secret_file: "bitcoin-rpc-password".to_string(),
|
secret_file: "bitcoin-rpc-password".to_string(),
|
||||||
|
optional: false,
|
||||||
}],
|
}],
|
||||||
generated_secrets: vec![],
|
generated_secrets: vec![],
|
||||||
generated_certs: vec![],
|
generated_certs: vec![],
|
||||||
@ -2099,6 +2116,51 @@ app:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_secret_env_skips_missing_or_empty_optional_entries() {
|
||||||
|
let c = ContainerConfig {
|
||||||
|
image: Some("x:latest".to_string()),
|
||||||
|
image_signature: None,
|
||||||
|
pull_policy: "if-not-present".to_string(),
|
||||||
|
build: None,
|
||||||
|
network: None,
|
||||||
|
network_aliases: vec![],
|
||||||
|
custom_args: vec![],
|
||||||
|
entrypoint: None,
|
||||||
|
derived_env: vec![],
|
||||||
|
secret_env: vec![
|
||||||
|
SecretEnv {
|
||||||
|
key: "REQUIRED".to_string(),
|
||||||
|
secret_file: "present".to_string(),
|
||||||
|
optional: false,
|
||||||
|
},
|
||||||
|
SecretEnv {
|
||||||
|
key: "OPT_MISSING".to_string(),
|
||||||
|
secret_file: "does-not-exist".to_string(),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
SecretEnv {
|
||||||
|
key: "OPT_EMPTY".to_string(),
|
||||||
|
secret_file: "empty".to_string(),
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generated_secrets: vec![],
|
||||||
|
generated_certs: vec![],
|
||||||
|
data_uid: None,
|
||||||
|
secret_env_refs: vec![],
|
||||||
|
secret_env_hash: None,
|
||||||
|
};
|
||||||
|
let p = MapSecretsProvider {
|
||||||
|
data: HashMap::from([
|
||||||
|
("present".to_string(), "value".to_string()),
|
||||||
|
("empty".to_string(), " \n".to_string()),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
let out = c.resolve_secret_env(&p).unwrap();
|
||||||
|
assert_eq!(out, vec!["REQUIRED=value".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unsafe_manifest_values_are_rejected() {
|
fn unsafe_manifest_values_are_rejected() {
|
||||||
let cases = [
|
let cases = [
|
||||||
|
|||||||
@ -885,8 +885,9 @@ mod tests {
|
|||||||
assert!(!image_uses_insecure_registry(
|
assert!(!image_uses_insecure_registry(
|
||||||
"23.182.128.160:3000/lfg2025/filebrowser:v2.27.0"
|
"23.182.128.160:3000/lfg2025/filebrowser:v2.27.0"
|
||||||
));
|
));
|
||||||
|
// HTTPS registries never match the insecure list.
|
||||||
assert!(!image_uses_insecure_registry(
|
assert!(!image_uses_insecure_registry(
|
||||||
"git.tx1138.com/lfg2025/bitcoin-knots:latest"
|
"ghcr.io/lfg2025/bitcoin-knots:latest"
|
||||||
));
|
));
|
||||||
assert!(!image_uses_insecure_registry(
|
assert!(!image_uses_insecure_registry(
|
||||||
"docker.io/library/nginx:latest"
|
"docker.io/library/nginx:latest"
|
||||||
|
|||||||
@ -87,4 +87,31 @@ impl Router {
|
|||||||
self.run_ok(&format!("/usr/bin/opkg remove {}", package))?;
|
self.run_ok(&format!("/usr/bin/opkg remove {}", package))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Install a standard OpenWrt package via whichever manager is active.
|
||||||
|
///
|
||||||
|
/// Unlike `tollgate-module-basic-go` itself (which falls back to manual
|
||||||
|
/// `.ipk` extraction and therefore skips dependency resolution — see
|
||||||
|
/// `tollgate::install`), packages like `nodogsplash` are in every
|
||||||
|
/// upstream OpenWrt feed, so a plain `apk add` / `opkg install` works
|
||||||
|
/// even in `ApkNative` mode.
|
||||||
|
pub fn install_package(&self, pkg_mgr: PkgManager, package: &str) -> Result<()> {
|
||||||
|
match pkg_mgr {
|
||||||
|
PkgManager::Opkg => self.opkg_install(package),
|
||||||
|
PkgManager::ApkNative => self.apk_install(package),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `apk add <package>`, skipping if already installed.
|
||||||
|
pub fn apk_install(&self, package: &str) -> Result<()> {
|
||||||
|
let (_, code) = self.run(&format!("apk info -e {} >/dev/null 2>&1", package))?;
|
||||||
|
if code == 0 {
|
||||||
|
info!("[{}] {} already installed", self.host, package);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("[{}] apk add {}", self.host, package);
|
||||||
|
self.run_ok(&format!("/usr/bin/apk add {}", package))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use ssh2::Session;
|
use ssh2::Session;
|
||||||
use std::io::Read;
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
@ -84,4 +84,22 @@ impl Router {
|
|||||||
.context("read /etc/openwrt_release — is this an OpenWrt device?")?;
|
.context("read /etc/openwrt_release — is this an OpenWrt device?")?;
|
||||||
Ok(release)
|
Ok(release)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload file contents to the router over SCP, overwriting any existing
|
||||||
|
/// file at `remote_path`. Used for config files that aren't UCI-backed
|
||||||
|
/// (e.g. `/etc/tollgate/config.json`), where `uci_*` helpers don't apply.
|
||||||
|
pub fn upload_file(&self, remote_path: &str, contents: &[u8]) -> Result<()> {
|
||||||
|
let mut channel = self
|
||||||
|
.session
|
||||||
|
.scp_send(Path::new(remote_path), 0o644, contents.len() as u64, None)
|
||||||
|
.with_context(|| format!("scp_send to {}", remote_path))?;
|
||||||
|
channel
|
||||||
|
.write_all(contents)
|
||||||
|
.with_context(|| format!("write contents to {}", remote_path))?;
|
||||||
|
channel.send_eof().context("scp send_eof")?;
|
||||||
|
channel.wait_eof().context("scp wait_eof")?;
|
||||||
|
channel.close().context("scp close")?;
|
||||||
|
channel.wait_close().context("scp wait_close")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::Router;
|
use crate::Router;
|
||||||
@ -40,7 +40,11 @@ impl Default for TollGateConfig {
|
|||||||
|
|
||||||
/// Write TollGate UCI configuration and commit.
|
/// Write TollGate UCI configuration and commit.
|
||||||
///
|
///
|
||||||
/// Maps TIP-01 / TIP-02 fields onto UCI keys used by tollgate-module-basic-go.
|
/// `tollgate-wrt` never reads UCI — see `apply_daemon_config` below for the
|
||||||
|
/// config it actually consumes. These `tollgate.main.*` keys exist only for
|
||||||
|
/// this project's own status display / detection probes (`uci get
|
||||||
|
/// tollgate.main.enabled` etc.); changing pricing or the mint here has no
|
||||||
|
/// effect on what the daemon advertises or accepts.
|
||||||
pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> {
|
pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> {
|
||||||
router.uci_apply(
|
router.uci_apply(
|
||||||
"tollgate",
|
"tollgate",
|
||||||
@ -57,3 +61,39 @@ pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write the config `tollgate-wrt` actually reads: `/etc/tollgate/config.json`
|
||||||
|
/// (schema `v0.0.6`/`v0.0.7`, see `config_manager` in the upstream Go source).
|
||||||
|
///
|
||||||
|
/// Merges into whatever config.json already exists (the daemon writes a
|
||||||
|
/// default on first boot) rather than overwriting it wholesale — fields this
|
||||||
|
/// project doesn't manage (`profit_share`, `upstream_detector`,
|
||||||
|
/// `upstream_session_manager`/`chandler`, `relays`, ...) must survive
|
||||||
|
/// re-provisioning.
|
||||||
|
///
|
||||||
|
/// Must run before the daemon is (re)started — it only reads this file at
|
||||||
|
/// startup, it does not hot-reload.
|
||||||
|
pub fn apply_daemon_config(router: &Router, cfg: &TollGateConfig) -> Result<()> {
|
||||||
|
let existing = router.run_ok("cat /etc/tollgate/config.json 2>/dev/null || echo '{}'")?;
|
||||||
|
let mut doc: serde_json::Value =
|
||||||
|
serde_json::from_str(existing.trim()).unwrap_or_else(|_| serde_json::json!({}));
|
||||||
|
|
||||||
|
doc["metric"] = serde_json::json!("milliseconds");
|
||||||
|
doc["step_size"] = serde_json::json!(cfg.step_size_ms);
|
||||||
|
doc["accepted_mints"] = serde_json::json!([{
|
||||||
|
"url": cfg.mint_url,
|
||||||
|
"min_balance": 64,
|
||||||
|
"balance_tolerance_percent": 10,
|
||||||
|
"payout_interval_seconds": 60,
|
||||||
|
"min_payout_amount": 128,
|
||||||
|
"price_per_step": cfg.price_sats,
|
||||||
|
"price_unit": "sats",
|
||||||
|
"purchase_min_steps": cfg.min_steps,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
let json_str = serde_json::to_string_pretty(&doc).context("serialize config.json")?;
|
||||||
|
router
|
||||||
|
.upload_file("/etc/tollgate/config.json", json_str.as_bytes())
|
||||||
|
.context("upload /etc/tollgate/config.json")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@ -1,21 +1,27 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod install;
|
pub mod install;
|
||||||
|
pub mod nodogsplash;
|
||||||
pub mod wifi;
|
pub mod wifi;
|
||||||
|
|
||||||
pub use config::TollGateConfig;
|
pub use config::TollGateConfig;
|
||||||
pub use install::install_tollgate;
|
pub use install::install_tollgate;
|
||||||
pub use wifi::provision_ssid;
|
pub use wifi::provision_ssid;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{opkg::PkgManager, Router};
|
use crate::{opkg::PkgManager, Router};
|
||||||
|
|
||||||
/// Full TollGate provisioning sequence:
|
/// Full TollGate provisioning sequence:
|
||||||
/// 1. Install tollgate-module-basic-go
|
/// 1. Install tollgate-module-basic-go
|
||||||
/// 2. Write TollGate UCI config (pricing, mint URL)
|
/// 2. Install NoDogSplash and immediately stop it (its postinst auto-starts
|
||||||
/// 3. Create the pay-as-you-go WiFi SSID
|
/// it against `br-lan` by default — see `nodogsplash::install_and_stop`)
|
||||||
/// 4. Restart affected services
|
/// 3. Write TollGate config: UCI (status/detection only) + the JSON file the
|
||||||
|
/// daemon actually reads
|
||||||
|
/// 4. Create the pay-as-you-go WiFi SSID and its dedicated bridge/network
|
||||||
|
/// 5. Configure NoDogSplash to gate that bridge (now that it exists) —
|
||||||
|
/// client gating; tollgate-wrt has no enforcement code of its own
|
||||||
|
/// 6. Restart affected services
|
||||||
pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
|
pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
|
||||||
info!("[{}] Starting TollGate provisioning", router.host);
|
info!("[{}] Starting TollGate provisioning", router.host);
|
||||||
|
|
||||||
@ -29,9 +35,41 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
|
|||||||
install::install_tollgate_apk_native(router)?;
|
install::install_tollgate_apk_native(router)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NoDogSplash is a hard runtime dependency of tollgate-wrt (upstream's
|
||||||
|
// package declares `+nodogsplash`), but neither install path above pulls
|
||||||
|
// it in: the opkg fast path only resolves deps against a real feed, and
|
||||||
|
// the raw .ipk-extraction fallback (used whenever the package isn't in a
|
||||||
|
// feed, and always on ApkNative) skips dependency resolution entirely.
|
||||||
|
// Without it, tollgate-wrt runs and accepts payments but never actually
|
||||||
|
// blocks unpaid clients. Install + stop happens before anything else so
|
||||||
|
// its auto-started default config (gating br-lan) is live for as little
|
||||||
|
// time as possible.
|
||||||
|
nodogsplash::install_and_stop(router, pkg_mgr)
|
||||||
|
.context("install nodogsplash — tollgate-wrt cannot gate clients without it")?;
|
||||||
|
|
||||||
|
// Wire NoDogSplash's webroot to TollGate's actual payment portal instead
|
||||||
|
// of the generic stock splash page it ships with. Confirmed live: without
|
||||||
|
// this, "click continue" on the stock page authorizes the client via
|
||||||
|
// NoDogSplash's own built-in handler with zero payment involved.
|
||||||
|
nodogsplash::install_captive_portal_symlink(router).context(
|
||||||
|
"wire up TollGate's captive portal — without it NoDogSplash serves its own \
|
||||||
|
generic splash page, which authorizes clients on click with no payment",
|
||||||
|
)?;
|
||||||
|
|
||||||
config::apply(router, config)?;
|
config::apply(router, config)?;
|
||||||
wifi::provision_ssid(router, config)?;
|
wifi::provision_ssid(router, config)?;
|
||||||
|
// Must come after provision_ssid (which creates br-tollgate) and before
|
||||||
|
// the daemon restart below — config.json is only read at startup.
|
||||||
|
config::apply_daemon_config(router, config)
|
||||||
|
.context("write /etc/tollgate/config.json — tollgate-wrt reads this, not UCI")?;
|
||||||
|
// Also must come after provision_ssid: points gatewayinterface at
|
||||||
|
// br-tollgate, which provision_ssid is what creates.
|
||||||
|
nodogsplash::configure(router, config)
|
||||||
|
.context("configure nodogsplash — tollgate-wrt cannot gate clients without it")?;
|
||||||
|
|
||||||
restart_services(router, config.enabled)?;
|
restart_services(router, config.enabled)?;
|
||||||
|
nodogsplash::restart(router)?;
|
||||||
|
|
||||||
info!("[{}] TollGate provisioning complete", router.host);
|
info!("[{}] TollGate provisioning complete", router.host);
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -57,5 +95,27 @@ fn restart_services(router: &Router, enabled: bool) -> Result<()> {
|
|||||||
// Reload wireless so wireless.tollgate.disabled takes effect on the radio —
|
// Reload wireless so wireless.tollgate.disabled takes effect on the radio —
|
||||||
// `network restart` alone doesn't reliably reconfigure wifi interfaces.
|
// `network restart` alone doesn't reliably reconfigure wifi interfaces.
|
||||||
router.run_ok("wifi down 2>&1; wifi up 2>&1")?;
|
router.run_ok("wifi down 2>&1; wifi up 2>&1")?;
|
||||||
|
// Observed live, twice, in two different ways: netifd can lose the race
|
||||||
|
// to claim br-tollgate as the wifi vif attaches to it during the restart
|
||||||
|
// above. The first time it showed up as netifd reporting
|
||||||
|
// "up: false, DEVICE_CLAIM_FAILED"; the second time netifd reported the
|
||||||
|
// interface up with its address assigned while the kernel-level device
|
||||||
|
// genuinely had none (`ip -4 addr show br-tollgate` empty) — dnsmasq
|
||||||
|
// logged "DHCP packet received on br-tollgate which has no address" and
|
||||||
|
// silently dropped every DISCOVER. A single blind ifdown/ifup isn't
|
||||||
|
// trustworthy here — verify the address actually landed at the kernel
|
||||||
|
// level (not just what netifd claims) and retry the cycle if not, since
|
||||||
|
// NoDogSplash refuses to start against an interface that isn't really up
|
||||||
|
// and dnsmasq will silently refuse to answer DHCP without erroring loudly.
|
||||||
|
router.run_ok(
|
||||||
|
"sleep 2; \
|
||||||
|
for i in 1 2 3 4 5; do \
|
||||||
|
ifdown tollgate 2>&1; sleep 1; ifup tollgate 2>&1; sleep 2; \
|
||||||
|
ip -4 addr show br-tollgate 2>/dev/null | grep -q 'inet ' && break; \
|
||||||
|
echo \"br-tollgate has no kernel-level IPv4 address after cycle $i, retrying\"; \
|
||||||
|
done; \
|
||||||
|
ip -4 addr show br-tollgate 2>/dev/null | grep -q 'inet ' || \
|
||||||
|
{ echo 'br-tollgate never got a kernel-level IPv4 address after 5 cycles'; exit 1; }"
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
139
core/openwrt/src/tollgate/nodogsplash.rs
Normal file
139
core/openwrt/src/tollgate/nodogsplash.rs
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::opkg::PkgManager;
|
||||||
|
use crate::tollgate::TollGateConfig;
|
||||||
|
use crate::Router;
|
||||||
|
|
||||||
|
/// Install NoDogSplash and immediately stop it, before configuring anything.
|
||||||
|
///
|
||||||
|
/// The OpenWrt package's postinst auto-enables and starts nodogsplash on
|
||||||
|
/// install using its stock default config — critically, `gatewayinterface`
|
||||||
|
/// defaults to `br-lan`. On a fresh install that window is real: NoDogSplash
|
||||||
|
/// only manages IPv4 iptables, so anything plugged into `br-lan` (e.g. an
|
||||||
|
/// admin's own management box) silently loses IPv4 connectivity (DHCP still
|
||||||
|
/// listens, but the gate blocks the client until ndsctl authorizes its MAC)
|
||||||
|
/// until we get a chance to repoint it — a full network re-scan can take
|
||||||
|
/// long enough for that to matter. Stopping it right after install, before
|
||||||
|
/// `configure()` ever runs, closes that window as early as possible.
|
||||||
|
///
|
||||||
|
/// `tollgate-wrt` delegates all MAC authorization and gate open/close to
|
||||||
|
/// NoDogSplash via `ndsctl` — it has no firewall/netfilter code of its own
|
||||||
|
/// (confirmed: its binary has no `nft`/`ipset`/`iptables` calls at all).
|
||||||
|
/// Upstream's package therefore hard-depends on `+nodogsplash`, but neither
|
||||||
|
/// of our install paths (see `tollgate::install`) pull it in automatically.
|
||||||
|
pub fn install_and_stop(router: &Router, pkg_mgr: PkgManager) -> Result<()> {
|
||||||
|
router
|
||||||
|
.install_package(pkg_mgr, "nodogsplash")
|
||||||
|
.context("install nodogsplash — required by tollgate-wrt for client gating")?;
|
||||||
|
router.run_ok("/etc/init.d/nodogsplash stop || true")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point NoDogSplash's webroot at TollGate's own splash page instead of the
|
||||||
|
/// generic stock one NoDogSplash ships with.
|
||||||
|
///
|
||||||
|
/// Confirmed live: without this, NoDogSplash serves its own bundled
|
||||||
|
/// click-to-continue splash page — clicking "Continue" calls NDS's built-in
|
||||||
|
/// auth handler directly and authorizes the client with zero payment
|
||||||
|
/// involved. TollGate's actual payment UI (a QR/Cashu-token entry SPA) lives
|
||||||
|
/// at `/etc/tollgate/tollgate-captive-portal-site` — the .ipk's data payload
|
||||||
|
/// stages it there (see `packaging/files/tollgate-captive-portal-site/` in
|
||||||
|
/// the upstream repo), it's just never wired up as NoDogSplash's webroot.
|
||||||
|
///
|
||||||
|
/// Mirrors upstream's own `90-tollgate-captive-portal-symlink` uci-defaults
|
||||||
|
/// script exactly (symlink swap, not a `webroot` UCI override) — confirmed
|
||||||
|
/// live that setting `option webroot` directly instead causes NoDogSplash to
|
||||||
|
/// 500 on every request, for reasons not fully understood (worth filing
|
||||||
|
/// upstream, but the symlink approach is what's actually shipped/tested).
|
||||||
|
pub fn install_captive_portal_symlink(router: &Router) -> Result<()> {
|
||||||
|
let (_, exists) = router.run("test -d /etc/tollgate/tollgate-captive-portal-site")?;
|
||||||
|
if exists != 0 {
|
||||||
|
anyhow::bail!(
|
||||||
|
"/etc/tollgate/tollgate-captive-portal-site missing — expected to be staged \
|
||||||
|
by the tollgate-wrt package install"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.run_ok(
|
||||||
|
"if [ -L /etc/nodogsplash/htdocs ]; then \
|
||||||
|
true; \
|
||||||
|
else \
|
||||||
|
if [ -d /etc/nodogsplash/htdocs ]; then \
|
||||||
|
mv /etc/nodogsplash/htdocs /etc/nodogsplash/htdocs.backup; \
|
||||||
|
fi; \
|
||||||
|
rm -rf /etc/nodogsplash/htdocs; \
|
||||||
|
ln -sf /etc/tollgate/tollgate-captive-portal-site /etc/nodogsplash/htdocs; \
|
||||||
|
fi"
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure NoDogSplash to gate the dedicated `br-tollgate` bridge (see
|
||||||
|
/// `wifi::provision_network`), not `br-lan` — the paid SSID here lives on its
|
||||||
|
/// own isolated network/subnet rather than the canonical upstream layout
|
||||||
|
/// where it's bridged into `lan`.
|
||||||
|
///
|
||||||
|
/// Must run after `wifi::provision_ssid` has created `br-tollgate` — pointing
|
||||||
|
/// `gatewayinterface` at a bridge that doesn't exist yet is at best a no-op
|
||||||
|
/// and at worst leaves NoDogSplash in a confused state.
|
||||||
|
pub fn configure(router: &Router, cfg: &TollGateConfig) -> Result<()> {
|
||||||
|
router.run_ok("touch /etc/config/nodogsplash")?;
|
||||||
|
|
||||||
|
// The nodogsplash package's own uci-defaults populate an anonymous
|
||||||
|
// `@nodogsplash[0]` section on first install, pointed at `br-lan` (its
|
||||||
|
// stock default — see `install_and_stop`). NoDogSplash supports multiple
|
||||||
|
// simultaneous gateway instances, one per config section, so leaving this
|
||||||
|
// in place alongside our own `nodogsplash.main` doesn't get overridden by
|
||||||
|
// it — it starts a *second* instance gating br-lan for real. Delete it;
|
||||||
|
// `main` is the only instance this project manages.
|
||||||
|
let _ = router.uci_delete("nodogsplash.@nodogsplash[0]");
|
||||||
|
|
||||||
|
router.uci_set("nodogsplash.main", "nodogsplash")?;
|
||||||
|
router.uci_set("nodogsplash.main.enabled", "1")?;
|
||||||
|
router.uci_set("nodogsplash.main.gatewayinterface", "br-tollgate")?;
|
||||||
|
router.uci_set("nodogsplash.main.gatewayname", &format!("{} Portal", cfg.ssid))?;
|
||||||
|
router.uci_set("nodogsplash.main.gatewaydomainname", "TollGate.lan")?;
|
||||||
|
router.uci_set("nodogsplash.main.gatewayport", "2050")?;
|
||||||
|
|
||||||
|
// Pre-auth "walled garden": traffic an unauthenticated client must still
|
||||||
|
// reach before ndsctl authorizes their MAC. `uci_delete` + rebuild (rather
|
||||||
|
// than only adding our own entries) is deliberate — the stock package
|
||||||
|
// config ships a `users_to_router` default of its own (DNS, DHCP, plus
|
||||||
|
// SSH/Telnet to the router), and `uci_set`/`add_list` on an existing
|
||||||
|
// *named* section does not clear an inherited default list, so without
|
||||||
|
// an explicit delete first, re-provisioning would silently keep
|
||||||
|
// whatever was there before.
|
||||||
|
//
|
||||||
|
// DNS (53) and DHCP (67, udp) are carried over from that stock default —
|
||||||
|
// without them a client can't even get an IP or resolve the portal
|
||||||
|
// domain before authenticating (confirmed live: omitting udp/67 here
|
||||||
|
// broke DHCP entirely for new clients on the archipelago SSID). 2121
|
||||||
|
// (TollGate payment) and 2050 (NDS's own splash portal) are ours.
|
||||||
|
// SSH/Telnet (22/23) are deliberately *not* carried over — the stock
|
||||||
|
// default exposes router shell access to every unauthenticated device
|
||||||
|
// on a public pay-as-you-go network, which is a bad default here.
|
||||||
|
let _ = router.uci_delete("nodogsplash.main.users_to_router");
|
||||||
|
router.uci_add_list("nodogsplash.main.users_to_router", "allow udp port 53")?;
|
||||||
|
router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 53")?;
|
||||||
|
router.uci_add_list("nodogsplash.main.users_to_router", "allow udp port 67")?;
|
||||||
|
router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 2121")?;
|
||||||
|
router.uci_add_list("nodogsplash.main.users_to_router", "allow tcp port 2050")?;
|
||||||
|
|
||||||
|
// Post-auth (paid) clients get full access — matches the stock package
|
||||||
|
// default (`list authenticated_users 'allow all'`), which our from-scratch
|
||||||
|
// named section never carried over. Under this router's default-ACCEPT
|
||||||
|
// FORWARD policy an empty list happens to behave the same, but that's an
|
||||||
|
// accident of this specific setup, not something to depend on.
|
||||||
|
let _ = router.uci_delete("nodogsplash.main.authenticated_users");
|
||||||
|
router.uci_add_list("nodogsplash.main.authenticated_users", "allow all")?;
|
||||||
|
|
||||||
|
router.uci_commit(Some("nodogsplash"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (Re)start the nodogsplash service so config changes and gate state take effect.
|
||||||
|
pub fn restart(router: &Router) -> Result<()> {
|
||||||
|
router.run_ok("/etc/init.d/nodogsplash enable")?;
|
||||||
|
router.run_ok("/etc/init.d/nodogsplash restart || /etc/init.d/nodogsplash start")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@ -38,14 +38,29 @@ pub fn provision_ssid(router: &Router, cfg: &TollGateConfig) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add a `tollgate` network interface (isolated LAN for TollGate clients).
|
/// Add a `tollgate` network interface (isolated LAN for TollGate clients).
|
||||||
|
///
|
||||||
|
/// Binds to a named bridge device (`br-tollgate`) rather than leaving the
|
||||||
|
/// wifi-iface as the network's raw device — NoDogSplash's `gatewayinterface`
|
||||||
|
/// needs a stable, known interface name to gate (see `nodogsplash::provision`),
|
||||||
|
/// and the driver-assigned name of a bare wifi vif (e.g. `phy0-ap0`) isn't
|
||||||
|
/// guaranteed across hardware.
|
||||||
fn provision_network(router: &Router) -> Result<()> {
|
fn provision_network(router: &Router) -> Result<()> {
|
||||||
router.uci_apply(
|
router.uci_apply(
|
||||||
"network",
|
"network",
|
||||||
&[
|
&[
|
||||||
|
("network.tollgate_bridge", "device"),
|
||||||
|
("network.tollgate_bridge.type", "bridge"),
|
||||||
|
("network.tollgate_bridge.name", "br-tollgate"),
|
||||||
("network.tollgate", "interface"),
|
("network.tollgate", "interface"),
|
||||||
|
("network.tollgate.device", "br-tollgate"),
|
||||||
("network.tollgate.proto", "static"),
|
("network.tollgate.proto", "static"),
|
||||||
("network.tollgate.ipaddr", "192.168.99.1"),
|
("network.tollgate.ipaddr", "192.168.99.1"),
|
||||||
("network.tollgate.netmask", "255.255.255.0"),
|
("network.tollgate.netmask", "255.255.255.0"),
|
||||||
|
// NoDogSplash only manages IPv4 iptables rules. If IPv6 RA/DHCPv6
|
||||||
|
// stays enabled, clients get routable IPv6 addresses and their OS
|
||||||
|
// validates connectivity (and browses freely) over IPv6, bypassing
|
||||||
|
// the portal entirely. See OpenTollGate/tollgate-module-basic-go#148.
|
||||||
|
("network.tollgate.ip6assign", "0"),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@ -58,6 +73,8 @@ fn provision_network(router: &Router) -> Result<()> {
|
|||||||
("dhcp.tollgate.start", "100"),
|
("dhcp.tollgate.start", "100"),
|
||||||
("dhcp.tollgate.limit", "150"),
|
("dhcp.tollgate.limit", "150"),
|
||||||
("dhcp.tollgate.leasetime", "5m"),
|
("dhcp.tollgate.leasetime", "5m"),
|
||||||
|
("dhcp.tollgate.ra", "disabled"),
|
||||||
|
("dhcp.tollgate.dhcpv6", "disabled"),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@ -66,8 +83,11 @@ fn provision_network(router: &Router) -> Result<()> {
|
|||||||
|
|
||||||
/// Add firewall zone for the tollgate interface.
|
/// Add firewall zone for the tollgate interface.
|
||||||
///
|
///
|
||||||
/// TollGate itself gates forwarding via iptables; the firewall zone isolates
|
/// This zone only isolates tollgate clients from other LAN segments and
|
||||||
/// tollgate clients from other LAN segments.
|
/// opens the payment port to the router. Per-client forwarding to WAN is
|
||||||
|
/// actually gated by NoDogSplash's own iptables rules (via `ndsctl`), not by
|
||||||
|
/// anything in this static firewall config — `tollgate-wrt` has no netfilter
|
||||||
|
/// code of its own. See `nodogsplash::provision`.
|
||||||
fn provision_firewall(router: &Router) -> Result<()> {
|
fn provision_firewall(router: &Router) -> Result<()> {
|
||||||
// Zone
|
// Zone
|
||||||
router.uci_apply(
|
router.uci_apply(
|
||||||
|
|||||||
@ -304,7 +304,7 @@ media (latest artifact only one minor behind).
|
|||||||
- [ ] 🟠 **Sign + checksum the ISO.** Pipeline ends at `xorriso` with no `SHA256SUMS`, no
|
- [ ] 🟠 **Sign + checksum the ISO.** Pipeline ends at `xorriso` with no `SHA256SUMS`, no
|
||||||
GPG/minisign, no Secure Boot (`BOOTX64.EFI` is unsigned though `grub-efi-amd64-signed` is
|
GPG/minisign, no Secure Boot (`BOOTX64.EFI` is unsigned though `grub-efi-amd64-signed` is
|
||||||
installed). Emit + sign checksums; wire signed Secure Boot.
|
installed). Emit + sign checksums; wire signed Secure Boot.
|
||||||
- [ ] 🟠 **Registries over HTTPS in the image too** — `146.59.87.168:3000` / `git.tx1138.com`
|
- [ ] 🟠 **Registries over HTTPS in the image too** — `146.59.87.168:3000`
|
||||||
are baked `insecure=true`/`tls_verify:false` (`:216`, `:2308`). (Ties to §A.)
|
are baked `insecure=true`/`tls_verify:false` (`:216`, `:2308`). (Ties to §A.)
|
||||||
- [ ] 🟡 **Add `unattended-upgrades` + a default-deny nftables firewall** (allow 22/80/443 +
|
- [ ] 🟡 **Add `unattended-upgrades` + a default-deny nftables firewall** (allow 22/80/443 +
|
||||||
mesh/WG). Neither exists today; OS packages drift until reflash and there is no host
|
mesh/WG). Neither exists today; OS packages drift until reflash and there is no host
|
||||||
|
|||||||
@ -842,7 +842,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<h4>Registry</h4>
|
<h4>Registry</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Private registry at <code>git.tx1138.com/lfg2025/</code></li>
|
<li>Private registry at <code>146.59.87.168:3000/lfg2025/</code></li>
|
||||||
<li>HTTPS (self-hosted Gitea)</li>
|
<li>HTTPS (self-hosted Gitea)</li>
|
||||||
<li>All images pre-pulled into registry; nodes pull on first boot</li>
|
<li>All images pre-pulled into registry; nodes pull on first boot</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -65,7 +65,7 @@ origin":
|
|||||||
|
|
||||||
### IndeeHub (the streaming target)
|
### IndeeHub (the streaming target)
|
||||||
- Original platform (not a fork). Working source: `~/Projects/Indeedhub Prototype/`
|
- Original platform (not a fork). Working source: `~/Projects/Indeedhub Prototype/`
|
||||||
(Vue 3 + NestJS). Submodule `git.tx1138.com/lfg2025/indeehub.git` (host retired —
|
(Vue 3 + NestJS). Submodule `146.59.87.168:3000/lfg2025/indeehub.git` (repointed off the retired host —
|
||||||
needs a live remote). In `archy`: image-only, `apps/indeedhub/manifest.yml` pulls
|
needs a live remote). In `archy`: image-only, `apps/indeedhub/manifest.yml` pulls
|
||||||
`146.59.87.168:3000/lfg2025/indeedhub:1.0.0` (+ `-api`, `-ffmpeg`, postgres, redis,
|
`146.59.87.168:3000/lfg2025/indeedhub:1.0.0` (+ `-api`, `-ffmpeg`, postgres, redis,
|
||||||
minio, nostr-rs-relay).
|
minio, nostr-rs-relay).
|
||||||
|
|||||||
@ -112,7 +112,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo mkdir -p /etc/containers/registries.conf.d
|
sudo mkdir -p /etc/containers/registries.conf.d
|
||||||
echo '[[registry]]
|
echo '[[registry]]
|
||||||
location = "git.tx1138.com"
|
location = "146.59.87.168:3000"
|
||||||
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
|
insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf
|
||||||
|
|
||||||
- name: Build unbundled ISO
|
- name: Build unbundled ISO
|
||||||
|
|||||||
@ -221,7 +221,7 @@ location = "146.59.87.168:3000"
|
|||||||
insecure = true
|
insecure = true
|
||||||
|
|
||||||
[[registry]]
|
[[registry]]
|
||||||
location = "git.tx1138.com"
|
location = "146.59.87.168:3000"
|
||||||
insecure = true
|
insecure = true
|
||||||
REGCONF
|
REGCONF
|
||||||
fi
|
fi
|
||||||
@ -1158,7 +1158,7 @@ fi
|
|||||||
# If built against a newer GLIBC, the binary will fail at runtime.
|
# If built against a newer GLIBC, the binary will fail at runtime.
|
||||||
# Rebuild with: FROM debian:13 AS builder
|
# Rebuild with: FROM debian:13 AS builder
|
||||||
echo " Extracting NostrVPN binary..."
|
echo " Extracting NostrVPN binary..."
|
||||||
_NVPN_IMG="${NOSTR_VPN_IMAGE:-git.tx1138.com/lfg2025/nostr-vpn:v0.3.7}"
|
_NVPN_IMG="${NOSTR_VPN_IMAGE:-146.59.87.168:3000/lfg2025/nostr-vpn:v0.3.7}"
|
||||||
NVPN_IMAGE_ID="$($CONTAINER_CMD images -q "$_NVPN_IMG" 2>/dev/null)"
|
NVPN_IMAGE_ID="$($CONTAINER_CMD images -q "$_NVPN_IMG" 2>/dev/null)"
|
||||||
if [ -z "$NVPN_IMAGE_ID" ]; then
|
if [ -z "$NVPN_IMAGE_ID" ]; then
|
||||||
$CONTAINER_CMD pull "$_NVPN_IMG" 2>/dev/null || true
|
$CONTAINER_CMD pull "$_NVPN_IMG" 2>/dev/null || true
|
||||||
@ -1189,11 +1189,11 @@ fi
|
|||||||
|
|
||||||
# Extract nostr-rs-relay binary from container image (native system service for VPN signaling)
|
# Extract nostr-rs-relay binary from container image (native system service for VPN signaling)
|
||||||
echo " Extracting nostr-rs-relay binary..."
|
echo " Extracting nostr-rs-relay binary..."
|
||||||
RELAY_IMAGE="$($CONTAINER_CMD images -q git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null)"
|
RELAY_IMAGE="$($CONTAINER_CMD images -q 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null)"
|
||||||
if [ -z "$RELAY_IMAGE" ]; then
|
if [ -z "$RELAY_IMAGE" ]; then
|
||||||
$CONTAINER_CMD pull git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null || true
|
$CONTAINER_CMD pull 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
RELAY_CONTAINER=$($CONTAINER_CMD create git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true
|
RELAY_CONTAINER=$($CONTAINER_CMD create 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true
|
||||||
if [ -n "$RELAY_CONTAINER" ]; then
|
if [ -n "$RELAY_CONTAINER" ]; then
|
||||||
$CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/local/bin/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \
|
$CONTAINER_CMD cp "$RELAY_CONTAINER:/usr/local/bin/nostr-rs-relay" "$ARCH_DIR/bin/nostr-rs-relay" 2>/dev/null && \
|
||||||
chmod +x "$ARCH_DIR/bin/nostr-rs-relay" && \
|
chmod +x "$ARCH_DIR/bin/nostr-rs-relay" && \
|
||||||
@ -2319,7 +2319,7 @@ location = "146.59.87.168:3000"
|
|||||||
insecure = true
|
insecure = true
|
||||||
|
|
||||||
[[registry]]
|
[[registry]]
|
||||||
location = "git.tx1138.com"
|
location = "146.59.87.168:3000"
|
||||||
insecure = true
|
insecure = true
|
||||||
REGCONF
|
REGCONF
|
||||||
chown -R 1000:1000 /mnt/target/home/archipelago/.config
|
chown -R 1000:1000 /mnt/target/home/archipelago/.config
|
||||||
@ -2330,7 +2330,7 @@ cat > /mnt/target/var/lib/archipelago/config/registries.json <<'DYNREG'
|
|||||||
{
|
{
|
||||||
"registries": [
|
"registries": [
|
||||||
{"url": "146.59.87.168:3000/lfg2025", "name": "Archipelago Primary", "tls_verify": false, "enabled": true, "priority": 0},
|
{"url": "146.59.87.168:3000/lfg2025", "name": "Archipelago Primary", "tls_verify": false, "enabled": true, "priority": 0},
|
||||||
{"url": "git.tx1138.com/lfg2025", "name": "Archipelago Fallback", "tls_verify": true, "enabled": true, "priority": 10}
|
{"url": "146.59.87.168:3000/lfg2025", "name": "Archipelago Fallback", "tls_verify": true, "enabled": true, "priority": 10}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
DYNREG
|
DYNREG
|
||||||
@ -2475,7 +2475,7 @@ if [ -d "$REPO_DIR/.git" ]; then
|
|||||||
exit 0 # Already cloned
|
exit 0 # Already cloned
|
||||||
fi
|
fi
|
||||||
echo "[update] Cloning Archipelago repo for self-updates..."
|
echo "[update] Cloning Archipelago repo for self-updates..."
|
||||||
su - archipelago -c "git clone https://git.tx1138.com/lfg2025/archy $REPO_DIR" 2>/dev/null || {
|
su - archipelago -c "git clone https://146.59.87.168:3000/lfg2025/archy $REPO_DIR" 2>/dev/null || {
|
||||||
echo "[update] Git clone failed (network?). Updates will retry on next boot."
|
echo "[update] Git clone failed (network?). Updates will retry on next boot."
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,25 +83,33 @@ xset s noblank 2>/dev/null || true
|
|||||||
pkill -u archipelago -f 'chromium.*localhost' 2>/dev/null || true
|
pkill -u archipelago -f 'chromium.*localhost' 2>/dev/null || true
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
# GPU vs headless (#36). On a real kiosk display with a GPU, GPU rasterization is
|
# GPU vs headless (#36, choppy-audio incident 2026-06-28). --enable-gpu-rasterization
|
||||||
# fast. On a GPU-less / headless server (no /dev/dri), --enable-gpu-rasterization
|
# spins a dedicated GPU process at 55-92% CPU even on real GPU hardware (Intel HD 5500)
|
||||||
# forces GPU paths that fall back to software compositing and SPIN a full core at
|
# because under X11 it falls back to software compositing anyway — that CPU
|
||||||
# ~92% CPU, saturating the node. Detect the GPU and pick safe flags accordingly.
|
# starvation is what caused choppy HDMI audio. --in-process-gpu avoids the
|
||||||
|
# separate process; GpuRasterization is also disabled via --disable-features below.
|
||||||
|
# On a GPU-less / headless server (no /dev/dri), disable GPU entirely instead.
|
||||||
if [ -e /dev/dri/card0 ] || [ -e /dev/dri/renderD128 ]; then
|
if [ -e /dev/dri/card0 ] || [ -e /dev/dri/renderD128 ]; then
|
||||||
GPU_FLAGS="--enable-gpu-rasterization --num-raster-threads=2"
|
GPU_FLAGS="--in-process-gpu --num-raster-threads=1"
|
||||||
else
|
else
|
||||||
GPU_FLAGS="--disable-gpu --num-raster-threads=1"
|
GPU_FLAGS="--disable-gpu --num-raster-threads=1"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ARCHIPELAGO_UID=$(id -u archipelago)
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago chromium --kiosk \
|
# XDG_RUNTIME_DIR must be passed explicitly — without it Chromium's audio
|
||||||
|
# backend can't find PipeWire-Pulse's socket at /run/user/<uid>/pulse/native,
|
||||||
|
# falls back to raw ALSA "default", fails to connect, and produces no audio
|
||||||
|
# at all with no visible error (--noerrdialogs suppresses it).
|
||||||
|
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago XDG_RUNTIME_DIR=/run/user/$ARCHIPELAGO_UID chromium --kiosk \
|
||||||
--app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
|
--app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
|
||||||
--noerrdialogs \
|
--noerrdialogs \
|
||||||
--disable-infobars \
|
--disable-infobars \
|
||||||
--disable-translate \
|
--disable-translate \
|
||||||
--no-first-run \
|
--no-first-run \
|
||||||
--check-for-update-interval=31536000 \
|
--check-for-update-interval=31536000 \
|
||||||
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled \
|
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
|
||||||
--disable-session-crashed-bubble \
|
--disable-session-crashed-bubble \
|
||||||
--disable-save-password-bubble \
|
--disable-save-password-bubble \
|
||||||
--disable-suggestions-service \
|
--disable-suggestions-service \
|
||||||
|
|||||||
@ -25,8 +25,11 @@ RestartSec=5
|
|||||||
# backend (it caused the .198 receive timeout + deploy storms). Cap CPU + memory
|
# backend (it caused the .198 receive timeout + deploy storms). Cap CPU + memory
|
||||||
# so a runaway kiosk can never take the whole machine down; Delegate so the cap
|
# so a runaway kiosk can never take the whole machine down; Delegate so the cap
|
||||||
# also binds the chromium/Xorg children in this unit's cgroup.
|
# also binds the chromium/Xorg children in this unit's cgroup.
|
||||||
|
# CPUQuota=75% (0.75 cores) was too tight even for normal playback — the kiosk
|
||||||
|
# was throttled ~40% of the time, which is what caused choppy HDMI audio on
|
||||||
|
# archy-x250-exp (2026-06-28 incident). 200% (2 cores) gives enough headroom.
|
||||||
Delegate=yes
|
Delegate=yes
|
||||||
CPUQuota=75%
|
CPUQuota=200%
|
||||||
# Raised from 1500M/1200M: a Framework (Tiger Lake) kiosk sat at 806M used /
|
# Raised from 1500M/1200M: a Framework (Tiger Lake) kiosk sat at 806M used /
|
||||||
# 1.1G peak, riding the old MemoryHigh reclaim-throttle line — the throttling
|
# 1.1G peak, riding the old MemoryHigh reclaim-throttle line — the throttling
|
||||||
# itself was the perceived UI lag. Keep Max well above real peaks; High stays
|
# itself was the perceived UI lag. Keep Max well above real peaks; High stays
|
||||||
|
|||||||
7
image-recipe/configs/asound.conf
Normal file
7
image-recipe/configs/asound.conf
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
pcm.!default {
|
||||||
|
type pulse
|
||||||
|
hint.description "Default ALSA Device (via PulseAudio)"
|
||||||
|
}
|
||||||
|
ctl.!default {
|
||||||
|
type pulse
|
||||||
|
}
|
||||||
@ -40,10 +40,10 @@ EOF
|
|||||||
# Configure registries (use Docker Hub and quay.io)
|
# Configure registries (use Docker Hub and quay.io)
|
||||||
mkdir -p /home/archipelago/.config/containers/registries.conf.d
|
mkdir -p /home/archipelago/.config/containers/registries.conf.d
|
||||||
cat > /home/archipelago/.config/containers/registries.conf <<EOF
|
cat > /home/archipelago/.config/containers/registries.conf <<EOF
|
||||||
unqualified-search-registries = ["docker.io", "ghcr.io", "quay.io", "git.tx1138.com"]
|
unqualified-search-registries = ["docker.io", "ghcr.io", "quay.io", "146.59.87.168:3000"]
|
||||||
|
|
||||||
[[registry]]
|
[[registry]]
|
||||||
location = "git.tx1138.com"
|
location = "146.59.87.168:3000"
|
||||||
insecure = true
|
insecure = true
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
@ -172,7 +172,7 @@
|
|||||||
"author": "File Browser",
|
"author": "File Browser",
|
||||||
"category": "data",
|
"category": "data",
|
||||||
"tier": "core",
|
"tier": "core",
|
||||||
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
|
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
|
||||||
"repoUrl": "https://github.com/filebrowser/filebrowser",
|
"repoUrl": "https://github.com/filebrowser/filebrowser",
|
||||||
"containerConfig": {
|
"containerConfig": {
|
||||||
"ports": [
|
"ports": [
|
||||||
@ -285,7 +285,7 @@
|
|||||||
"icon": "/assets/img/app-icons/fedimint.png",
|
"icon": "/assets/img/app-icons/fedimint.png",
|
||||||
"author": "Fedimint",
|
"author": "Fedimint",
|
||||||
"category": "money",
|
"category": "money",
|
||||||
"dockerImage": "git.tx1138.com/lfg2025/gatewayd:v0.10.0",
|
"dockerImage": "146.59.87.168:3000/lfg2025/gatewayd:v0.10.0",
|
||||||
"repoUrl": "https://github.com/fedimint/fedimint",
|
"repoUrl": "https://github.com/fedimint/fedimint",
|
||||||
"containerConfig": {
|
"containerConfig": {
|
||||||
"ports": [
|
"ports": [
|
||||||
|
|||||||
@ -348,7 +348,7 @@
|
|||||||
|
|
||||||
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
|
<div class="mt-5 rounded-xl border border-white/10 bg-white/[0.04] p-4 text-sm text-white/65">
|
||||||
<p class="font-medium text-white/80 mb-2">Easy sources</p>
|
<p class="font-medium text-white/80 mb-2">Easy sources</p>
|
||||||
<p>Use images from Docker Hub, GHCR, git.tx1138.com, the VPS2 Gitea registry, or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
|
<p>Use images from Docker Hub, GHCR, the VPS2 Gitea registry (146.59.87.168:3000), or localhost. Good first candidates: Excalidraw, Stirling PDF, FreshRSS, Wallabag, HedgeDoc, CyberChef, Mealie, or PairDrop.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 flex gap-3">
|
<div class="mt-5 flex gap-3">
|
||||||
|
|||||||
@ -519,6 +519,35 @@ const walletTransactions = ref<WalletTransaction[]>([])
|
|||||||
|
|
||||||
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
|
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
|
||||||
|
|
||||||
|
// wallet.ecash-history's shape (see handle_wallet_ecash_history in
|
||||||
|
// api/rpc/wallet.rs) — distinct from the LND-shaped WalletTransaction used
|
||||||
|
// elsewhere in this file, so it's mapped into that shape below rather than
|
||||||
|
// widening WalletTransaction itself with a pile of ecash-only fields.
|
||||||
|
interface EcashTransaction {
|
||||||
|
id: string
|
||||||
|
tx_type: 'send' | 'receive'
|
||||||
|
amount_sats: number
|
||||||
|
timestamp: string
|
||||||
|
description: string
|
||||||
|
mint_url: string
|
||||||
|
peer: string
|
||||||
|
kind: 'cashu' | 'fedimint'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ecashToWalletTransaction(tx: EcashTransaction): WalletTransaction {
|
||||||
|
return {
|
||||||
|
tx_hash: tx.id,
|
||||||
|
amount_sats: tx.amount_sats,
|
||||||
|
direction: tx.tx_type === 'receive' ? 'incoming' : 'outgoing',
|
||||||
|
num_confirmations: 1,
|
||||||
|
time_stamp: Math.floor(new Date(tx.timestamp).getTime() / 1000),
|
||||||
|
total_fees: 0,
|
||||||
|
dest_addresses: [],
|
||||||
|
label: tx.description,
|
||||||
|
block_height: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadWeb5Status() {
|
async function loadWeb5Status() {
|
||||||
// A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when
|
// A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when
|
||||||
// there is a balance"). On failure keep the last-known value — the refs start
|
// there is a balance"). On failure keep the last-known value — the refs start
|
||||||
@ -526,7 +555,15 @@ async function loadWeb5Status() {
|
|||||||
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false }
|
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false }
|
||||||
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
|
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||||
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
|
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||||
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { /* keep last-known transactions */ }
|
// Merge LND transactions with ecash/Fedimint history (wallet.ecash-history
|
||||||
|
// already unifies both) — previously only LND transactions were fetched
|
||||||
|
// here, so any Cashu or Fedimint receive (e.g. a TollGate payment) never
|
||||||
|
// appeared in the Transactions modal even though the balance included it.
|
||||||
|
let lndTxs: WalletTransaction[] = []
|
||||||
|
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); lndTxs = res.transactions || [] } catch { /* keep last-known transactions */ }
|
||||||
|
let ecashTxs: WalletTransaction[] = []
|
||||||
|
try { const res = await rpcClient.call<{ transactions: EcashTransaction[] }>({ method: 'wallet.ecash-history', timeout: 5000 }); ecashTxs = (res.transactions || []).map(ecashToWalletTransaction) } catch { /* keep last-known transactions */ }
|
||||||
|
walletTransactions.value = [...lndTxs, ...ecashTxs].sort((a, b) => b.time_stamp - a.time_stamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// System stats
|
// System stats
|
||||||
|
|||||||
@ -24,7 +24,7 @@ const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache
|
|||||||
|
|
||||||
/** Catalog URLs tried in order. First success wins.
|
/** Catalog URLs tried in order. First success wins.
|
||||||
* Primary is the backend proxy (`/api/app-catalog`) — server-side fetch
|
* Primary is the backend proxy (`/api/app-catalog`) — server-side fetch
|
||||||
* bypasses CORS on git.tx1138.com and CSP restrictions on the IP-port
|
* bypasses CORS on the upstream Gitea and CSP restrictions on the IP-port
|
||||||
* fallback. If the backend is offline (mid-restart etc.) we fall back
|
* fallback. If the backend is offline (mid-restart etc.) we fall back
|
||||||
* to the static copy baked into the frontend build. */
|
* to the static copy baked into the frontend build. */
|
||||||
const CATALOG_URLS = [
|
const CATALOG_URLS = [
|
||||||
|
|||||||
@ -1405,7 +1405,7 @@ init()
|
|||||||
<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>Updates survive network hiccups. Downloads now resume from exactly where a dropped connection left off, and retry up to 6 times with increasing gaps between attempts, instead of restarting from byte zero or giving up.</p>
|
<p>Updates survive network hiccups. Downloads now resume from exactly where a dropped connection left off, and retry up to 6 times with increasing gaps between attempts, instead of restarting from byte zero or giving up.</p>
|
||||||
<p>The download progress bar now shows real progress. Instead of a fake number that creeps to 95% and freezes, you see the actual bytes arriving, and it continues to update correctly even if you navigate away and come back.</p>
|
<p>The download progress bar now shows real progress. Instead of a fake number that creeps to 95% and freezes, you see the actual bytes arriving, and it continues to update correctly even if you navigate away and come back.</p>
|
||||||
<p>Update check itself retries on slow responses. If git.tx1138.com is momentarily overloaded, the node tries three times with a five-second wait between attempts before concluding you're up to date.</p>
|
<p>Update check itself retries on slow responses. If the release server is momentarily overloaded, the node tries three times with a five-second wait between attempts before concluding you're up to date.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- v1.7.14-alpha -->
|
<!-- v1.7.14-alpha -->
|
||||||
@ -1591,7 +1591,7 @@ init()
|
|||||||
<span class="text-xs text-white/40">Apr 11, 2026</span>
|
<span class="text-xs text-white/40">Apr 11, 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>Migrated container registry to git.tx1138.com</p>
|
<p>Migrated container registry to the self-hosted Gitea</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- v1.3.4 -->
|
<!-- v1.3.4 -->
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -78,7 +78,7 @@ if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^electrumx$'; then
|
|||||||
-e "DAEMON_URL=http://${RPC_USER}:${RPC_PASS}@bitcoin-knots:8332/" \
|
-e "DAEMON_URL=http://${RPC_USER}:${RPC_PASS}@bitcoin-knots:8332/" \
|
||||||
-e COIN=Bitcoin -e DB_DIRECTORY=/data \
|
-e COIN=Bitcoin -e DB_DIRECTORY=/data \
|
||||||
-e "SERVICES=tcp://:50001,rpc://0.0.0.0:8000" \
|
-e "SERVICES=tcp://:50001,rpc://0.0.0.0:8000" \
|
||||||
"${ELECTRUMX_IMAGE:-git.tx1138.com/lfg2025/electrumx:v1.18.0}"
|
"${ELECTRUMX_IMAGE:-146.59.87.168:3000/lfg2025/electrumx:v1.18.0}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Mempool API
|
# Mempool API
|
||||||
@ -98,7 +98,7 @@ if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^mempool-api$'; th
|
|||||||
-e "DATABASE_ENABLED=true" -e "DATABASE_HOST=archy-mempool-db" \
|
-e "DATABASE_ENABLED=true" -e "DATABASE_HOST=archy-mempool-db" \
|
||||||
-e "DATABASE_DATABASE=mempool" -e "DATABASE_USERNAME=mempool" \
|
-e "DATABASE_DATABASE=mempool" -e "DATABASE_USERNAME=mempool" \
|
||||||
-e "DATABASE_PASSWORD=$(cat "$SECRETS_DIR/mempool-db-password" 2>/dev/null || echo mempoolpass)" \
|
-e "DATABASE_PASSWORD=$(cat "$SECRETS_DIR/mempool-db-password" 2>/dev/null || echo mempoolpass)" \
|
||||||
"${MEMPOOL_API_IMAGE:-git.tx1138.com/lfg2025/mempool-api:v3.2.0}"
|
"${MEMPOOL_API_IMAGE:-146.59.87.168:3000/lfg2025/mempool-api:v3.2.0}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stop Tor tunnel if it was active
|
# Stop Tor tunnel if it was active
|
||||||
|
|||||||
@ -16,6 +16,9 @@
|
|||||||
# 7. Containers stuck with exit code 127 (binary not found)
|
# 7. Containers stuck with exit code 127 (binary not found)
|
||||||
# 8. Stopped core containers (rootless restart policy workaround)
|
# 8. Stopped core containers (rootless restart policy workaround)
|
||||||
# 9. Missing rootless port listeners while Podman still shows published ports
|
# 9. Missing rootless port listeners while Podman still shows published ports
|
||||||
|
# 10. Nginx Proxy Manager public hosts not mirrored into host nginx
|
||||||
|
# 11. BTCPay stores producing unpayable Lightning invoices (route hints off)
|
||||||
|
# 12. Missing catatonit (Podman init binary) — init-enabled deploys fail
|
||||||
#
|
#
|
||||||
# Safe to run multiple times (idempotent). Never blocks deploy (exit 0 always).
|
# Safe to run multiple times (idempotent). Never blocks deploy (exit 0 always).
|
||||||
#
|
#
|
||||||
@ -383,10 +386,22 @@ print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]]))
|
|||||||
|
|
||||||
# ── Fix 8: Rootless netns egress lost ────────────────────────
|
# ── Fix 8: Rootless netns egress lost ────────────────────────
|
||||||
# Rootless podman uses pasta to give containers internet egress. If pasta's
|
# Rootless podman uses pasta to give containers internet egress. If pasta's
|
||||||
# tap vanishes (host link flap, mount churn), the rootless-netns keeps inter-
|
# tap vanishes (host link flap, mount churn, pasta dying during a boot-time
|
||||||
# container traffic working but silently loses outbound. Bitcoin IBD stalls
|
# restart storm), the rootless-netns keeps inter-container traffic working
|
||||||
# at 0 peers; package pulls fail. The only reliable repair is a stop-all/
|
# but silently loses outbound. Bitcoin IBD stalls at 0 peers; package pulls
|
||||||
# start-all cycle so pasta + aardvark-dns rebuild the netns from scratch.
|
# fail. The repair must rebuild the netns from scratch: merely cycling the
|
||||||
|
# containers reuses the existing (broken) netns because its holders
|
||||||
|
# (aardvark-dns, podman's pause process) survive — observed on shorty-s
|
||||||
|
# 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() {
|
fix_rootless_netns_egress() {
|
||||||
# Needs root for nsenter. When doctor runs as the rootless container owner,
|
# Needs root for nsenter. When doctor runs as the rootless container owner,
|
||||||
# a failed nsenter probe is a permissions artifact, not evidence of broken
|
# a failed nsenter probe is a permissions artifact, not evidence of broken
|
||||||
@ -410,16 +425,28 @@ fix_rootless_netns_egress() {
|
|||||||
# Probe egress from inside the rootless-netns. One probe is noisy;
|
# Probe egress from inside the rootless-netns. One probe is noisy;
|
||||||
# require two consecutive failures 10s apart to rule out transients.
|
# 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
|
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
|
return 1 # first probe succeeded
|
||||||
fi
|
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)
|
||||||
[ -z "$aardvark_pid" ] && return 1
|
[ -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
|
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
|
return 1 # recovered on its own
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "Rootless-netns egress is broken (host online, container netns unreachable) — cycling"
|
# 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
|
||||||
|
fi
|
||||||
|
|
||||||
|
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 PODMANCMD="sudo -u archipelago XDG_RUNTIME_DIR=/run/user/$archi_uid podman"
|
||||||
local running
|
local running
|
||||||
@ -435,6 +462,19 @@ fix_rootless_netns_egress() {
|
|||||||
$PODMANCMD stop --all --time 30 >/dev/null 2>&1
|
$PODMANCMD stop --all --time 30 >/dev/null 2>&1
|
||||||
sleep 5
|
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..."
|
log " Starting containers back up..."
|
||||||
for c in $running; do
|
for c in $running; do
|
||||||
$PODMANCMD start "$c" >/dev/null 2>&1 &
|
$PODMANCMD start "$c" >/dev/null 2>&1 &
|
||||||
@ -445,8 +485,11 @@ fix_rootless_netns_egress() {
|
|||||||
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
|
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)"
|
log " Rootless-netns egress restored ($count containers cycled)"
|
||||||
|
rm -f "$NETNS_CYCLE_STATE"
|
||||||
else
|
else
|
||||||
log " WARN: egress still broken after cycle — may need manual intervention"
|
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
|
fi
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@ -526,6 +569,51 @@ fix_npm_public_hosts() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Fix 12: BTCPay Lightning route hints ─────────────────────
|
||||||
|
# A BTCPay store whose LND node has only private (unannounced) channels
|
||||||
|
# produces BOLT11 invoices that external wallets cannot route to unless the
|
||||||
|
# store's lightningPrivateRouteHints flag is on — payers see "no way to pay
|
||||||
|
# this invoice" (observed on shorty-s 2026-07-10 with a Blink payer). Route
|
||||||
|
# hints are a no-op with public channels and essential with private ones, so
|
||||||
|
# the doctor enforces the flag on every store. BTCPay reads store blobs from
|
||||||
|
# Postgres per request; no restart needed.
|
||||||
|
fix_btcpay_route_hints() {
|
||||||
|
local state
|
||||||
|
state=$(podman_rootless inspect archy-btcpay-db --format '{{.State.Status}}' 2>/dev/null || echo "missing")
|
||||||
|
[ "$state" = "running" ] || return 1
|
||||||
|
|
||||||
|
local count
|
||||||
|
count=$(podman_rootless exec archy-btcpay-db psql -U btcpay -d btcpay -t -A -c \
|
||||||
|
"SELECT count(*) FROM \"Stores\" WHERE (\"StoreBlob\"->>'lightningPrivateRouteHints') = 'false';" 2>/dev/null)
|
||||||
|
[ -n "$count" ] && [ "$count" -gt 0 ] 2>/dev/null || return 1
|
||||||
|
|
||||||
|
if podman_rootless exec archy-btcpay-db psql -U btcpay -d btcpay -q -c \
|
||||||
|
"UPDATE \"Stores\" SET \"StoreBlob\" = jsonb_set(\"StoreBlob\", '{lightningPrivateRouteHints}', 'true'::jsonb) WHERE (\"StoreBlob\"->>'lightningPrivateRouteHints') = 'false';" >/dev/null 2>&1; then
|
||||||
|
log "Enabled Lightning route hints on $count BTCPay store(s) (private-channel invoices were unpayable)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Fix 13: Missing catatonit (container init binary) ────────
|
||||||
|
# Podman resolves `--init` (and any Portainer/compose deploy with
|
||||||
|
# "init: true") through catatonit; Debian's podman package only
|
||||||
|
# Recommends it, so a node installed or upgraded without it fails those
|
||||||
|
# deploys with a missing-init error (observed on shorty-s 2026-07-10
|
||||||
|
# deploying sites via Portainer). install-podman.sh covers fresh ISO
|
||||||
|
# installs; this heals nodes that predate it.
|
||||||
|
fix_missing_catatonit() {
|
||||||
|
command -v catatonit >/dev/null 2>&1 && return 1
|
||||||
|
command -v apt-get >/dev/null 2>&1 || return 1
|
||||||
|
|
||||||
|
if DEBIAN_FRONTEND=noninteractive apt-get install -y catatonit >/dev/null 2>&1; then
|
||||||
|
log "Installed catatonit (init-enabled container deploys were failing)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "WARNING: catatonit missing and apt-get install failed — init-enabled deploys will fail"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
# If remote host provided, run via SSH
|
# If remote host provided, run via SSH
|
||||||
@ -556,6 +644,8 @@ 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
|
||||||
|
run_fix "btcpay-route-hints" fix_btcpay_route_hints
|
||||||
|
run_fix "catatonit" fix_missing_catatonit
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
if [ $FIXES_APPLIED -gt 0 ]; then
|
if [ $FIXES_APPLIED -gt 0 ]; then
|
||||||
|
|||||||
@ -74,7 +74,7 @@ mkdir -p "$BUILD_DIR"
|
|||||||
|
|
||||||
# Create Dockerfile
|
# Create Dockerfile
|
||||||
cat > "$BUILD_DIR/Dockerfile" << 'EOF'
|
cat > "$BUILD_DIR/Dockerfile" << 'EOF'
|
||||||
FROM ${NGINX_ALPINE_IMAGE:-git.tx1138.com/lfg2025/nginx:1.29.6-alpine}
|
FROM ${NGINX_ALPINE_IMAGE:-146.59.87.168:3000/lfg2025/nginx:1.29.6-alpine}
|
||||||
|
|
||||||
# Copy the static UI
|
# Copy the static UI
|
||||||
COPY index.html /usr/share/nginx/html/
|
COPY index.html /usr/share/nginx/html/
|
||||||
|
|||||||
@ -425,6 +425,22 @@ deploy_node() {
|
|||||||
' 2>/dev/null || true
|
' 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Deploy ALSA default-device config ────────────────────────────
|
||||||
|
ASOUND_CONF="$PROJECT_DIR/image-recipe/configs/asound.conf"
|
||||||
|
if [ -f "$ASOUND_CONF" ]; then
|
||||||
|
step "Deploying ALSA default-device config"
|
||||||
|
scp $SSH_OPTS "$ASOUND_CONF" "$TARGET:/tmp/asound.conf" 2>/dev/null || true
|
||||||
|
ssh $SSH_OPTS "$TARGET" '
|
||||||
|
if ! diff -q /tmp/asound.conf /etc/asound.conf >/dev/null 2>&1; then
|
||||||
|
sudo cp /tmp/asound.conf /etc/asound.conf
|
||||||
|
echo " Installed"
|
||||||
|
else
|
||||||
|
echo " Unchanged"
|
||||||
|
fi
|
||||||
|
rm -f /tmp/asound.conf
|
||||||
|
' 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Step 18: NTP + swap ──────────────────────────────────────────
|
# ── Step 18: NTP + swap ──────────────────────────────────────────
|
||||||
step "Ensuring NTP + swap"
|
step "Ensuring NTP + swap"
|
||||||
ssh $SSH_OPTS "$TARGET" '
|
ssh $SSH_OPTS "$TARGET" '
|
||||||
|
|||||||
@ -463,6 +463,22 @@ deploy_secondary() {
|
|||||||
' 2>/dev/null || true
|
' 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Deploy ALSA default-device config (routes ALSA "default" through PulseAudio/PipeWire)
|
||||||
|
ASOUND_CONF="$PROJECT_DIR/image-recipe/configs/asound.conf"
|
||||||
|
if [ -f "$ASOUND_CONF" ]; then
|
||||||
|
echo " Syncing ALSA default-device config to .$SEC_LABEL..."
|
||||||
|
scp $SSH_OPTS "$ASOUND_CONF" "$SEC_TARGET:/tmp/asound.conf" 2>/dev/null || true
|
||||||
|
ssh $SSH_OPTS "$SEC_TARGET" '
|
||||||
|
if ! diff -q /tmp/asound.conf /etc/asound.conf >/dev/null 2>&1; then
|
||||||
|
sudo cp /tmp/asound.conf /etc/asound.conf
|
||||||
|
echo " ALSA default-device config installed"
|
||||||
|
else
|
||||||
|
echo " ALSA default-device config unchanged"
|
||||||
|
fi
|
||||||
|
rm -f /tmp/asound.conf
|
||||||
|
' 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
# Dev mode + FileBrowser
|
# Dev mode + FileBrowser
|
||||||
ssh $SSH_OPTS "$SEC_TARGET" '
|
ssh $SSH_OPTS "$SEC_TARGET" '
|
||||||
# Dev mode
|
# Dev mode
|
||||||
@ -805,6 +821,23 @@ if [ "$LIVE" = true ]; then
|
|||||||
' 2>/dev/null || true
|
' 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Deploy ALSA default-device config (routes ALSA "default" through
|
||||||
|
# PulseAudio/PipeWire — without it Chromium's raw ALSA fallback can't
|
||||||
|
# reach the HDMI sink and kiosk HDMI audio is silent).
|
||||||
|
ASOUND_CONF="$PROJECT_DIR/image-recipe/configs/asound.conf"
|
||||||
|
if [ -f "$ASOUND_CONF" ]; then
|
||||||
|
scp $SSH_OPTS "$ASOUND_CONF" "$TARGET_HOST:/tmp/asound.conf" 2>/dev/null || true
|
||||||
|
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||||
|
if ! diff -q /tmp/asound.conf /etc/asound.conf >/dev/null 2>&1; then
|
||||||
|
sudo cp /tmp/asound.conf /etc/asound.conf
|
||||||
|
echo " ALSA default-device config installed"
|
||||||
|
else
|
||||||
|
echo " ALSA default-device config unchanged"
|
||||||
|
fi
|
||||||
|
rm -f /tmp/asound.conf
|
||||||
|
' 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
# Deploy Claude API proxy (auto-install if missing)
|
# Deploy Claude API proxy (auto-install if missing)
|
||||||
progress "Setting up Claude API proxy"
|
progress "Setting up Claude API proxy"
|
||||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||||
|
|||||||
@ -146,7 +146,7 @@ run_smoke_tests() {
|
|||||||
|
|
||||||
# Test 3: Install a lightweight container (filebrowser — small, fast, no deps)
|
# Test 3: Install a lightweight container (filebrowser — small, fast, no deps)
|
||||||
TESTS=$((TESTS + 1))
|
TESTS=$((TESTS + 1))
|
||||||
local install_img="git.tx1138.com/lfg2025/filebrowser:v2.27.0"
|
local install_img="146.59.87.168:3000/lfg2025/filebrowser:v2.27.0"
|
||||||
# Check if already installed
|
# Check if already installed
|
||||||
local fb_state
|
local fb_state
|
||||||
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'none'")
|
fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'none'")
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
#
|
#
|
||||||
# Image versions: sourced from /opt/archipelago/image-versions.sh (single source of truth).
|
# Image versions: sourced from /opt/archipelago/image-versions.sh (single source of truth).
|
||||||
# All container image references use the $*_IMAGE variables defined there.
|
# All container image references use the $*_IMAGE variables defined there.
|
||||||
# Images pull from the Archipelago app registry (git.tx1138.com/lfg2025/).
|
# Images pull from the Archipelago app registry (146.59.87.168:3000/lfg2025/).
|
||||||
#
|
#
|
||||||
# --- PLANNED REFACTOR (post-beta) ---
|
# --- PLANNED REFACTOR (post-beta) ---
|
||||||
# This script is ~995 lines and should be split into a modular library.
|
# This script is ~995 lines and should be split into a modular library.
|
||||||
|
|||||||
@ -11,7 +11,8 @@
|
|||||||
|
|
||||||
# Archipelago app registries (primary + fallback)
|
# Archipelago app registries (primary + fallback)
|
||||||
ARCHY_REGISTRY="146.59.87.168:3000/lfg2025"
|
ARCHY_REGISTRY="146.59.87.168:3000/lfg2025"
|
||||||
ARCHY_REGISTRY_FALLBACK="git.tx1138.com/lfg2025"
|
# No fallback registry: the old tx1138 registry host was retired (2026-06-13); empty disables the fallback path.
|
||||||
|
ARCHY_REGISTRY_FALLBACK=""
|
||||||
|
|
||||||
# Bitcoin stack
|
# Bitcoin stack
|
||||||
BITCOIN_KNOTS_IMAGE="$ARCHY_REGISTRY/bitcoin-knots:latest"
|
BITCOIN_KNOTS_IMAGE="$ARCHY_REGISTRY/bitcoin-knots:latest"
|
||||||
|
|||||||
@ -304,7 +304,7 @@ resolve_spec_image() {
|
|||||||
"${ARCHY_REGISTRY_FALLBACK:-}/${image_path}" \
|
"${ARCHY_REGISTRY_FALLBACK:-}/${image_path}" \
|
||||||
"80.71.235.15:3000/archipelago/${image_name}:${image_tag}" \
|
"80.71.235.15:3000/archipelago/${image_name}:${image_tag}" \
|
||||||
"80.71.235.15:3000/lfg2025/${image_name}:${image_tag}"; do
|
"80.71.235.15:3000/lfg2025/${image_name}:${image_tag}"; do
|
||||||
[ "$candidate" = "/" ] && continue
|
case "$candidate" in /*) continue;; esac
|
||||||
if image_exists "$candidate"; then
|
if image_exists "$candidate"; then
|
||||||
info "$SPEC_NAME — using local image alias $candidate"
|
info "$SPEC_NAME — using local image alias $candidate"
|
||||||
SPEC_IMAGE="$candidate"
|
SPEC_IMAGE="$candidate"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Self-update: pull latest code from git.tx1138.com and apply
|
# Self-update: pull latest code from the OVH Gitea (146.59.87.168:3000) and apply
|
||||||
# Designed to run on installed Archipelago nodes (as archipelago user)
|
# Designed to run on installed Archipelago nodes (as archipelago user)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
@ -8,7 +8,7 @@
|
|||||||
# ./self-update.sh --force # Apply even if already up to date
|
# ./self-update.sh --force # Apply even if already up to date
|
||||||
#
|
#
|
||||||
# The script:
|
# The script:
|
||||||
# 1. Pulls latest code from origin (git.tx1138.com)
|
# 1. Pulls latest code from origin (146.59.87.168:3000)
|
||||||
# 2. Builds the Rust backend (release mode)
|
# 2. Builds the Rust backend (release mode)
|
||||||
# 3. Builds the Vue frontend (production mode)
|
# 3. Builds the Vue frontend (production mode)
|
||||||
# 4. Installs the new binary and web UI
|
# 4. Installs the new binary and web UI
|
||||||
@ -69,7 +69,7 @@ done
|
|||||||
# Ensure repo exists
|
# Ensure repo exists
|
||||||
if [ ! -d "$REPO_DIR/.git" ]; then
|
if [ ! -d "$REPO_DIR/.git" ]; then
|
||||||
err "Repo not found at $REPO_DIR"
|
err "Repo not found at $REPO_DIR"
|
||||||
err "Clone it first: git clone https://git.tx1138.com/lfg2025/archy ~/archy"
|
err "Clone it first: git clone http://146.59.87.168:3000/lfg2025/archy ~/archy"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@ -86,7 +86,7 @@ else
|
|||||||
|
|
||||||
# Check trusted registry
|
# Check trusted registry
|
||||||
TRUSTED=false
|
TRUSTED=false
|
||||||
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "git.tx1138.com"; do
|
for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000"; do
|
||||||
if echo "$IMAGE" | grep -q "$reg"; then
|
if echo "$IMAGE" | grep -q "$reg"; then
|
||||||
TRUSTED=true
|
TRUSTED=true
|
||||||
break
|
break
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user