Compare commits

..
11 changed files with 217 additions and 65 deletions
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## v1.7.65-alpha (2026-05-18)
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
- Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
## v1.7.64-alpha (2026-05-18)
- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows.
- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing.
## v1.7.63-alpha (2026-05-18)
- Release automation now rebuilds the Rust backend after bumping the version and before hashing release artifacts, preventing OTA manifests from pointing at a stale backend binary.
- This corrected release carries the Nginx Proxy Manager stale-port repair in an updated backend binary, so nodes running `1.7.61-alpha` can actually receive and execute the fix.
- Validation confirmed the previously published `v1.7.62-alpha` backend artifact still contained `1.7.61-alpha`, explaining why nodes did not advance after applying that update.
## v1.7.62-alpha (2026-05-18)
- Nginx Proxy Manager start and restart now repair stale Podman containers that still publish the admin UI on host port `81`, which conflicts with host nginx on updated nodes.
- The repair recreates only the stale Nginx Proxy Manager container metadata while preserving `/var/lib/archipelago/nginx-proxy-manager` data and using the current `8081:81`, `8084:80`, and `8444:443` mappings.
- Runtime stale-listener cleanup for Nginx Proxy Manager is shared across start and restart paths so rootless port helper leftovers are still cleared before lifecycle retries.
- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml` and `cargo check -p archipelago --manifest-path core/Cargo.toml`.
## v1.7.61-alpha (2026-05-18)
- Multi-container stack installs now keep their app card in the `Installing` state for up to 20 minutes while dependency containers are being pulled and prepared.
- BTCPay Server installs no longer appear to vanish or fail after two minutes while Postgres and NBXplorer are still being created before the primary `btcpay-server` container exists.
- The stale-transition escape hatch remains short for start, stop, restart, update, and removal operations, so genuinely wedged lifecycle actions still recover quickly.
- Live validation on `100.70.96.88` confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect.
## v1.7.60-alpha (2026-05-18)
- Meshtastic serial detection now rejects malformed or incomplete handshakes instead of accepting unrelated serial devices as a fallback Meshtastic radio.
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.60-alpha"
version = "1.7.65-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.60-alpha"
version = "1.7.65-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -1,4 +1,7 @@
use super::config::{get_containers_for_app, get_data_dirs_for_app, is_valid_docker_image};
use super::config::{
get_app_capabilities, get_containers_for_app, get_data_dirs_for_app, get_health_check_args,
get_memory_limit, is_valid_docker_image,
};
use super::dependencies::ordered_containers_for_start;
use super::install::install_log;
use super::validation::validate_app_id;
@@ -665,6 +668,7 @@ async fn do_orchestrator_package_start(
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
repair_before_package_start(name).await;
match orchestrator.start(name).await {
Ok(()) => wait_after_orchestrator_start(name).await,
Err(e) if is_unknown_app_id_error(&e) => {
@@ -863,11 +867,98 @@ async fn repair_before_package_start(container_name: &str) {
repair_nextcloud_dirs().await;
cleanup_stale_pasta_port("8085").await;
}
"nginx-proxy-manager" => repair_nginx_proxy_manager_container().await,
"gitea" => cleanup_gitea_stale_ports().await,
_ => {}
}
}
async fn repair_nginx_proxy_manager_container() {
if !nginx_proxy_manager_has_legacy_admin_port().await {
cleanup_nginx_proxy_manager_ports().await;
return;
}
install_log(
"START REPAIR: nginx-proxy-manager - recreating stale container using host port 8081",
)
.await;
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
cleanup_nginx_proxy_manager_ports().await;
if let Err(err) = recreate_nginx_proxy_manager_container().await {
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
}
}
async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
let Ok(output) = podman_control(&["port", "nginx-proxy-manager", "81/tcp"]).await else {
return false;
};
if !output.status.success() {
return false;
}
String::from_utf8_lossy(&output.stdout).lines().any(|line| {
line.rsplit(':')
.next()
.is_some_and(|port| port.trim() == "81")
})
}
async fn recreate_nginx_proxy_manager_container() -> Result<()> {
tokio::process::Command::new("sudo")
.args([
"mkdir",
"-p",
"/var/lib/archipelago/nginx-proxy-manager/data",
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
])
.output()
.await
.context("failed to create nginx-proxy-manager data directories")?;
let image = crate::container::image_versions::pinned_image_for_app("nginx-proxy-manager")
.unwrap_or_else(|| "docker.io/jc21/nginx-proxy-manager:latest".to_string());
let mut args = vec![
"run".to_string(),
"-d".to_string(),
"--name".to_string(),
"nginx-proxy-manager".to_string(),
"--restart=unless-stopped".to_string(),
"--network=slirp4netns:allow_host_loopback=true".to_string(),
"--cap-drop=ALL".to_string(),
"--security-opt=no-new-privileges:true".to_string(),
"--pids-limit=4096".to_string(),
];
args.extend(get_app_capabilities("nginx-proxy-manager"));
args.extend([
"-p".to_string(),
"8081:81".to_string(),
"-p".to_string(),
"8084:80".to_string(),
"-p".to_string(),
"8444:443".to_string(),
"-v".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
"-v".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
"--memory".to_string(),
get_memory_limit("nginx-proxy-manager").to_string(),
"--cpus=2".to_string(),
]);
args.extend(get_health_check_args("nginx-proxy-manager", ""));
args.push(image);
let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
let output = podman_control(&refs).await?;
if !output.status.success() {
anyhow::bail!(
"podman run nginx-proxy-manager failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
let Some(port) = runtime_required_host_port(container_name) else {
return Ok(());
@@ -1075,15 +1166,17 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
"nextcloud" => cleanup_stale_pasta_port("8085").await,
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
"nginx-proxy-manager" => cleanup_nginx_proxy_manager_ports().await,
_ => {}
}
}
async fn cleanup_nginx_proxy_manager_ports() {
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
async fn cleanup_stale_pasta_port(port: &str) {
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
+4 -1
View File
@@ -80,7 +80,10 @@ impl EndpointRateLimiter {
limits.insert("backup.upload-s3".to_string(), (3, 600));
limits.insert("backup.download-s3".to_string(), (3, 600));
// System operations
limits.insert("update.apply".to_string(), (2, 600));
// Update apply is an authenticated local admin action. Keep a guard
// against accidental button storms without locking operators out for
// ten minutes during OTA troubleshooting.
limits.insert("update.apply".to_string(), (10, 60));
limits.insert("system.reboot".to_string(), (2, 300));
limits.insert("system.shutdown".to_string(), (2, 300));
// Password and TOTP changes
+29 -4
View File
@@ -840,6 +840,20 @@ const CONTAINER_ABSENCE_THRESHOLD: u32 = 3;
/// scanner's authoritative view. Applies to all transitional variants.
const TRANSITIONAL_STUCK_TIMEOUT: Duration = Duration::from_secs(120);
/// Multi-container installs can legitimately spend several minutes before the
/// primary user-facing container exists. BTCPay, for example, pulls/starts
/// Postgres and NBXplorer before `btcpay-server`; do not erase its installing
/// card just because the primary container is absent during that setup window.
const INSTALLING_STUCK_TIMEOUT: Duration = Duration::from_secs(20 * 60);
fn transitional_stuck_timeout(state: &crate::data_model::PackageState) -> Duration {
if *state == crate::data_model::PackageState::Installing {
INSTALLING_STUCK_TIMEOUT
} else {
TRANSITIONAL_STUCK_TIMEOUT
}
}
/// Returns true if `state` is one of the transitional variants that a
/// `spawn_transitional`-style background task owns. While such a state is
/// set, the package scanner must not overwrite it with whatever podman
@@ -961,13 +975,14 @@ async fn scan_and_update_packages(
let overwrite = match existing {
Some(existing_entry) if is_transitional(&existing_entry.state) => {
let entered = *transitional_since.entry(id.clone()).or_insert(now);
let stuck = now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT;
let timeout = transitional_stuck_timeout(&existing_entry.state);
let stuck = now.duration_since(entered) > timeout;
if stuck {
warn!(
"Container {} stuck in {:?} for >{}s; overriding with scan state {:?}",
id,
existing_entry.state,
TRANSITIONAL_STUCK_TIMEOUT.as_secs(),
timeout.as_secs(),
pkg.state
);
transitional_since.remove(id);
@@ -1015,12 +1030,13 @@ async fn scan_and_update_packages(
if let Some(entry) = merged.get(&id) {
if is_transitional(&entry.state) {
let entered = *transitional_since.entry(id.clone()).or_insert(now);
if now.duration_since(entered) > TRANSITIONAL_STUCK_TIMEOUT {
let timeout = transitional_stuck_timeout(&entry.state);
if now.duration_since(entered) > timeout {
warn!(
"Container {} stuck in {:?} and absent for >{}s; removing stale transitional state",
id,
entry.state,
TRANSITIONAL_STUCK_TIMEOUT.as_secs()
timeout.as_secs()
);
merged.remove(&id);
transitional_since.remove(&id);
@@ -1247,4 +1263,13 @@ mod merge_tests {
assert!(!is_transitional(&s), "{:?} should NOT be transitional", s);
}
}
#[test]
fn installing_uses_longer_stale_timeout_than_other_transitions() {
assert!(transitional_stuck_timeout(&PackageState::Installing) > TRANSITIONAL_STUCK_TIMEOUT);
assert_eq!(
transitional_stuck_timeout(&PackageState::Stopping),
TRANSITIONAL_STUCK_TIMEOUT
);
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.60-alpha",
"version": "1.7.65-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.60-alpha",
"version": "1.7.65-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.60-alpha",
"version": "1.7.65-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+14 -17
View File
@@ -1,29 +1,26 @@
{
"version": "1.7.60-alpha",
"version": "1.7.65-alpha",
"release_date": "2026-05-18",
"changelog": [
"Meshtastic serial detection now rejects malformed or incomplete handshakes instead of accepting unrelated serial devices as a fallback Meshtastic radio.",
"Mesh radio auto-detection now skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks, avoiding interference with production peripherals.",
"Meshtastic config sync now sends `want_config_id` with the correct protobuf wire type, fixing radio-side `ignore malformed toradio` errors and allowing node-info/contact ingestion.",
"The stable `/dev/mesh-radio` udev rule no longer claims every `ttyACM*` device; it only matches known mesh USB serial adapters and known USB CDC ACM radio vendors.",
"Live validation on `100.70.96.88` confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts."
"Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.",
"Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.60-alpha",
"new_version": "1.7.60-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.60-alpha/archipelago",
"sha256": "ef67a16686e1a5f05385455589c56b22243b690b79a914b8b341b3ff4f063be9",
"size_bytes": 42737160
"current_version": "1.7.65-alpha",
"new_version": "1.7.65-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.65-alpha/archipelago",
"sha256": "bc4197e99bdea6e63da19915c9cb9e06b84248fb2c627f2b5973e42532ef7b62",
"size_bytes": 42939312
},
{
"name": "archipelago-frontend-1.7.60-alpha.tar.gz",
"current_version": "1.7.60-alpha",
"new_version": "1.7.60-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.60-alpha/archipelago-frontend-1.7.60-alpha.tar.gz",
"sha256": "f781295c9dbd6a18098e63a73e0783dec403ca9598ab4b60defcb717bf1ef5f2",
"size_bytes": 166468694
"name": "archipelago-frontend-1.7.65-alpha.tar.gz",
"current_version": "1.7.65-alpha",
"new_version": "1.7.65-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.65-alpha/archipelago-frontend-1.7.65-alpha.tar.gz",
"sha256": "a76778934b747405410dfc92b1fffc2df4b44c0067f71652e23a86c9b77ab7ba",
"size_bytes": 166470001
}
]
}
+14 -17
View File
@@ -1,29 +1,26 @@
{
"version": "1.7.60-alpha",
"version": "1.7.65-alpha",
"release_date": "2026-05-18",
"changelog": [
"Meshtastic serial detection now rejects malformed or incomplete handshakes instead of accepting unrelated serial devices as a fallback Meshtastic radio.",
"Mesh radio auto-detection now skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks, avoiding interference with production peripherals.",
"Meshtastic config sync now sends `want_config_id` with the correct protobuf wire type, fixing radio-side `ignore malformed toradio` errors and allowing node-info/contact ingestion.",
"The stable `/dev/mesh-radio` udev rule no longer claims every `ttyACM*` device; it only matches known mesh USB serial adapters and known USB CDC ACM radio vendors.",
"Live validation on `100.70.96.88` confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts."
"Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.",
"Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.60-alpha",
"new_version": "1.7.60-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.60-alpha/archipelago",
"sha256": "ef67a16686e1a5f05385455589c56b22243b690b79a914b8b341b3ff4f063be9",
"size_bytes": 42737160
"current_version": "1.7.65-alpha",
"new_version": "1.7.65-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.65-alpha/archipelago",
"sha256": "bc4197e99bdea6e63da19915c9cb9e06b84248fb2c627f2b5973e42532ef7b62",
"size_bytes": 42939312
},
{
"name": "archipelago-frontend-1.7.60-alpha.tar.gz",
"current_version": "1.7.60-alpha",
"new_version": "1.7.60-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.60-alpha/archipelago-frontend-1.7.60-alpha.tar.gz",
"sha256": "f781295c9dbd6a18098e63a73e0783dec403ca9598ab4b60defcb717bf1ef5f2",
"size_bytes": 166468694
"name": "archipelago-frontend-1.7.65-alpha.tar.gz",
"current_version": "1.7.65-alpha",
"new_version": "1.7.65-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.65-alpha/archipelago-frontend-1.7.65-alpha.tar.gz",
"sha256": "a76778934b747405410dfc92b1fffc2df4b44c0067f71652e23a86c9b77ab7ba",
"size_bytes": 166470001
}
]
}
+22 -15
View File
@@ -28,11 +28,12 @@ for arg in "$@"; do
echo "Steps performed:"
echo " 1. Validate version format (SemVer)"
echo " 2. Bump version in Cargo.toml and package.json"
echo " 3. Build frontend"
echo " 4. Generate changelog from git log"
echo " 5. Create release manifest"
echo " 6. Commit version bump"
echo " 7. Create git tag v{VERSION}"
echo " 3. Build backend"
echo " 4. Build frontend"
echo " 5. Generate changelog from git log"
echo " 6. Create release manifest"
echo " 7. Commit version bump"
echo " 8. Create git tag v{VERSION}"
echo ""
echo "Options:"
echo " --dry-run Show what would be done without making changes"
@@ -95,11 +96,12 @@ if $DRY_RUN; then
echo "[DRY RUN] Would perform the following:"
echo " 1. Update core/archipelago/Cargo.toml version to $VERSION"
echo " 2. Update neode-ui/package.json version to $VERSION"
echo " 3. Build frontend (npm run build)"
echo " 4. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
echo " 5. Create release manifest"
echo " 6. Commit: 'chore: release v${VERSION}'"
echo " 7. Tag: v${VERSION}"
echo " 3. Build backend (cargo build --release -p archipelago)"
echo " 4. Build frontend (npm run build)"
echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}"
echo " 6. Create release manifest"
echo " 7. Commit: 'chore: release v${VERSION}'"
echo " 8. Tag: v${VERSION}"
echo ""
echo "After this script, you would:"
echo " - Push: git push && git push --tags"
@@ -123,12 +125,17 @@ cd "$PROJECT_ROOT/neode-ui"
npm version "$VERSION" --no-git-tag-version --allow-same-version 2>/dev/null || true
cd "$PROJECT_ROOT"
echo "[3/7] Building frontend..."
echo "[3/8] Building backend..."
cd "$PROJECT_ROOT/core"
cargo build --release -p archipelago
cd "$PROJECT_ROOT"
echo "[4/8] Building frontend..."
cd "$PROJECT_ROOT/neode-ui"
npm run build 2>&1 | tail -3
cd "$PROJECT_ROOT"
echo "[4/7] Validating curated changelog..."
echo "[5/8] Validating curated changelog..."
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"
RELEASE_DATE=$(date +%Y-%m-%d)
@@ -144,12 +151,12 @@ if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE"
exit 1
fi
echo "[5/7] Creating release manifest..."
echo "[6/8] Creating release manifest..."
mkdir -p "$PROJECT_ROOT/releases"
"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$"
cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json"
echo "[6/7] Committing version bump..."
echo "[7/8] Committing version bump..."
git -C "$PROJECT_ROOT" add \
core/archipelago/Cargo.toml \
neode-ui/package.json \
@@ -161,7 +168,7 @@ git -C "$PROJECT_ROOT" add \
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
echo "[7/7] Creating git tag..."
echo "[8/8] Creating git tag..."
git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}"
echo ""